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