A backend is tested at two levels. Unit tests run the service's business logic in memory: the database and external APIs are replaced with stand-ins, and such tests finish in a fraction of a second. Integration tests send real HTTP requests to the app through supertest and talk to a real PostgreSQL in Docker; that's the only way to check SQL, database constraints, status codes and access rules. In both cases external services are mocked, and every test starts with a clean database.
After this you will be able to
- write a unit test for a service, replacing the repository with a fake and the external API with
vi.fn() - write an integration test for an endpoint with supertest against a real PostgreSQL 17 in Docker via Testcontainers
- isolate tests from each other: table cleanup, fixture factories, running files that share a database one by one
- mock an external HTTP service with MSW and check
400,401,403,404,409and422responses
Know beforehand
Verified with
Node.js 24 LTS (24.18)Vitest 5.0supertest 7.3MSW 3.0Testcontainers 12.2pg 8.23Express 5.3PostgreSQL 17 (postgres:17-alpine)TypeScript 7.0
Checked against docs: October 11, 2026
Contents
- What to test in a backend
- Setting up the project
- A unit test for the service: swapping dependencies
- The test database: PostgreSQL in Docker via Testcontainers
- An integration test for an endpoint: supertest, fixtures and an external API mock
- Errors and access rules; a test that must fail
- The same in Java: JUnit 5 and MockMvc
- Exercises
What to test in a backend
The general idea of test levels is covered in the article on the testing pyramid, and Vitest as a tool in the frontend testing lesson. In the Express REST API lesson you already wrote your first tests with supertest and node:test against an in-memory store. This lesson takes the next step: splitting unit and integration tests, a real PostgreSQL and mocking external APIs. What is specific to a backend: it has no screen; its behavior is a contract (which request gets which response), the data in the database and how it reacts to errors.
Business rules: phone normalization, the "spam or not" decision
- Level
- Unit
- Example from this lesson
createLeadsServicewith a fake repository
The HTTP contract: status codes, response body, headers
- Level
- Integration
- Example from this lesson
POST /api/leads→201and the lead as JSON
SQL and database constraints: UNIQUE, NOT NULL, types
- Level
- Integration with a real database
- Example from this lesson
- a repeated phone →
409, not500
Errors and access rules
- Level
- Integration
- Example from this lesson
401without a token,403for the viewer role,404
Behavior when a third-party API fails
- Level
- Both, with a network mock
- Example from this lesson
- the anti-spam service returned
503, and the lead is still accepted
The practice project is a leads API: POST /api/leads accepts a lead, checks the phone with an external anti-spam service and stores it in PostgreSQL; GET /api/leads is available to a manager, and GET /api/leads/:id to a viewer as well. It's the leads API from the exercise in the Express lesson, only with PostgreSQL and split into layers so each one is easy to test.
Setting up the project
npm init -y && npm pkg set type=module
npm install express pg
npm install -D vitest supertest msw @testcontainers/postgresql typescript \
@types/express @types/pg @types/supertest @types/nodeNode.js 24 runs .ts directly by stripping type annotations, so there is no build step; tsconfig.json matches the one in the background jobs article (add tests and vitest.config.ts to include) and is needed only for npx tsc --noEmit. The built-in node:test from the Express lesson would do here too, and supertest works with both; we pick Vitest for projects, globalSetup with passing data into tests, and built-in mocks. Unit and integration tests live in two Vitest projects: the former run without Docker in a fraction of a second, the latter start a database.
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
include: ['tests/unit/**/*.test.ts'],
},
},
{
test: {
name: 'integration',
include: ['tests/integration/**/*.test.ts'],
globalSetup: ['tests/integration/global-setup.ts'],
fileParallelism: false, // the files share one database: run them one by one
hookTimeout: 60_000, // pulling the image on the first run takes time
},
},
],
},
});The service depends not on PostgreSQL and HTTP but on two interfaces: LeadsRepo (storage) and SpamCheck (the spam check). Domain errors are separate classes, so the HTTP layer can turn them into the right status codes.
export type Lead = {
id: number;
name: string;
phone: string;
spamChecked: boolean;
createdAt: Date;
};
export type NewLead = Pick<Lead, 'name' | 'phone' | 'spamChecked'>;
// Dependencies are interfaces: the service doesn't know about PostgreSQL or HTTP.
export interface LeadsRepo {
insert(lead: NewLead): Promise<Lead>;
findById(id: number): Promise<Lead | null>;
list(): Promise<Lead[]>;
}
export interface SpamCheck {
isSpam(phone: string): Promise<boolean>;
}
export class ValidationError extends Error {}
export class SpamError extends Error {}
export class ConflictError extends Error {}
// "+7 701 123 45 67", "8 (701) 123-45-67" -> "+77011234567"; anything else -> null
export function normalizePhone(input: string): string | null {
const digits = input.replace(/\D/g, '');
if (digits.length !== 11 || !/^[78]/.test(digits)) return null;
return `+7${digits.slice(1)}`;
}
export function createLeadsService(deps: { repo: LeadsRepo; spam: SpamCheck }) {
return {
async create(input: { name?: unknown; phone?: unknown }): Promise<Lead> {
const name = typeof input.name === 'string' ? input.name.trim() : '';
const phone = typeof input.phone === 'string' ? normalizePhone(input.phone) : null;
if (!name) throw new ValidationError('name is required');
if (!phone) throw new ValidationError('phone must be a Kazakhstan number');
let spamChecked = true;
try {
if (await deps.spam.isSpam(phone)) throw new SpamError('lead rejected as spam');
} catch (err) {
if (err instanceof SpamError) throw err;
spamChecked = false; // the checker is down: accept the lead, flag it for review
}
return deps.repo.insert({ name, phone, spamChecked });
},
get: (id: number) => deps.repo.findById(id),
list: () => deps.repo.list(),
};
}
export type LeadsService = ReturnType<typeof createLeadsService>;A unit test for the service: swapping dependencies
There are two ways to swap a dependency. A fake is a simplified working implementation: a repository backed by an array behaves like the real one, just without a database. A mock is a spy function, vi.fn(), that returns a preset value and records its calls, so you can check whether the external service was called and with which arguments.
import { describe, expect, it, vi } from 'vitest';
import {
createLeadsService,
normalizePhone,
SpamError,
ValidationError,
type Lead,
type LeadsRepo,
} from '../../src/leads.ts';
// A fake repository: real behavior, but in memory instead of PostgreSQL
function memoryRepo(): LeadsRepo & { rows: Lead[] } {
const rows: Lead[] = [];
return {
rows,
async insert(lead) {
const saved = { ...lead, id: rows.length + 1, createdAt: new Date('2026-10-11T09:00:00Z') };
rows.push(saved);
return saved;
},
async findById(id) {
return rows.find((r) => r.id === id) ?? null;
},
async list() {
return rows;
},
};
}
function setup(isSpam = vi.fn(async (_phone: string) => false)) {
const repo = memoryRepo();
const service = createLeadsService({ repo, spam: { isSpam } });
return { repo, service, isSpam };
}
describe('normalizePhone', () => {
it.each([
['+7 701 123 45 67', '+77011234567'],
['8 (701) 123-45-67', '+77011234567'],
['77011234567', '+77011234567'],
['12345', null],
['+1 202 555 0143', null],
])('%s -> %s', (input, expected) => {
expect(normalizePhone(input)).toBe(expected);
});
});
describe('createLeadsService.create', () => {
it('saves a lead with a normalized phone', async () => {
const { service, repo, isSpam } = setup();
const lead = await service.create({ name: ' Aigerim ', phone: '8 (701) 123-45-67' });
expect(lead).toMatchObject({ name: 'Aigerim', phone: '+77011234567', spamChecked: true });
expect(isSpam).toHaveBeenCalledWith('+77011234567');
expect(repo.rows).toHaveLength(1);
});
it('rejects an invalid phone before calling anything external', async () => {
const { service, repo, isSpam } = setup();
await expect(service.create({ name: 'Aigerim', phone: '123' })).rejects.toThrow(ValidationError);
expect(isSpam).not.toHaveBeenCalled();
expect(repo.rows).toHaveLength(0);
});
it('rejects spam and saves nothing', async () => {
const { service, repo } = setup(vi.fn(async () => true));
await expect(service.create({ name: 'Bot', phone: '+77011234567' })).rejects.toThrow(SpamError);
expect(repo.rows).toHaveLength(0);
});
it('accepts the lead when the spam checker is down, but flags it', async () => {
const { service } = setup(vi.fn().mockRejectedValue(new Error('HTTP 503')));
const lead = await service.create({ name: 'Aigerim', phone: '+77011234567' });
expect(lead.spamChecked).toBe(false);
});
});it.eachturns an "input → expected" table into separate tests, which suits functions likenormalizePhone.expect(isSpam).not.toHaveBeenCalled()checks the order of operations: with an invalid phone, the service shouldn't spend a request to a paid API.vi.fn().mockRejectedValue(...)simulates a failed service without a network, which is how the "accept the lead but flag it for review" branch gets tested.
$ npx vitest run --project unit
RUN v5.0.3 …/leads-api
Test Files 1 passed (1)
Tests 9 passed (9)
Start at 17:36:28
Duration 202ms (transform 52%, import 29%, tests 12%, worker 8%)The test database: PostgreSQL in Docker via Testcontainers
Integration tests need a real database of the same version as production, but their own: tests wipe tables, so a shared development database won't do. Testcontainers starts a container from test code, waits until it's ready and hands back a connection string; after the run, the container is stopped. All you need is a running Docker.
CREATE TABLE leads (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
phone text NOT NULL UNIQUE,
spam_checked boolean NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);import { readFile } from 'node:fs/promises';
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import pg from 'pg';
import type { TestProject } from 'vitest/node';
let container: StartedPostgreSqlContainer | undefined;
export async function setup(project: TestProject) {
// Either a database you started yourself, or a fresh container for this run
let url = process.env.TEST_DATABASE_URL;
if (!url) {
container = await new PostgreSqlContainer('postgres:17-alpine').start();
url = container.getConnectionUri();
}
// The schema is recreated from scratch: never point this at a real database
if (!new URL(url).pathname.includes('test')) {
throw new Error(`refusing to reset a non-test database: ${new URL(url).pathname}`);
}
const client = new pg.Client({ connectionString: url });
await client.connect();
await client.query('DROP TABLE IF EXISTS leads');
await client.query(await readFile('db/schema.sql', 'utf8'));
await client.end();
project.provide('databaseUrl', url);
}
export async function teardown() {
await container?.stop();
}
declare module 'vitest' {
export interface ProvidedContext {
databaseUrl: string;
}
}The Vitest docs warn that globalSetup runs in a separate scope: its variables aren't visible to tests, so data is passed with project.provide and read with inject. The ProvidedContext declaration gives inject('databaseUrl') the right type.
If Testcontainers isn't available for some reason (say, CI has no access to the Docker API but offers a service container), the same setup can point at a database you started by hand:
docker run -d --rm --name kb-pg-leads-test -e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=leads_test -p 55437:5432 postgres:17-alpine
TEST_DATABASE_URL=postgres://postgres:test@localhost:55437/leads_test npx vitest run --project integration- Isolation between tests:
TRUNCATE leads RESTART IDENTITYbefore each test empties the table and resets the id counter to 1. A test must not depend on what the previous one left behind. - Isolation between files: Vitest runs files in parallel by default, but there is one database. We checked: with
fileParallelism: true, one test failed in four runs out of four because a neighboring file wiped the table in the middle of another file's test. So in theintegrationproject files run one by one; the alternative is a separate database per worker. - A safety net: setup recreates the table and refuses to run if the database name doesn't contain
test. ATEST_DATABASE_URLaccidentally pointed at a real database produces the error "refusing to reset a non-test database" rather than data loss.
An integration test for an endpoint: supertest, fixtures and an external API mock
The pg repository turns a uniqueness violation (PostgreSQL code 23505) into a ConflictError, and the HTTP layer turns that into 409. This is exactly the chain a unit test can't check.
import type { Pool } from 'pg';
import { ConflictError, type Lead, type LeadsRepo } from './leads.ts';
type Row = { id: number; name: string; phone: string; spam_checked: boolean; created_at: Date };
const toLead = (r: Row): Lead => ({
id: r.id,
name: r.name,
phone: r.phone,
spamChecked: r.spam_checked,
createdAt: r.created_at,
});
export function pgLeadsRepo(pool: Pool): LeadsRepo {
return {
async insert(lead) {
try {
const { rows } = await pool.query<Row>(
'INSERT INTO leads (name, phone, spam_checked) VALUES ($1, $2, $3) RETURNING *',
[lead.name, lead.phone, lead.spamChecked],
);
return toLead(rows[0]);
} catch (err) {
// 23505 = unique_violation: a lead with this phone already exists
if ((err as { code?: string }).code === '23505') {
throw new ConflictError('lead with this phone already exists');
}
throw err;
}
},
async findById(id) {
const { rows } = await pool.query<Row>('SELECT * FROM leads WHERE id = $1', [id]);
return rows[0] ? toLead(rows[0]) : null;
},
async list() {
const { rows } = await pool.query<Row>('SELECT * FROM leads ORDER BY id');
return rows.map(toLead);
},
};
}import type { SpamCheck } from './leads.ts';
// A client for an external anti-spam service (a third-party HTTP API).
export function httpSpamCheck(baseUrl: string, apiKey: string): SpamCheck {
return {
async isSpam(phone) {
const res = await fetch(`${baseUrl}/v1/check`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ phone }),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) throw new Error(`spam check failed: HTTP ${res.status}`);
const body = (await res.json()) as { spam: boolean };
return body.spam;
},
};
}import express, { type NextFunction, type Request, type Response } from 'express';
import { ConflictError, SpamError, ValidationError, type LeadsService } from './leads.ts';
export type Role = 'manager' | 'viewer';
export function createApp(deps: { leads: LeadsService; tokens: Record<string, Role> }) {
const app = express();
app.use(express.json());
// Bearer token -> role. No token: 401, wrong role: 403.
const allow = (...roles: Role[]) => (req: Request, res: Response, next: NextFunction) => {
const token = req.get('authorization')?.replace(/^Bearer /, '');
const role = token ? deps.tokens[token] : undefined;
if (!role) return void res.status(401).json({ error: 'unauthorized' });
if (!roles.includes(role)) return void res.status(403).json({ error: 'forbidden' });
next();
};
app.post('/api/leads', async (req, res) => {
const lead = await deps.leads.create(req.body ?? {});
res.status(201).json(lead);
});
app.get('/api/leads', allow('manager'), async (_req, res) => {
res.json(await deps.leads.list());
});
app.get('/api/leads/:id', allow('manager', 'viewer'), async (req, res) => {
const id = Number(req.params.id);
const lead = Number.isInteger(id) ? await deps.leads.get(id) : null;
if (!lead) return void res.status(404).json({ error: 'not found' });
res.json(lead);
});
// Express 5 forwards rejected promises from async handlers here.
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof ValidationError) return void res.status(400).json({ error: err.message });
if (err instanceof SpamError) return void res.status(422).json({ error: err.message });
if (err instanceof ConflictError) return void res.status(409).json({ error: err.message });
console.error(err);
res.status(500).json({ error: 'internal error' });
});
return app;
}import pg from 'pg';
import { createApp } from './app.ts';
import { httpSpamCheck } from './http-spam-check.ts';
import { createLeadsService } from './leads.ts';
import { pgLeadsRepo } from './pg-leads-repo.ts';
const env = (name: string) => {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set`);
return value;
};
const pool = new pg.Pool({ connectionString: env('DATABASE_URL') });
const leads = createLeadsService({
repo: pgLeadsRepo(pool),
spam: httpSpamCheck(env('SPAM_API_URL'), env('SPAM_API_KEY')),
});
const app = createApp({ leads, tokens: { [env('MANAGER_TOKEN')]: 'manager' } });
app.listen(3000, () => console.log('http://localhost:3000'));supertest takes the app and, as its docs say, binds it to a free ephemeral port itself, so there are no ports to track. The external anti-spam service is replaced at the network level with MSW: the real fetch in http-spam-check.ts reaches a handler instead of the internet.
import { http, HttpResponse } from 'msw/http';
import { setupServer } from 'msw/node';
import pg from 'pg';
import { afterAll, afterEach, beforeAll, beforeEach, inject } from 'vitest';
import { createApp, type Role } from '../../src/app.ts';
import { httpSpamCheck } from '../../src/http-spam-check.ts';
import { createLeadsService, type NewLead } from '../../src/leads.ts';
import { pgLeadsRepo } from '../../src/pg-leads-repo.ts';
export const SPAM_API = 'https://spam-check.example';
// The external service is mocked at the network level; by default it says "not spam"
export const spamApi = setupServer(
http.post(`${SPAM_API}/v1/check`, () => HttpResponse.json({ spam: false })),
);
export function useTestApp() {
const pool = new pg.Pool({ connectionString: inject('databaseUrl') });
const tokens: Record<string, Role> = { 'manager-token': 'manager', 'viewer-token': 'viewer' };
const leads = createLeadsService({
repo: pgLeadsRepo(pool),
spam: httpSpamCheck(SPAM_API, 'test-key'),
});
const app = createApp({ leads, tokens });
beforeAll(() =>
spamApi.listen({
onUnhandledFrame({ frame, defaults }) {
// MSW also sees supertest's calls to our own app on 127.0.0.1: let them through
if (frame.protocol === 'http') {
const { request } = frame.data as { request: Request };
if (new URL(request.url).hostname === '127.0.0.1') return;
}
defaults.error(); // any other unexpected outgoing request is an error
},
}),
);
beforeEach(() => pool.query('TRUNCATE leads RESTART IDENTITY')); // every test starts empty
afterEach(() => spamApi.resetHandlers());
afterAll(async () => {
spamApi.close();
await pool.end();
});
return { app, pool };
}
// Factory: valid data by default; a test overrides only what it cares about
let seq = 0;
export function buildLead(overrides: Partial<NewLead> = {}): NewLead {
seq += 1;
return {
name: `Lead ${seq}`,
phone: `+770100${String(seq).padStart(5, '0')}`,
spamChecked: true,
...overrides,
};
}
// Fixtures go straight to SQL so they don't depend on the code under test
export async function insertLead(pool: pg.Pool, overrides: Partial<NewLead> = {}) {
const lead = buildLead(overrides);
const { rows } = await pool.query<{ id: number }>(
'INSERT INTO leads (name, phone, spam_checked) VALUES ($1, $2, $3) RETURNING id',
[lead.name, lead.phone, lead.spamChecked],
);
return { ...lead, id: rows[0].id };
}Fixtures are data a test prepares in advance. The buildLead factory produces a valid lead with a unique phone, and the test overrides only the field it cares about. insertLead writes the fixture with plain SQL rather than through the code under test: if the repository breaks, the repository tests fail, not the data setup of every test at once.
import { http, HttpResponse } from 'msw/http';
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { insertLead, SPAM_API, spamApi, useTestApp } from './test-app.ts';
const { app, pool } = useTestApp();
describe('POST /api/leads', () => {
it('creates a lead and stores it in PostgreSQL', async () => {
const res = await request(app)
.post('/api/leads')
.send({ name: 'Aigerim', phone: '8 (701) 123-45-67' })
.expect(201);
expect(res.body).toMatchObject({ id: 1, phone: '+77011234567', spamChecked: true });
const { rows } = await pool.query('SELECT name, phone FROM leads');
expect(rows).toEqual([{ name: 'Aigerim', phone: '+77011234567' }]);
});
it('returns 400 for an invalid phone and writes nothing', async () => {
const res = await request(app).post('/api/leads').send({ name: 'Aigerim', phone: '123' }).expect(400);
expect(res.body.error).toMatch(/phone/);
const { rows } = await pool.query('SELECT count(*)::int AS n FROM leads');
expect(rows[0].n).toBe(0);
});
it('returns 409 when the phone is already taken (UNIQUE in the database)', async () => {
await insertLead(pool, { phone: '+77011234567' });
await request(app)
.post('/api/leads')
.send({ name: 'Aigerim', phone: '+7 701 123 45 67' })
.expect(409);
});
it('returns 422 when the spam service flags the lead', async () => {
spamApi.use(http.post(`${SPAM_API}/v1/check`, () => HttpResponse.json({ spam: true })));
await request(app).post('/api/leads').send({ name: 'Bot', phone: '+77011234567' }).expect(422);
});
it('accepts the lead when the spam service is down', async () => {
spamApi.use(http.post(`${SPAM_API}/v1/check`, () => new HttpResponse(null, { status: 503 })));
const res = await request(app)
.post('/api/leads')
.send({ name: 'Aigerim', phone: '+77011234567' })
.expect(201);
expect(res.body.spamChecked).toBe(false);
});
});Note that the first test checks not only the response but also the database row, with a query to pool after the API call. The 409 test prepares a fixture with the same phone in a different format and checks that normalization and the UNIQUE constraint work together.
Errors and access rules; a test that must fail
Every protected endpoint needs at least three tests: no token gives 401, a token without the right role gives 403, the right role succeeds. It's cheap and catches the nastiest bug: an endpoint that accidentally became public.
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { insertLead, useTestApp } from './test-app.ts';
const { app, pool } = useTestApp();
describe('GET /api/leads (access rules)', () => {
it('returns 401 without a token', async () => {
await request(app).get('/api/leads').expect(401);
});
it('returns 403 for a viewer', async () => {
await request(app).get('/api/leads').set('Authorization', 'Bearer viewer-token').expect(403);
});
it('returns all leads to a manager, oldest first', async () => {
await insertLead(pool, { name: 'First' });
await insertLead(pool, { name: 'Second' });
const res = await request(app)
.get('/api/leads')
.set('Authorization', 'Bearer manager-token')
.expect(200);
expect(res.body.map((l: { name: string }) => l.name)).toEqual(['First', 'Second']);
});
});
describe('GET /api/leads/:id', () => {
it('lets a viewer read one lead', async () => {
const lead = await insertLead(pool);
const res = await request(app)
.get(`/api/leads/${lead.id}`)
.set('Authorization', 'Bearer viewer-token')
.expect(200);
expect(res.body.phone).toBe(lead.phone);
});
it('returns 404 for a missing lead', async () => {
await request(app).get('/api/leads/999').set('Authorization', 'Bearer manager-token').expect(404);
});
});$ npx vitest run --reporter=verbose
RUN v5.0.3 …/leads-api
✓ |unit| tests/unit/leads-service.test.ts > normalizePhone > +7 701 123 45 67 -> +77011234567 1ms
✓ |unit| tests/unit/leads-service.test.ts > normalizePhone > 8 (701) 123-45-67 -> +77011234567 0ms
✓ |unit| tests/unit/leads-service.test.ts > normalizePhone > 77011234567 -> +77011234567 0ms
✓ |unit| tests/unit/leads-service.test.ts > normalizePhone > 12345 -> null 0ms
✓ |unit| tests/unit/leads-service.test.ts > normalizePhone > +1 202 555 0143 -> null 0ms
✓ |unit| tests/unit/leads-service.test.ts > createLeadsService.create > saves a lead with a normalized phone 2ms
✓ |unit| tests/unit/leads-service.test.ts > createLeadsService.create > rejects an invalid phone before calling anything external 1ms
✓ |unit| tests/unit/leads-service.test.ts > createLeadsService.create > rejects spam and saves nothing 0ms
✓ |unit| tests/unit/leads-service.test.ts > createLeadsService.create > accepts the lead when the spam checker is down, but flags it 0ms
✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > creates a lead and stores it in PostgreSQL 78ms
✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > returns 400 for an invalid phone and writes nothing 10ms
✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > returns 409 when the phone is already taken (UNIQUE in the database) 15ms
✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > returns 422 when the spam service flags the lead 20ms
✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > accepts the lead when the spam service is down 12ms
✓ |integration| tests/integration/access.api.test.ts > GET /api/leads (access rules) > returns 401 without a token 50ms
✓ |integration| tests/integration/access.api.test.ts > GET /api/leads (access rules) > returns 403 for a viewer 9ms
✓ |integration| tests/integration/access.api.test.ts > GET /api/leads (access rules) > returns all leads to a manager, oldest first 16ms
✓ |integration| tests/integration/access.api.test.ts > GET /api/leads/:id > lets a viewer read one lead 11ms
✓ |integration| tests/integration/access.api.test.ts > GET /api/leads/:id > returns 404 for a missing lead 8ms
Test Files 3 passed (3)
Tests 19 passed (19)
Start at 17:34:54
Duration 4.47s (import 55%, tests 26%, transform 17%, worker 2%)A green run proves nothing yet: you need to make sure the tests catch a breakage. Let's introduce a plausible typo by mixing up the PostgreSQL error code:
--- a/src/pg-leads-repo.ts
+++ b/src/pg-leads-repo.ts
@@ -22,7 +22,7 @@ export function pgLeadsRepo(pool: Pool): LeadsRepo {
return toLead(rows[0]);
} catch (err) {
// 23505 = unique_violation: a lead with this phone already exists
- if ((err as { code?: string }).code === '23505') {
+ if ((err as { code?: string }).code === '23503') {
throw new ConflictError('lead with this phone already exists');
}
throw err;$ npx vitest run --project integration; echo "exit=$?"
RUN v5.0.3 …/leads-api
stderr | tests/integration/leads.api.test.ts > POST /api/leads > returns 409 when the phone is already taken (UNIQUE in the database)
error: duplicate key value violates unique constraint "leads_phone_key"
…
code: '23505',
detail: 'Key (phone)=(+77011234567) already exists.',
…
❯ |integration| tests/integration/leads.api.test.ts (5 tests | 1 failed) 155ms
❯ POST /api/leads (5)
× returns 409 when the phone is already taken (UNIQUE in the database) 16ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL |integration| tests/integration/leads.api.test.ts > POST /api/leads > returns 409 when the phone is already taken (UNIQUE in the database)
Error: expected 409 "Conflict", got 500 "Internal Server Error"
❯ tests/integration/leads.api.test.ts:34:8
32| .post('/api/leads')
33| .send({ name: 'Aigerim', phone: '+7 701 123 45 67' })
34| .expect(409);
| ^
35| });
36|
…
Test Files 1 failed | 1 passed (2)
Tests 1 failed | 9 passed (10)
exit=1Unit tests don't see this bug: there is no PostgreSQL and no 23505 code in them. The integration test shows both the cause (stderr has the original database error with code: '23505') and the effect on the client: 500 instead of 409. Once the typo is reverted, all 19 tests are green again.
The same in Java: JUnit 5 and MockMvc
The approach doesn't depend on the language. In Spring Boot (the docs were checked for version 4.1), the same levels look like this:
Unit test for a service
- Node.js (this lesson)
- Vitest, a fake or
vi.fn() - Spring Boot
- JUnit 5 + Mockito
The HTTP layer without starting a server
- Node.js (this lesson)
- supertest +
createApp - Spring Boot
@WebMvcTest+MockMvc, controller dependencies via@MockitoBean
The whole application
- Node.js (this lesson)
- supertest + real dependencies
- Spring Boot
@SpringBootTest+@AutoConfigureMockMvc
A real database in a container
- Node.js (this lesson)
- Testcontainers in
globalSetup - Spring Boot
- Testcontainers +
@ServiceConnection: Spring takes the connection details from the container itself
Per the docs, the @WebMvcTest slice starts only the MVC infrastructure and controllers; regular @Component beans aren't scanned, so services are replaced with @MockitoBean. It's the counterpart of passing dependencies into createApp.
Exercises
How this was verified4 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.