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.
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 jsdom3. Path aliases matching our tsconfig: @ -> src/, @/lib -> src/lib/, etc.4. Exclude node_modules and .next from test discovery5. Coverage provider: v8, thresholds: 80% statements, 70% branches6. 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.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 metadata2. GET with viewer role succeeds (viewer can read)3. POST with operator role succeeds4. POST with viewer role returns 4035. POST with invalid body returns 400 with Zod error details6. GET with no session returns 401The 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
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');});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
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:
| Priority | What | Why |
|---|---|---|
| Always | withPermission() enforcement | Authorization bugs are audit findings |
| Always | Zod schema validation | Malformed input is the #1 attack vector |
| Always | Response helper output shape | Downstream consumers depend on the contract |
| Always | Pagination edge cases | Off-by-one errors corrupt list views |
| Usually | Safe error formatting | Prevents information leakage |
| Usually | Database query construction | SQL injection risk if queries are dynamic |
| Skip | Next.js middleware wiring | Covered by E2E tests (Lesson 13) |
| Skip | MUI component rendering | Covered by component tests in jsdom |
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.tsand 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
parsePaginationbreaks 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.