TDD Patterns by Domain¶
How to Use This Page¶
Each pattern follows the same structure:
- Context — When this pattern applies
- Spec Elements — What Anna's formal spec provides for this domain
- Write Test First — What Antje writes before any implementation exists
- Implement to Pass — What the developer writes to make the test green
- Integration Check — What Marije/Judith verify in the real environment
RULE: Every pattern starts with the spec, not with the implementation idea. RULE: Code examples show the TEST first, then the implementation. This ordering is intentional.
Pattern 1: API Endpoint — Request/Response Contract¶
Context¶
Any new REST or GraphQL endpoint. The most common pattern in GE client projects.
Spec Elements from Anna¶
- Endpoint path and HTTP method
- Request schema (required fields, types, constraints)
- Response schema (shape, status codes, error formats)
- Pre-conditions (authentication, authorization, valid input)
- Post-conditions (database state, side effects)
- Edge cases (empty body, malformed JSON, oversized payload)
Write Test First (Antje)¶
// tests/api/create-invoice.test.ts
// RED: All tests fail — endpoint does not exist yet
describe('POST /api/invoices', () => {
// From spec: Pre-condition — must be authenticated
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app).post('/api/invoices').send(validInvoice);
expect(res.status).toBe(401);
});
// From spec: Pre-condition — must have invoicing permission
it('rejects unauthorized users with 403', async () => {
const res = await authedRequest('viewer').post('/api/invoices').send(validInvoice);
expect(res.status).toBe(403);
});
// From spec: Post-condition — invoice created with correct fields
it('creates invoice and returns 201 with invoice ID', async () => {
const res = await authedRequest('admin').post('/api/invoices').send(validInvoice);
expect(res.status).toBe(201);
expect(res.body.id).toMatch(/^INV-/);
expect(res.body.status).toBe('draft');
});
// From spec: Post-condition — database state
it('persists invoice to database', async () => {
const res = await authedRequest('admin').post('/api/invoices').send(validInvoice);
const dbRecord = await db.query.invoices.findFirst({
where: eq(invoices.id, res.body.id),
});
expect(dbRecord).toBeDefined();
expect(dbRecord.amount).toBe(validInvoice.amount);
});
// From spec: Invariant — amount must be positive
it('rejects zero amount with 400', async () => {
const res = await authedRequest('admin')
.post('/api/invoices')
.send({ ...validInvoice, amount: 0 });
expect(res.status).toBe(400);
expect(res.body.error).toContain('amount');
});
// From spec: Edge case — duplicate idempotency key
it('returns existing invoice for duplicate idempotency key', async () => {
const key = 'idem-123';
const res1 = await authedRequest('admin')
.post('/api/invoices')
.set('Idempotency-Key', key)
.send(validInvoice);
const res2 = await authedRequest('admin')
.post('/api/invoices')
.set('Idempotency-Key', key)
.send(validInvoice);
expect(res1.body.id).toBe(res2.body.id);
expect(res2.status).toBe(201);
});
});
Implement to Pass (Developer)¶
// app/api/invoices/route.ts
// GREEN: Minimal implementation to pass all tests above
export async function POST(req: NextRequest) {
const session = await getSession(req);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (!session.permissions.includes('invoicing'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const parsed = invoiceSchema.safeParse(body);
if (!parsed.success)
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const idempotencyKey = req.headers.get('Idempotency-Key');
if (idempotencyKey) {
const existing = await db.query.invoices.findFirst({
where: eq(invoices.idempotencyKey, idempotencyKey),
});
if (existing) return NextResponse.json(existing, { status: 201 });
}
const invoice = await db.insert(invoices).values({
...parsed.data,
id: `INV-${nanoid()}`,
status: 'draft',
idempotencyKey,
}).returning();
return NextResponse.json(invoice[0], { status: 201 });
}
Integration Check (Marije/Judith)¶
CHECK: Does the endpoint work through the real network path? - Curl from outside the pod to the ingress - Verify the database row exists after creation - Verify the audit log captured the event
Pattern 2: React Component — Render + Interaction¶
Context¶
Any new UI component. GE uses React with TypeScript.
Spec Elements from Anna¶
- Component props and their types
- Render states (loading, empty, populated, error)
- User interactions (click, type, submit)
- Accessibility requirements (ARIA roles, keyboard navigation)
- Side effects (API calls on interaction, state updates)
Write Test First (Antje)¶
// tests/components/invoice-list.test.tsx
// RED: Component does not exist yet
describe('InvoiceList', () => {
// From spec: Render state — loading
it('shows loading skeleton while fetching', () => {
render(<InvoiceList />);
expect(screen.getByTestId('invoice-skeleton')).toBeInTheDocument();
});
// From spec: Render state — empty
it('shows empty state when no invoices exist', async () => {
server.use(rest.get('/api/invoices', (_, res, ctx) => res(ctx.json([]))));
render(<InvoiceList />);
await waitFor(() => {
expect(screen.getByText('No invoices yet')).toBeInTheDocument();
});
});
// From spec: Render state — populated
it('renders invoice rows with amount and status', async () => {
server.use(rest.get('/api/invoices', (_, res, ctx) =>
res(ctx.json([{ id: 'INV-1', amount: 1500, status: 'draft' }]))
));
render(<InvoiceList />);
await waitFor(() => {
expect(screen.getByText('INV-1')).toBeInTheDocument();
expect(screen.getByText('$1,500.00')).toBeInTheDocument();
expect(screen.getByText('Draft')).toBeInTheDocument();
});
});
// From spec: User interaction — delete with confirmation
it('shows confirmation dialog before deleting', async () => {
const user = userEvent.setup();
render(<InvoiceList invoices={[mockInvoice]} />);
await user.click(screen.getByRole('button', { name: /delete/i }));
expect(screen.getByText('Are you sure?')).toBeInTheDocument();
});
// From spec: Accessibility
it('supports keyboard navigation between rows', async () => {
const user = userEvent.setup();
render(<InvoiceList invoices={mockInvoices} />);
await user.tab();
expect(screen.getAllByRole('row')[0]).toHaveFocus();
await user.keyboard('{ArrowDown}');
expect(screen.getAllByRole('row')[1]).toHaveFocus();
});
});
Implement to Pass (Developer)¶
The developer builds the component to satisfy each test. Loading skeleton first (passes test 1), then empty state (test 2), then data rendering (test 3), then interaction (test 4), then accessibility (test 5).
Integration Check (Marije/Judith)¶
CHECK: Does the component render correctly with real API data? - Mount in staging environment with real backend - Verify loading states with network throttling - Verify error states with backend errors
Pattern 3: Database — Migration + Query Testing¶
Context¶
Any schema change or new query pattern.
Spec Elements from Anna¶
- Table schema (columns, types, constraints, indexes)
- Migration behavior (up and down)
- Query contracts (inputs, outputs, performance bounds)
- Data integrity invariants (foreign keys, unique constraints, check constraints)
Write Test First (Antje)¶
// tests/db/invoice-migration.test.ts
// RED: Migration does not exist yet
describe('Invoice table migration', () => {
// From spec: Migration creates table with correct schema
it('creates invoices table with required columns', async () => {
await runMigration('0005_create_invoices');
const columns = await getTableColumns('invoices');
expect(columns).toContainEqual({ name: 'id', type: 'text', nullable: false });
expect(columns).toContainEqual({ name: 'amount', type: 'integer', nullable: false });
expect(columns).toContainEqual({ name: 'status', type: 'text', nullable: false });
expect(columns).toContainEqual({ name: 'created_at', type: 'timestamp', nullable: false });
});
// From spec: Invariant — amount check constraint
it('enforces positive amount constraint', async () => {
await expect(
db.insert(invoices).values({ id: 'INV-1', amount: -100, status: 'draft' })
).rejects.toThrow(/check/i);
});
// From spec: Invariant — status enum constraint
it('enforces valid status values', async () => {
await expect(
db.insert(invoices).values({ id: 'INV-1', amount: 100, status: 'invalid' })
).rejects.toThrow();
});
// From spec: Migration rollback
it('rolls back cleanly', async () => {
await runMigration('0005_create_invoices');
await rollbackMigration('0005_create_invoices');
const tables = await listTables();
expect(tables).not.toContain('invoices');
});
});
describe('Invoice queries', () => {
// From spec: Query contract — list by client
it('returns only invoices for the specified client', async () => {
await seedInvoices([
{ clientId: 'C1', amount: 100 },
{ clientId: 'C2', amount: 200 },
]);
const result = await getInvoicesByClient('C1');
expect(result).toHaveLength(1);
expect(result[0].clientId).toBe('C1');
});
// From spec: Query performance bound
it('completes listing within 100ms for 10k records', async () => {
await seedBulkInvoices(10_000);
const start = performance.now();
await getInvoicesByClient('C1');
expect(performance.now() - start).toBeLessThan(100);
});
});
Implement to Pass (Developer)¶
Developer writes the migration SQL and query functions to satisfy each test.
Integration Check (Marije/Judith)¶
CHECK: Does the migration run against a real PostgreSQL instance? CHECK: Does the migration not break existing data? CHECK: Do queries perform under load?
Pattern 4: Authentication Flow¶
Context¶
Login, registration, session management, token refresh.
Spec Elements from Anna¶
- Authentication method (WebAuthn, password, OAuth)
- Session lifecycle (creation, validation, expiry, revocation)
- Security invariants (timing-safe comparison, rate limiting, CSRF)
- Error responses (generic messages, no information leakage)
Write Test First (Antje)¶
describe('WebAuthn login flow', () => {
// From spec: Challenge generation
it('returns challenge in httpOnly cookie', async () => {
const res = await request(app).post('/api/auth/challenge');
expect(res.status).toBe(200);
expect(res.headers['set-cookie']).toMatch(/challenge=.*HttpOnly/);
});
// From spec: Verification reads from cookie, not body
it('reads challenge from cookie during verification', async () => {
const challengeRes = await request(app).post('/api/auth/challenge');
const cookie = challengeRes.headers['set-cookie'];
const verifyRes = await request(app)
.post('/api/auth/verify')
.set('Cookie', cookie)
.send(validCredential);
expect(verifyRes.status).toBe(200);
});
// From spec: Security invariant — no challenge reuse
it('rejects reused challenge', async () => {
const challengeRes = await request(app).post('/api/auth/challenge');
const cookie = challengeRes.headers['set-cookie'];
await request(app).post('/api/auth/verify').set('Cookie', cookie).send(validCredential);
const reuse = await request(app).post('/api/auth/verify').set('Cookie', cookie).send(validCredential);
expect(reuse.status).toBe(400);
});
// From spec: Rate limiting
it('blocks after 5 failed attempts in 15 minutes', async () => {
for (let i = 0; i < 5; i++) {
await request(app).post('/api/auth/verify').send(invalidCredential);
}
const res = await request(app).post('/api/auth/verify').send(validCredential);
expect(res.status).toBe(429);
});
// From spec: Error message — no information leakage
it('returns generic error for invalid credentials', async () => {
const res = await request(app).post('/api/auth/verify').send(invalidCredential);
expect(res.body.error).toBe('Authentication failed');
expect(res.body).not.toHaveProperty('reason');
});
});
ANTI_PATTERN: Testing auth with hardcoded tokens or bypassed middleware. FIX: Always test through the real auth middleware. Mock the credential, not the middleware.
Pattern 5: File Upload¶
Context¶
Any file upload endpoint (documents, images, attachments).
Spec Elements from Anna¶
- Allowed file types and size limits
- Storage destination (S3, local, database)
- Virus scanning requirements
- Metadata extraction
- Access control on uploaded files
Write Test First (Antje)¶
describe('POST /api/uploads', () => {
it('accepts valid PDF under size limit', async () => {
const res = await authedRequest('admin')
.post('/api/uploads')
.attach('file', validPdfBuffer, 'invoice.pdf');
expect(res.status).toBe(201);
expect(res.body.contentType).toBe('application/pdf');
});
it('rejects files exceeding 10MB', async () => {
const res = await authedRequest('admin')
.post('/api/uploads')
.attach('file', oversizedBuffer, 'large.pdf');
expect(res.status).toBe(413);
});
it('rejects disallowed file types', async () => {
const res = await authedRequest('admin')
.post('/api/uploads')
.attach('file', exeBuffer, 'malware.exe');
expect(res.status).toBe(415);
});
it('strips EXIF data from images', async () => {
const res = await authedRequest('admin')
.post('/api/uploads')
.attach('file', imageWithExif, 'photo.jpg');
const stored = await getStoredFile(res.body.id);
expect(extractExif(stored)).toEqual({});
});
});
Pattern 6: Webhook Handling¶
Context¶
Receiving webhooks from external services (Stripe, GitHub, etc.).
Spec Elements from Anna¶
- Signature verification method
- Idempotent processing (same webhook delivered twice)
- Timeout and retry behavior
- Event type routing
Write Test First (Antje)¶
describe('POST /api/webhooks/stripe', () => {
it('verifies webhook signature', async () => {
const payload = JSON.stringify(stripeEvent);
const signature = computeStripeSignature(payload, webhookSecret);
const res = await request(app)
.post('/api/webhooks/stripe')
.set('stripe-signature', signature)
.send(payload);
expect(res.status).toBe(200);
});
it('rejects invalid signature with 400', async () => {
const res = await request(app)
.post('/api/webhooks/stripe')
.set('stripe-signature', 'invalid')
.send(JSON.stringify(stripeEvent));
expect(res.status).toBe(400);
});
it('processes same event idempotently', async () => {
const payload = JSON.stringify(stripeEvent);
const sig = computeStripeSignature(payload, webhookSecret);
await request(app).post('/api/webhooks/stripe').set('stripe-signature', sig).send(payload);
await request(app).post('/api/webhooks/stripe').set('stripe-signature', sig).send(payload);
const orders = await db.query.orders.findMany({
where: eq(orders.stripeEventId, stripeEvent.id),
});
expect(orders).toHaveLength(1);
});
it('returns 200 quickly and processes asynchronously', async () => {
const start = performance.now();
const res = await request(app)
.post('/api/webhooks/stripe')
.set('stripe-signature', validSig)
.send(JSON.stringify(longRunningEvent));
expect(res.status).toBe(200);
expect(performance.now() - start).toBeLessThan(500);
});
});
Pattern 7: Real-Time Features (WebSocket/SSE)¶
Context¶
Live updates, notifications, chat, presence indicators.
Spec Elements from Anna¶
- Connection lifecycle (open, message, close, reconnect)
- Message format and validation
- Authorization on connection and per-message
- Backpressure handling
Write Test First (Antje)¶
describe('WebSocket /ws/notifications', () => {
it('requires authentication on connection', async () => {
const ws = new WebSocket('ws://localhost/ws/notifications');
await expect(waitForClose(ws)).resolves.toMatchObject({ code: 1008 });
});
it('sends notification when invoice is created', async () => {
const ws = await connectAuthenticated('/ws/notifications');
await createInvoice(validInvoice);
const msg = await waitForMessage(ws);
expect(JSON.parse(msg)).toMatchObject({
type: 'invoice.created',
data: { id: expect.stringMatching(/^INV-/) },
});
ws.close();
});
it('reconnects automatically after disconnect', async () => {
const ws = await connectAuthenticated('/ws/notifications');
ws.terminate(); // Force disconnect
const ws2 = await connectAuthenticated('/ws/notifications');
expect(ws2.readyState).toBe(WebSocket.OPEN);
ws2.close();
});
});
Pattern 8: State Machine Transitions¶
Context¶
Any entity with defined states and transitions (orders, invoices, tickets, deployments).
Spec Elements from Anna¶
- State diagram (all states, all valid transitions)
- Guard conditions on each transition
- Side effects of each transition
- Invalid transition behavior
Write Test First (Antje)¶
describe('Order state machine', () => {
// From spec: Valid transition
it('transitions from draft to submitted', async () => {
const order = await createOrder({ status: 'draft' });
const result = await transitionOrder(order.id, 'submit');
expect(result.status).toBe('submitted');
});
// From spec: Invalid transition
it('rejects transition from draft to shipped', async () => {
const order = await createOrder({ status: 'draft' });
await expect(transitionOrder(order.id, 'ship')).rejects.toThrow('Invalid transition');
});
// From spec: Guard condition
it('requires payment before shipping', async () => {
const order = await createOrder({ status: 'approved', paymentStatus: 'pending' });
await expect(transitionOrder(order.id, 'ship')).rejects.toThrow('Payment required');
});
// From spec: Side effect
it('sends notification email on approval', async () => {
const order = await createOrder({ status: 'submitted' });
await transitionOrder(order.id, 'approve');
expect(emailSpy).toHaveBeenCalledWith(
expect.objectContaining({ template: 'order-approved', orderId: order.id })
);
});
// From spec: Exhaustive invalid transitions
const invalidTransitions = [
['draft', 'approve'], ['draft', 'ship'], ['draft', 'deliver'],
['submitted', 'ship'], ['submitted', 'deliver'],
['approved', 'submit'],
['shipped', 'submit'], ['shipped', 'approve'],
];
it.each(invalidTransitions)('rejects %s → %s', async (from, action) => {
const order = await createOrder({ status: from });
await expect(transitionOrder(order.id, action)).rejects.toThrow('Invalid transition');
});
});
Pattern Summary¶
| Domain | Key Test Focus | Common Mistake |
|---|---|---|
| API endpoints | Request/response contract, auth, idempotency | Testing handler in isolation without middleware |
| React components | Render states, interactions, accessibility | Testing implementation details (state values) |
| Database | Migration correctness, constraints, query performance | Testing with in-memory DB instead of real PostgreSQL |
| Auth flows | Security invariants, rate limiting, no info leakage | Bypassing auth middleware in tests |
| File upload | Type validation, size limits, metadata stripping | Not testing with real file buffers |
| Webhooks | Signature verification, idempotency, async processing | Testing without signature verification |
| Real-time | Connection lifecycle, auth, message format | Not testing reconnection behavior |
| State machines | Valid/invalid transitions, guards, side effects | Testing only happy path transitions |
Related Documentation¶
- TDD Philosophy — Why TDD is mandatory in GE
- TDD Workflow — Full pipeline with decision trees
- Agentic TDD — AI-specific TDD considerations
- TDD Pitfalls — Common mistakes and anti-patterns