405 lines
12 KiB
Markdown
405 lines
12 KiB
Markdown
# Backend Testing Strategy
|
||
|
||
Comprehensive testing guide for backend and full-stack applications. Covers the full testing pyramid with deep focus on API integration tests, database testing, contract testing, and performance testing.
|
||
|
||
## Quick Start Checklist
|
||
|
||
- [ ] **Test runner configured** (Jest/Vitest, Pytest, Go test)
|
||
- [ ] **Test database** ready (Docker container or in-memory)
|
||
- [ ] **Database isolation** per test (transaction rollback or truncation)
|
||
- [ ] **Test factories** for common entities (user, order, product)
|
||
- [ ] **Auth helper** to generate tokens for tests
|
||
- [ ] **CI pipeline** runs tests with real database service
|
||
- [ ] **Coverage threshold** enforced (≥ 80%)
|
||
|
||
---
|
||
|
||
## The Testing Pyramid
|
||
|
||
```
|
||
╱╲ E2E (few, slow) — full flows across services
|
||
╱ ╲
|
||
╱────╲ Integration (moderate) — API + DB + external
|
||
╱ ╲
|
||
╱────────╲ Unit (many, fast) — pure business logic
|
||
╱__________╲
|
||
```
|
||
|
||
| Level | What | Speed | Count |
|
||
|-------|------|-------|-------|
|
||
| Unit | Pure functions, business logic, no I/O | < 10ms | 70%+ of tests |
|
||
| Integration | API routes + real database + mocked externals | 50-500ms | ~20% |
|
||
| E2E | Full user flow across deployed services | 1-30s | ~10% |
|
||
| Contract | API compatibility between services | < 100ms | Per API boundary |
|
||
| Performance | Load, stress, soak | Minutes | Per critical path |
|
||
|
||
---
|
||
|
||
## 1. API Integration Testing (CRITICAL)
|
||
|
||
### What to Test for Every Endpoint
|
||
|
||
| Aspect | Tests to Write |
|
||
|--------|---------------|
|
||
| Happy path | Correct input → expected response + correct DB state |
|
||
| Auth | No token → 401, bad token → 401, expired → 401 |
|
||
| Authorization | Wrong role → 403, not owner → 403 |
|
||
| Validation | Missing fields → 422, bad types → 422, boundary values |
|
||
| Not found | Invalid ID → 404, deleted resource → 404 |
|
||
| Conflict | Duplicate create → 409, stale update → 409 |
|
||
| Idempotency | Same request twice → same result |
|
||
| Side effects | DB state changed, events emitted, cache invalidated |
|
||
| Error format | All errors match RFC 9457 envelope |
|
||
|
||
### TypeScript (Jest + Supertest)
|
||
|
||
```typescript
|
||
describe('POST /api/orders', () => {
|
||
let token: string;
|
||
let product: Product;
|
||
|
||
beforeAll(async () => {
|
||
await resetDatabase();
|
||
const user = await createTestUser({ role: 'customer' });
|
||
token = await getAuthToken(user);
|
||
product = await createTestProduct({ price: 29.99, stock: 10 });
|
||
});
|
||
|
||
it('creates order → 201 + correct DB state', async () => {
|
||
const res = await request(app)
|
||
.post('/api/orders')
|
||
.set('Authorization', `Bearer ${token}`)
|
||
.send({ items: [{ productId: product.id, quantity: 2 }] });
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(res.body.data.total).toBe(59.98);
|
||
|
||
const updated = await db.product.findUnique({ where: { id: product.id } });
|
||
expect(updated!.stock).toBe(8);
|
||
});
|
||
|
||
it('rejects without auth → 401', async () => {
|
||
const res = await request(app).post('/api/orders').send({ items: [] });
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('rejects empty items → 422', async () => {
|
||
const res = await request(app)
|
||
.post('/api/orders')
|
||
.set('Authorization', `Bearer ${token}`)
|
||
.send({ items: [] });
|
||
expect(res.status).toBe(422);
|
||
expect(res.body.errors[0].field).toBe('items');
|
||
});
|
||
});
|
||
```
|
||
|
||
### Python (Pytest + FastAPI TestClient)
|
||
|
||
```python
|
||
@pytest.fixture
|
||
def client(db_session):
|
||
def override_get_db():
|
||
yield db_session
|
||
app.dependency_overrides[get_db] = override_get_db
|
||
yield TestClient(app)
|
||
app.dependency_overrides.clear()
|
||
|
||
def test_create_order_success(client, auth_headers, test_product):
|
||
response = client.post("/api/orders", json={
|
||
"items": [{"product_id": test_product.id, "quantity": 2}]
|
||
}, headers=auth_headers)
|
||
assert response.status_code == 201
|
||
assert response.json()["data"]["total"] == 59.98
|
||
|
||
def test_create_order_no_auth(client):
|
||
response = client.post("/api/orders", json={"items": []})
|
||
assert response.status_code == 401
|
||
|
||
def test_create_order_empty_items(client, auth_headers):
|
||
response = client.post("/api/orders", json={"items": []}, headers=auth_headers)
|
||
assert response.status_code == 422
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Database Testing (HIGH)
|
||
|
||
### Test Isolation Strategies
|
||
|
||
| Strategy | Speed | Realism | When |
|
||
|----------|-------|---------|------|
|
||
| **Transaction rollback** | ⚡ Fastest | Medium | Default for unit + integration |
|
||
| **Truncation** | Fast | High | When rollback isn't possible |
|
||
| **Test containers** | Slow startup | Highest | CI pipeline, full integration |
|
||
|
||
**Transaction rollback (recommended default):**
|
||
```typescript
|
||
let tx: Transaction;
|
||
beforeEach(async () => { tx = await db.beginTransaction(); });
|
||
afterEach(async () => { await tx.rollback(); });
|
||
```
|
||
|
||
**Docker test containers (CI):**
|
||
```yaml
|
||
# docker-compose.test.yml
|
||
services:
|
||
test-db:
|
||
image: postgres:16-alpine
|
||
tmpfs: /var/lib/postgresql/data # RAM disk for speed
|
||
environment:
|
||
POSTGRES_DB: myapp_test
|
||
```
|
||
|
||
### Test Factories (Not Raw SQL)
|
||
|
||
```typescript
|
||
// factories/user.factory.ts
|
||
import { faker } from '@faker-js/faker';
|
||
|
||
export function buildUser(overrides: Partial<User> = {}): CreateUserDTO {
|
||
return {
|
||
email: faker.internet.email(),
|
||
firstName: faker.person.firstName(),
|
||
role: 'customer',
|
||
...overrides,
|
||
};
|
||
}
|
||
export async function createUser(overrides = {}) {
|
||
return db.user.create({ data: buildUser(overrides) });
|
||
}
|
||
```
|
||
|
||
```python
|
||
# factories/user_factory.py
|
||
import factory
|
||
from faker import Faker
|
||
|
||
class UserFactory(factory.Factory):
|
||
class Meta:
|
||
model = User
|
||
email = factory.LazyAttribute(lambda _: Faker().email())
|
||
first_name = factory.LazyAttribute(lambda _: Faker().first_name())
|
||
role = "customer"
|
||
```
|
||
|
||
---
|
||
|
||
## 3. External Service Testing (HIGH)
|
||
|
||
### HTTP-Level Mocking (Not Function Mocking)
|
||
|
||
**TypeScript (nock):**
|
||
```typescript
|
||
import nock from 'nock';
|
||
|
||
it('processes payment successfully', async () => {
|
||
nock('https://api.stripe.com')
|
||
.post('/v1/charges')
|
||
.reply(200, { id: 'ch_123', status: 'succeeded', amount: 5000 });
|
||
|
||
const result = await paymentService.charge({ amount: 50.00, currency: 'usd' });
|
||
expect(result.status).toBe('succeeded');
|
||
});
|
||
|
||
it('handles payment timeout', async () => {
|
||
nock('https://api.stripe.com').post('/v1/charges').delay(10000).reply(200);
|
||
await expect(paymentService.charge({ amount: 50, currency: 'usd' }))
|
||
.rejects.toThrow('timeout');
|
||
});
|
||
```
|
||
|
||
**Python (responses):**
|
||
```python
|
||
import responses
|
||
|
||
@responses.activate
|
||
def test_payment_success():
|
||
responses.post("https://api.stripe.com/v1/charges",
|
||
json={"id": "ch_123", "status": "succeeded"}, status=200)
|
||
result = payment_service.charge(amount=50.00, currency="usd")
|
||
assert result.status == "succeeded"
|
||
```
|
||
|
||
### Test Containers for Infrastructure
|
||
|
||
```typescript
|
||
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
||
import { RedisContainer } from '@testcontainers/redis';
|
||
|
||
beforeAll(async () => {
|
||
const pg = await new PostgreSqlContainer('postgres:16').start();
|
||
process.env.DATABASE_URL = pg.getConnectionUri();
|
||
await runMigrations();
|
||
}, 60000);
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Contract Testing (MEDIUM-HIGH)
|
||
|
||
### Consumer-Driven Contracts (Pact)
|
||
|
||
**Consumer (OrderService calls UserService):**
|
||
```typescript
|
||
it('can fetch user by ID', async () => {
|
||
await pact.addInteraction()
|
||
.given('user usr_123 exists')
|
||
.uponReceiving('GET /users/usr_123')
|
||
.withRequest('GET', '/api/users/usr_123')
|
||
.willRespondWith(200, (b) => {
|
||
b.jsonBody({ data: { id: MatchersV3.string(), email: MatchersV3.email() } });
|
||
})
|
||
.executeTest(async (mockserver) => {
|
||
const user = await new UserClient(mockserver.url).getUser('usr_123');
|
||
expect(user.id).toBeDefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
**Provider verifies in CI:**
|
||
```typescript
|
||
await new Verifier({
|
||
providerBaseUrl: 'http://localhost:3001',
|
||
pactBrokerUrl: process.env.PACT_BROKER_URL,
|
||
provider: 'UserService',
|
||
}).verifyProvider();
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Performance Testing (MEDIUM)
|
||
|
||
### k6 Load Test
|
||
|
||
```javascript
|
||
import http from 'k6/http';
|
||
import { check, sleep } from 'k6';
|
||
|
||
export const options = {
|
||
stages: [
|
||
{ duration: '30s', target: 20 }, // ramp up
|
||
{ duration: '1m', target: 100 }, // sustain
|
||
{ duration: '30s', target: 0 }, // ramp down
|
||
],
|
||
thresholds: {
|
||
http_req_duration: ['p(95)<500', 'p(99)<1000'],
|
||
http_req_failed: ['rate<0.01'],
|
||
},
|
||
};
|
||
|
||
export default function () {
|
||
const res = http.get(`${__ENV.BASE_URL}/api/orders`);
|
||
check(res, { 'status 200': (r) => r.status === 200 });
|
||
sleep(1);
|
||
}
|
||
```
|
||
|
||
### Performance Budgets
|
||
|
||
| Metric | Target | Action if Exceeded |
|
||
|--------|--------|--------------------|
|
||
| p95 response time | < 500ms | Optimize queries/caching |
|
||
| p99 response time | < 1000ms | Check outlier queries |
|
||
| Error rate | < 0.1% | Investigate spikes |
|
||
| DB query time | < 100ms each | Add indexes |
|
||
|
||
### When to Run
|
||
|
||
| Trigger | Test Type |
|
||
|---------|-----------|
|
||
| Before major release | Full load test |
|
||
| New DB query/index | Query benchmark |
|
||
| Infrastructure change | Baseline comparison |
|
||
| Weekly (CI) | Smoke load test |
|
||
|
||
---
|
||
|
||
## Test File Organization
|
||
|
||
```
|
||
tests/
|
||
unit/ # Pure logic, mocked dependencies
|
||
order.service.test.ts
|
||
integration/ # API + real DB
|
||
orders.api.test.ts
|
||
auth.api.test.ts
|
||
contracts/ # Consumer-driven contracts
|
||
user-service.consumer.pact.ts
|
||
performance/ # Load tests
|
||
load-test.js
|
||
fixtures/
|
||
factories/ # Test data factories
|
||
user.factory.ts
|
||
seeds/
|
||
test-data.ts
|
||
helpers/
|
||
setup.ts # Global test config
|
||
auth.helper.ts # Token generation
|
||
db.helper.ts # DB cleanup
|
||
```
|
||
|
||
---
|
||
|
||
## Anti-Patterns
|
||
|
||
| # | ❌ Don't | ✅ Do Instead |
|
||
|---|---------|--------------|
|
||
| 1 | Test only happy paths | Test errors, auth, validation, edge cases |
|
||
| 2 | Mock everything (no real DB) | Use test containers or test DB |
|
||
| 3 | Tests depend on execution order | Each test sets up / tears down own state |
|
||
| 4 | Hardcode test data | Use factories (faker + overrides) |
|
||
| 5 | Test implementation details | Test behavior: input → output |
|
||
| 6 | Share mutable state | Isolate per test (transaction rollback) |
|
||
| 7 | Skip migration testing in CI | Run migrations from scratch in CI |
|
||
| 8 | No performance test before release | Load test every major release |
|
||
| 9 | Test against production data | Generated test data only |
|
||
| 10 | Test suite > 10 minutes | Parallelize, RAM disk, optimize setup |
|
||
|
||
---
|
||
|
||
## Common Issues
|
||
|
||
### Issue 1: "Tests pass alone but fail together"
|
||
|
||
**Cause:** Shared database state between tests. Missing cleanup.
|
||
|
||
**Fix:**
|
||
```typescript
|
||
beforeEach(async () => { await db.raw('TRUNCATE orders, users CASCADE'); });
|
||
// OR use transaction rollback per test
|
||
```
|
||
|
||
### Issue 2: "Jest did not exit one second after test run"
|
||
|
||
**Cause:** Unclosed database connections or HTTP servers.
|
||
|
||
**Fix:**
|
||
```typescript
|
||
afterAll(async () => {
|
||
await db.destroy();
|
||
await server.close();
|
||
});
|
||
```
|
||
|
||
### Issue 3: "Async callback was not invoked within timeout"
|
||
|
||
**Cause:** Missing `async/await` or unhandled promise.
|
||
|
||
**Fix:**
|
||
```typescript
|
||
// ❌ Promise not awaited
|
||
it('should work', () => { request(app).get('/users'); });
|
||
|
||
// ✅ Properly awaited
|
||
it('should work', async () => { await request(app).get('/users'); });
|
||
```
|
||
|
||
### Issue 4: "Integration tests too slow in CI"
|
||
|
||
**Fix:**
|
||
1. Use `tmpfs` for PostgreSQL data dir (RAM disk)
|
||
2. Run migrations once in `beforeAll`, truncate in `beforeEach`
|
||
3. Parallelize test suites with `--maxWorkers`
|
||
4. Skip performance tests on feature branches (only main)
|