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
These contracts are easy to write as tests before the implementation exists.
Testing Next.js API Routes
Test API route handlers directly — no HTTP server needed:
typescript
// app/api/posts/route.test.ts
import { POST, GET } from './route';
import { NextRequest } from 'next/server';
function createRequest(body: object, headers: Record<string, string> = {}) {
return new NextRequest('http://localhost/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
});
}TDD Cycle for a Create Post API
typescript
// Cycle 1: RED — validation
it('returns 400 when title is missing', async () => {
const req = createRequest({});
const res = await POST(req);
expect(res.status).toBe(400);
});
// GREEN — implement validation
export async function POST(req: NextRequest) {
const body = await req.json();
if (!body.title) {
return Response.json({ error: 'title is required' }, { status: 400 });
}
// ... to be implemented
}
// Cycle 2: RED — success case
it('returns 201 with created post', async () => {
const req = createRequest({ title: 'Test Post', content: 'Content here' });
const res = await POST(req);
expect(res.status).toBe(201);
const post = await res.json();
expect(post.title).toBe('Test Post');
expect(post.id).toBeDefined();
});
// GREEN — implement creation
export async function POST(req: NextRequest) {
const body = await req.json();
if (!body.title) {
return Response.json({ error: 'title is required' }, { status: 400 });
}
const post = await db.posts.create({ title: body.title, content: body.content });
return Response.json(post, { status: 201 });
}
// Cycle 3: RED — authorization
it('returns 401 when not authenticated', async () => {
const req = createRequest({ title: 'Test' }); // No auth header
const res = await POST(req);
expect(res.status).toBe(401);
});
// GREEN — add authentication check
export async function POST(req: NextRequest) {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await verifyToken(token);
if (!user) {
return Response.json({ error: 'Invalid token' }, { status: 401 });
}
// ... rest of handler
}Mocking the Database
For unit-level API tests, mock the database:
typescript
import { vi } from 'vitest';
import * as db from '@/lib/db';
vi.mock('@/lib/db', () => ({
posts: {
create: vi.fn(),
findById: vi.fn(),
},
}));
it('creates a post in the database', async () => {
const mockPost = { id: '1', title: 'Test', content: 'Content' };
vi.mocked(db.posts.create).mockResolvedValue(mockPost);
const req = createRequest({ title: 'Test', content: 'Content' }, authHeader);
const res = await POST(req);
expect(db.posts.create).toHaveBeenCalledWith({
title: 'Test',
content: 'Content',
userId: expect.any(String),
});
expect(res.status).toBe(201);
});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
typescript
// 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();
});
});Try it yourself — TYPESCRIPT