Test-Driven Development
TDD for API Routes and Server Actions
Apply TDD to backend code — where precise input/output definitions make test-first development natural.
Backend TDD: Where It Shines
TDD for API routes is where the methodology produces the clearest benefits. API behavior is perfectly expressed as input/output contracts:
- Given this input → expect this response code and body
- Given invalid input → expect this error response
- Given unauthenticated request → expect 401
Testing Next.js API Routes
Test API route handlers directly — no HTTP server needed. Import the route handler function and call it with a mock NextRequest object.
TDD Cycle for a Create Post API
Build the API incrementally with TDD:
- RED: Test that missing title returns 400
- GREEN: Implement validation
- RED: Test success case returns 201 with post
- GREEN: Implement creation
- RED: Test unauthenticated returns 401
- GREEN: Add authentication check
Mocking the Database
For unit-level API tests, mock the database using vi.mock(). Verify both the response status and the database call arguments. For integration tests, use a real test database.
Key Takeaways
- API routes are the ideal target for TDD — behavior maps perfectly to input/output contracts
- Test route handlers directly by calling the exported function with a mock NextRequest
- Write one test per requirement: validation, success, authorization, error cases
- Mock the database for unit tests; use a real test database for integration tests
- TDD naturally produces comprehensive API route tests covering all HTTP status codes
Example
// TDD for a "create comment" API route
describe('POST /api/comments', () => {
it('returns 400 for missing content', async () => {
const res = await POST(createRequest({}));
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: expect.stringContaining('content') });
});
it('returns 401 when unauthenticated', async () => {
const res = await POST(createRequest({ content: 'Hello' }));
expect(res.status).toBe(401);
});
it('creates comment and returns 201', async () => {
const res = await POST(createRequest(
{ content: 'Great post!', postId: 'post-1' },
{ authorization: 'Bearer valid-token' }
));
expect(res.status).toBe(201);
const comment = await res.json();
expect(comment.content).toBe('Great post!');
expect(comment.id).toBeDefined();
});
});// TDD for a "create comment" API route
describe('POST /api/comments', () => {
it('returns 400 for missing content', async () => {
const res = await POST(createRequest({}));
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: expect.stringContaining('content') });
});
it('returns 401 when unauthenticated', async () => {
const res = await POST(createRequest({ content: 'Hello' }));
expect(res.status).toBe(401);
});
it('creates comment and returns 201', async () => {
const res = await POST(createRequest(
{ content: 'Great post!', postId: 'post-1' },
{ authorization: 'Bearer valid-token' }
));
expect(res.status).toBe(201);
const comment = await res.json();
expect(comment.content).toBe('Great post!');
expect(comment.id).toBeDefined();
});
});