Applied Module 12 Β· AI-Accelerated Government Development

Test Server Logic with Vitest

What you'll learn

~30 min
  • Configure Vitest with environment switching for Node API routes and jsdom component tests
  • Test route handlers by mocking NextRequest and verifying response helpers
  • Verify withPermission() enforcement returns 403 for unauthorized roles
  • Write unit tests for pagination calculation, Zod schema validation, and safe error formatting

The problem with untested API routes

Every route handler in the DS platform does four things: authenticate, authorize, validate, respond. If any of those steps breaks, your users see a 500 or β€” worse β€” get data they should not have. The state auditor does not care that your manual QA passed. They want to see that withPermission('operator') actually blocks a viewer from mutating records.

Vitest gives you sub-second feedback on all of it. But the DS platform has a split personality: API routes run in Node (they talk to Azure SQL via mssql), while React components render in a browser-like environment. Getting that environment switching right is the first thing to nail.

πŸ’¬Why not Jest?

Jest works. You have used it. But Vitest is ESM-native, shares your Vite config, and runs faster on a Next.js 15 codebase. The DS platform already uses Vite for tooling β€” Vitest slots in without a second bundler. If your team has an existing Jest suite, the migration is straightforward (the API surface is nearly identical), but new tests should go in Vitest.


Configure Vitest for the platform

The key decision: which test files run in Node, and which run in jsdom? The answer is directory-based.

Here is the prompt:

Generate a vitest.config.ts for our Next.js 15.5 project. Requirements:
1. Default environment: 'node' (API routes, helpers, middleware)
2. Override: any file under __tests__/components/ or matching *.component.test.ts
should use @vitest-environment jsdom
3. Path aliases matching our tsconfig: @ -> src/, @/lib -> src/lib/, etc.
4. Exclude node_modules and .next from test discovery
5. Coverage provider: v8, thresholds: 80% statements, 70% branches
6. Setup file: src/test/setup.ts that mocks next/headers (cookies, headers)
and @azure/msal-node (acquireTokenByClientCredential returns a dummy token)
7. Global timeout: 10 seconds per test (our Azure SQL mocks should never hit network)
The project uses mssql for database, @azure/msal-node for auth, next-auth v5,
and Zod v3 for validation. All API route handlers use our custom response helpers
(listResponse, errorResponse, successResponse) from src/lib/api/responses.ts.
Claude Code β€” Vitest Configuration
/home/dev/ds-platform $ claude 'Generate a vitest.config.ts for our Next.js 15.5 project with Node default environment and jsdom override for component tests'
I'll create the Vitest configuration and test setup. Created: vitest.config.ts (42 lines) Created: src/test/setup.ts (38 lines) Updated: package.json β€” added vitest, @vitest/coverage-v8, jsdom to devDependencies The config uses: - Default: 'node' environment for API routes - environmentMatchGlobs for component tests β†’ jsdom - Path aliases from your tsconfig.json - v8 coverage with 80/70 thresholds - Global 10s timeout Setup file mocks next/headers and @azure/msal-node.
/home/dev/ds-platform $ npx vitest --run --reporter=verbose
βœ“ src/lib/api/__tests__/responses.test.ts (6 tests) 12ms βœ“ listResponse wraps array with metadata βœ“ listResponse caps at 500 items βœ“ errorResponse returns structured error with status βœ“ errorResponse sanitizes internal details βœ“ successResponse includes timestamp βœ“ successResponse accepts custom status codes Test Files 1 passed (1) Tests 6 passed (6) Duration 0.34s
/home/dev/ds-platform $
πŸ’‘The environment comment trick

If a single test file needs to override the environment, add this comment at the top: // @vitest-environment jsdom. Vitest reads it before running the file. Use this sparingly β€” if you need it often, your test is probably in the wrong directory.


Test a route handler

API route handlers in the DS platform follow a consistent shape: parse the request, check permissions, validate input, query the database, return a response helper. Testing them means mocking NextRequest and verifying the response.

Here is a prompt that generates a complete test suite for a facilities API route:

Write Vitest tests for src/app/api/facilities/route.ts. The route:
- GET: calls withPermission('viewer') then queries facilities from Azure SQL,
returns listResponse({ data, total, page, pageSize })
- POST: calls withPermission('operator'), validates body with FacilityCreateSchema
(Zod), inserts into DB, returns successResponse({ id })
- Both use getServerSession from next-auth to get the user
Mock these dependencies:
- src/lib/db/connection.ts β€” mock getPool() to return a fake mssql pool
where pool.request().query() returns controlled data
- next-auth β€” mock getServerSession to return { user: { role: 'viewer' } }
or { user: { role: 'operator' } } depending on the test
- Do NOT mock withPermission itself β€” test that it actually blocks unauthorized roles
Test cases:
1. GET returns facilities with correct pagination metadata
2. GET with viewer role succeeds (viewer can read)
3. POST with operator role succeeds
4. POST with viewer role returns 403
5. POST with invalid body returns 400 with Zod error details
6. GET with no session returns 401
⚠Do not mock withPermission()

The whole point of testing route handlers is verifying that authorization works end-to-end. If you mock withPermission to always pass, you are testing nothing. Mock the session and the database β€” let withPermission run for real and verify it blocks unauthorized roles.

The AI will generate something like this pattern for the POST 403 test:

it('POST with viewer role returns 403', async () => {
vi.mocked(getServerSession).mockResolvedValue({
user: { id: '1', role: 'viewer', name: 'Test User' },
expires: new Date(Date.now() + 3600000).toISOString(),
});
const req = new NextRequest('http://localhost/api/facilities', {
method: 'POST',
body: JSON.stringify({ name: 'Test Facility', code: 'TST' }),
headers: { 'Content-Type': 'application/json' },
});
const res = await POST(req);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toContain('permission');
});

This test does not mock withPermission. It sets up a viewer session and calls the real route handler. The handler internally calls withPermission('operator'), checks the session role, sees viewer, and returns 403. That is the behavior you are verifying.


Test helper functions

The DS platform has utility functions that every route depends on. These are the easiest tests to write and the most valuable per line of code.

Pagination calculation

src/lib/api/__tests__/pagination.test.ts
import { describe, it, expect } from 'vitest';
import { parsePagination } from '../pagination';
describe('parsePagination', () => {
it('defaults to page 1, pageSize 50', () => {
const result = parsePagination({});
expect(result).toEqual({ page: 1, pageSize: 50, offset: 0 });
});
it('caps pageSize at 500', () => {
const result = parsePagination({ pageSize: '1000' });
expect(result.pageSize).toBe(500);
});
it('rejects negative page numbers', () => {
const result = parsePagination({ page: '-3' });
expect(result.page).toBe(1);
});
it('calculates offset correctly', () => {
const result = parsePagination({ page: '3', pageSize: '25' });
expect(result.offset).toBe(50);
});
});

Zod schema validation

import { FacilityCreateSchema } from '../schemas/facility';
it('rejects facility without required code field', () => {
const result = FacilityCreateSchema.safeParse({ name: 'Test' });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toContain('code');
});
it('rejects code longer than 10 characters', () => {
const result = FacilityCreateSchema.safeParse({
name: 'Test',
code: 'TOOLONGCODE1',
});
expect(result.success).toBe(false);
});

Safe error formatting

import { formatSafeError } from '../errors';
it('strips stack traces from error messages', () => {
const err = new Error('Connection refused');
err.stack = 'Error: Connection refused\n at Pool.connect (mssql/lib/pool.js:42)';
const safe = formatSafeError(err);
expect(safe).not.toContain('mssql/lib');
expect(safe).toContain('Connection refused');
});
it('replaces unknown errors with generic message', () => {
const safe = formatSafeError('something weird happened');
expect(safe).toBe('An unexpected error occurred');
});
β„ΉSafe errors protect you in audits

State auditors review error responses. If your API returns a stack trace containing internal file paths or connection strings, that is a finding. formatSafeError strips internals and returns a sanitized message. Testing it means you can prove it works.


Run the full suite

Claude Code β€” Full Test Run
/home/dev/ds-platform $ npx vitest --run --coverage
βœ“ src/lib/api/__tests__/responses.test.ts (6 tests) 12ms βœ“ src/lib/api/__tests__/pagination.test.ts (4 tests) 8ms βœ“ src/lib/api/__tests__/errors.test.ts (3 tests) 5ms βœ“ src/app/api/facilities/__tests__/route.test.ts (6 tests) 45ms βœ“ src/lib/schemas/__tests__/facility.test.ts (4 tests) 6ms Test Files 5 passed (5) Tests 23 passed (23) Duration 0.89s ----------|---------|----------|---------|---------| File | % Stmts | % Branch | % Funcs | % Lines | ----------|---------|----------|---------|---------| responses | 100 | 91.6 | 100 | 100 | pagination| 100 | 100 | 100 | 100 | errors | 95.2 | 85.7 | 100 | 95.2 | facilities| 88.4 | 75.0 | 100 | 88.4 | ----------|---------|----------|---------|---------| All | 94.1 | 85.3 | 100 | 94.1 | ----------|---------|----------|---------|---------|
/home/dev/ds-platform $

Sub-second. No network calls. No database connections. That is how server-side tests should feel.


What to test vs. what to skip

Not everything needs a unit test. Here is the priority list for a DS platform module:

PriorityWhatWhy
AlwayswithPermission() enforcementAuthorization bugs are audit findings
AlwaysZod schema validationMalformed input is the #1 attack vector
AlwaysResponse helper output shapeDownstream consumers depend on the contract
AlwaysPagination edge casesOff-by-one errors corrupt list views
UsuallySafe error formattingPrevents information leakage
UsuallyDatabase query constructionSQL injection risk if queries are dynamic
SkipNext.js middleware wiringCovered by E2E tests (Lesson 13)
SkipMUI component renderingCovered by component tests in jsdom

KNOWLEDGE CHECK

You are writing a Vitest test for a route handler that calls withPermission('admin'). You mock getServerSession to return a user with role 'operator'. What should the test assert?


Key takeaways

  • Environment switching is the first thing to get right. API routes need Node; component tests need jsdom. Configure it once in vitest.config.ts and forget about it.
  • Never mock withPermission() β€” mock the session. Your tests should prove that authorization actually works, not that your mock returns the right value.
  • Helper functions are the highest-ROI tests. Pagination, Zod schemas, safe error formatting β€” they are small, pure, and used everywhere. A bug in parsePagination breaks every list view in the platform.
  • Sub-second test runs change behavior. When tests run in under a second, you run them after every change. When they take 30 seconds, you skip them. Speed is a feature.

What’s next

Unit tests verify that individual functions do the right thing in isolation. In the next lesson, you will use Playwright to verify that the entire application works end-to-end β€” from MSAL login through RBAC enforcement to mutation feedback in the browser.