Unit and API tests
Most of the checks in a trusted suite are fast unit tests on pure rules and API tests that drive the real service and database. This lesson shows you how to write both for Mangrove's checkout: price rules tested at every boundary, test data that never leaks between tests, and API tests that prove a double tap on Pay creates one order.
By the end you can
- Write unit tests for price rules with arrange, act, assert, one behaviour per test and
test.eachtables built from boundaries - Build test data with builders and factories instead of shared mutable fixtures
- Choose between a stub, a spy, a mock and a fake, and know when a fake is the better choice
- Run API tests against an in-process NestJS app and a real PostgreSQL, with data isolated per test
- Assert status, body and side effects, including negative cases and an idempotent double submit
The everyday picture
Think of a busy restaurant kitchen. Each cook tastes their own sauce at their station before it goes on a plate. That takes seconds, and when the sauce is too salty, everyone knows exactly which pot to fix. Then, at the pass, the head chef checks the whole plate before it leaves: the right dish, the right table, the ticket marked as served.
One more rule keeps the kitchen sane. Every order arrives on a numbered ticket. When a waiter shouts "table 9, ticket 81" a second time because he is not sure the kitchen heard him, the cooks look at the ticket number and say "already cooking 81". Nobody makes a second plate.
Unit tests are the cooks tasting at their stations. API tests are the check at the pass. And the ticket number is the idempotency key that stops a repeated request from creating a second order.
Unit tests for price rules
Mangrove's checkout has a small set of price rules, and they decide real money. All amounts are integer cents. Delivery costs 150 cents when the cart is under 2,500 cents ($25) and is free from 2,500. The promo code FRESH10 takes 10% off from 2,000 cents, on a customer's first order only, and never together with the staff discount. Staff get 5% off. A cart must be between 500 and 50,000 cents, and each line has a quantity from 1 to 20. The team keeps these rules in one pure function, priceOrder, which takes priced lines and a few flags and returns the subtotal, discount, delivery fee and total.
A pure function is the cheapest thing in the codebase to test. No database, no network, no waiting: a test runs in about a millisecond. That is why the rules live here and the API only calls this function. You test them at this level, and you keep the slower API tests for the wiring around them. A test strategy, not a pile of scripts explains this "push each check down to the fastest level" idea; Errors, debugging and testing covers the Vitest basics this lesson builds on.
Three habits make unit tests easy to trust:
- Arrange, act, assert. Build the input, call the function once, check the result. When a test needs two acts, it is usually two tests.
- One behaviour per test. "Gives 10% off a first order from 2,000 cents" is one behaviour. A test that checks fees, discounts and limits together fails with a message that tells you nothing.
- Names as sentences. The test name is the bug report you will read in CI: "charges 150 cents delivery on a 2,499 cent cart" tells a reviewer what broke before they open a file.
Where do the input values come from? From the boundary analysis you learned in Designing tests that find bugs. Every rule with a threshold has a boundary, and the classic bug is an off-by-one: > written where the rule says "from", which is >=. So you test the last value on one side and the first value on the other: 2,499 and 2,500 for delivery, 1,999 and 2,000 for FRESH10, 499 and 500, 50,000 and 50,001 for the cart limits. A middle value such as 3,000 cents passes with or without the bug.
Vitest's test.each (also called it.each) turns those pairs into a table. You write the test once and give it rows; each row runs as its own named test. With an array of objects, $name in the title is replaced by that row's value, so the table produces names like "charges 0 cents delivery on a 2500 cent cart". When a row fails, you see exactly which boundary broke.
Quick check
You add a test.each table for the delivery fee rule (150 cents under 2,500, free from 2,500). Which two rows catch an off-by-one in the comparison?
The rule changes between 2,499 and 2,500, so those are the two values where > and >= give different answers. The pair 2,500 and 2,501 catches a fee charged at exactly 2,500, but misses a bug that makes 2,499 free. Values far from the edge pass whether the bug is there or not.
test.each tables in Build it.Test data that does not leak
Every price test needs an order: lines, a code, a first-order flag, a staff flag. The tempting shortcut is one object at the top of the file, const order = { ... }, that every test uses and adjusts. It works on day one. Then a test sets order.staff = true for its own case, and every test after it now prices a staff order. The FRESH10 test fails, but only when the whole file runs, and only in that order. This is a shared mutable fixture, and it is one of the main sources of tests that pass alone and fail together.
The fix is to give each test its own fresh data, cheaply. Two patterns do this:
- A factory function returns a new valid object each time and takes overrides:
orderBody({ paymentMethod: 'cod' }). It is enough when objects have few variations. - A test data builder starts from a valid default and lets the test say only what matters to it:
anOrder().asFirstOrder().withCode('FRESH10').build(). It pays off when there are many combinations, as in pricing.
Both share three rules. Defaults are valid and boring: the default order is a plain $25 cart, no code, no staff flag, so a test that needs FRESH10 has to say so, and the reader sees the reason in the test. Every call returns a new object, including new copies of nested arrays like lines, so nothing one test does can reach another. Tests state the values they assert on: when a test checks a 200 cent discount, the 2,000 cent subtotal appears in that test, not hidden in a default three files away.
Not everything has to be private. Reference data that no test changes, such as the article prices and delivery slots, can be seeded once for the whole run. Data a test creates or changes, such as customers and orders, belongs to that test alone.
Quick check
A price test file passes when you run one test on its own, and fails when you run the whole file. The tests share const baseOrder = {...} at the top. What is the most likely cause?
Tests in one file share the module's variables, so a change one test makes to a shared object is visible to every test after it. Vitest runs the tests in a file in order unless you ask it to shuffle, which is exactly why the failure is stable but only appears in the full run. A builder that returns a new object per call fixes it.
Test doubles, and when a fake beats a mock
The order service talks to things you do not want in a test: a card network, a wallet provider like M-Pesa, UPI or GCash, an SMS gateway, the clock. A test double stands in for each one. The names come from Gerard Meszaros's book on test patterns, and they mean different things:
- A stub returns canned answers. A clock stub always says "2026-10-20 18:00", so a test of an evening rule never depends on when CI runs.
- A spy records how it was called, so you can check afterwards.
vi.fn()andvi.spyOn()in Vitest are spies that can also return canned values. - A mock is set up with expectations about the calls it should receive, and the test fails if the calls differ. In everyday speech people call any
vi.fn()a mock. - A fake is a small working implementation that takes a shortcut, such as an in-memory payment gateway that keeps a list of charges and can be told to decline the next one.
Mocks and spies make you assert on calls: "gateway.charge was called once with 2,500". Fakes let you assert on outcomes: "there is one charge of 2,500 for this order". The difference shows up when the code changes. Suppose the team moves from one charge call to an authorize call followed by a capture, which wallets often need. Every call-based test breaks, even though customers pay exactly the same. The tests with a fake keep passing, because the fake gained the two new methods once and the outcome did not change.
So use the simplest double that answers your question. Stub what only feeds data in (the clock, a feature flag). Use a fake for a dependency with behaviour you test against many times (payments, an outbox). Keep spies and mocks for cases where the call itself is the outcome, such as "exactly one 'order placed' SMS went to the customer's number". And do not double your own database in API tests: the SQL is part of what you are testing.
Quick check
A rule gives free delivery on rainy evenings after 6 pm, and the service reads the current time. How should a unit test control the time?
The test needs a value fed in, so a stub fits. A mock on now() checks a detail of how the code reads the time, which breaks when someone refactors and still does not set the time. A test that depends on the real clock passes or fails depending on when CI happens to run.
API tests for the order service
Unit tests prove the rules. They cannot prove that POST /orders reads the right article prices from PostgreSQL, rejects bad input with the right status, saves the order and charges once. An API test does that, and at Mangrove it runs in around a hundred milliseconds per test, far faster than driving the app.
Start the app inside the test process. The NestJS testing module builds the real AppModule, and overrideProvider swaps one provider, here the payment gateway, for a fake. supertest sends HTTP requests to the app's server on a free local port, so nothing clashes with a dev server. From script to service: a small API in Node shows the same setup on a smaller API. If your service is not NestJS, start it on a random port and use fetch; the ideas are the same.
Use a real PostgreSQL. An in-memory database or a mocked repository will happily accept SQL that PostgreSQL rejects, and it will not enforce your unique constraints, which you are about to rely on. Testcontainers starts a PostgreSQL container from a test, gives you its connection string and removes it afterwards. It needs Docker or a compatible runtime, which GitHub-hosted Linux runners have. Start one container for the whole run in Vitest's global setup, run the same migrations production runs, and pass the connection string to the tests. Without Docker, a CI service container or a fresh database per run, such as orders_test_4187, works too.
Isolate the data. Test files run in parallel, so tests must not step on each other. There are three common ways. Unique data per test: every test creates its own customer with a random id and asserts only on that customer's rows. This is the simplest and survives parallel runs. A transaction per test, rolled back at the end: very clean, but it only works when the test and the app share one connection, which an HTTP app with its own pool does not. Truncating tables before each test: easy, but only safe when one test touches the database at a time. Mangrove uses unique data per test, plus reference articles and slots seeded once.
Assert three things: the status, the body and the side effect. A 201 with the right total is half the story. The test then reads the order row from the database and the charges from the fake gateway, because "the response looked right but nothing was saved" and "saved twice" are both real bugs that a response check cannot see.
Write the negative tests on purpose. The order service answers 400 when the request is malformed (a quantity of 21, a missing field, an unknown payment method), 422 when it is well-formed but breaks a business rule (a 350 cent cart), and 409 when it conflicts with the current state (cancelling an order that is already delivered, a full delivery slot). Each negative test also checks that nothing was stored and nothing was charged. Testing APIs and data covers these status codes from the tester's side.
Test the double submit. On patchy mobile data, the app's request can reach the server while the response is lost. The app retries, and without protection the customer gets two orders and two charges. So the app sends an idempotency key, a random id per checkout attempt, in an Idempotency-Key header. The service stores the key with the order, behind a unique constraint. A retry with the same key and the same body gets the first response back, with the same order id. The IETF draft for this header suggests 422 when a key is reused with a different body and 409 while the first request is still being processed. The test sends the same request twice, then checks the second id matches the first, there is one row and there is one charge.
Quick check
A customer sends POST /orders with valid JSON, valid quantities and a single 350 cent loaf of bread, below the 500 cent minimum. What should the API test expect?
The request is well-formed, so it is not a 400; it breaks a business rule, which Mangrove's API answers with 422. 409 is for conflicts with the current state, such as cancelling a delivered order. And the test checks the database too, because a 422 that still saved a row is a bug.
Build it
Write the unit tests for priceOrder with a builder and boundary tables, then API tests for POST /orders against a real PostgreSQL. The code is TypeScript in a NestJS project with Vitest, as in From script to service: a small API in Node; you need Docker running for the API tests.
-
Keep the price rules in one pure function
TypeScript// src/pricing/price-order.ts: all money in integer cents export type PricedLine = { articleId: string; priceCents: number; qty: number }; export type PriceInput = { lines: PricedLine[]; code: string | null; firstOrder: boolean; staff: boolean }; export type Price = { subtotalCents: number; discountCents: number; deliveryFeeCents: number; totalCents: number }; export function priceOrder({ lines, code, firstOrder, staff }: PriceInput): Price { for (const line of lines) { if (!Number.isInteger(line.qty) || line.qty < 1 || line.qty > 20) throw new RangeError('qty must be 1 to 20'); } const subtotalCents = lines.reduce((sum, l) => sum + l.priceCents * l.qty, 0); if (subtotalCents < 500 || subtotalCents > 50_000) throw new RangeError('cart must be 500 to 50000 cents'); let discountCents = 0; if (staff) { discountCents = Math.floor((subtotalCents * 5) / 100); // staff: 5%, never with FRESH10 } else if (code === 'FRESH10' && firstOrder && subtotalCents >= 2_000) { discountCents = Math.floor(subtotalCents / 10); // 10% from $20, first order only } const deliveryFeeCents = subtotalCents >= 2_500 ? 0 : 150; // based on the subtotal before discounts return { subtotalCents, discountCents, deliveryFeeCents, totalCents: subtotalCents - discountCents + deliveryFeeCents }; }Discounts round down to whole cents, and the delivery fee looks at the subtotal before discounts. Those are product decisions; write them in a comment so the tests and the code agree on purpose.
-
Add a builder and a factory for test data
TypeScript// test/builders/order.builder.ts: fresh, valid price input for every test import type { PriceInput, PricedLine } from '../../src/pricing/price-order.js'; export function anOrder() { const order: PriceInput = { // a new object on every call lines: [{ articleId: 'A1001', priceCents: 1_250, qty: 2 }], // a plain $25 cart code: null, firstOrder: false, staff: false, }; const builder = { withLines(...lines: PricedLine[]) { order.lines = lines.map((l) => ({ ...l })); return builder; }, withSubtotal(cents: number) { return builder.withLines({ articleId: 'A1001', priceCents: cents, qty: 1 }); }, withCode(code: string) { order.code = code; return builder; }, asFirstOrder() { order.firstOrder = true; return builder; }, asStaff() { order.staff = true; return builder; }, build(): PriceInput { return { ...order, lines: order.lines.map((l) => ({ ...l })) }; }, // copy nested lines too }; return builder; }TypeScript// test/builders/order-body.ts: a factory for POST /orders request bodies export type OrderBody = { lines: { articleId: string; qty: number }[]; paymentMethod: 'card' | 'wallet' | 'cod'; slotId: string; }; export const orderBody = (overrides: Partial<OrderBody> = {}): OrderBody => ({ lines: [{ articleId: 'A1001', qty: 2 }], // seeded reference article: 1,250 cents paymentMethod: 'card', slotId: 'SLOT-TEST', // seeded with a very large capacity, so parallel tests never fill it ...overrides, }); -
Write the unit tests as boundary tables
TypeScript// src/pricing/price-order.spec.ts import { describe, it, expect } from 'vitest'; import { priceOrder } from './price-order.js'; import { anOrder } from '../../test/builders/order.builder.js'; describe('priceOrder', () => { it.each([ { subtotal: 2_499, fee: 150 }, { subtotal: 2_500, fee: 0 }, ])('charges $fee cents delivery on a $subtotal cent cart', ({ subtotal, fee }) => { const order = anOrder().withSubtotal(subtotal).build(); // arrange const price = priceOrder(order); // act expect(price.deliveryFeeCents).toBe(fee); // assert }); it.each([ { subtotal: 1_999, discount: 0 }, { subtotal: 2_000, discount: 200 }, ])('gives a first order $discount cents off a $subtotal cent cart with FRESH10', ({ subtotal, discount }) => { const order = anOrder().withSubtotal(subtotal).asFirstOrder().withCode('FRESH10').build(); expect(priceOrder(order).discountCents).toBe(discount); }); it('gives no FRESH10 discount on a second order', () => { expect(priceOrder(anOrder().withSubtotal(3_000).withCode('FRESH10').build()).discountCents).toBe(0); }); it('gives staff only their 5% when they also enter FRESH10', () => { const order = anOrder().withSubtotal(3_000).asStaff().asFirstOrder().withCode('FRESH10').build(); expect(priceOrder(order).discountCents).toBe(150); }); it.each([500, 50_000])('accepts a %i cent cart', (subtotal) => { expect(priceOrder(anOrder().withSubtotal(subtotal).build()).subtotalCents).toBe(subtotal); }); it.each([499, 50_001])('rejects a %i cent cart', (subtotal) => { expect(() => priceOrder(anOrder().withSubtotal(subtotal).build())).toThrow(RangeError); }); it.each([0, 21])('rejects a line with qty %i', (qty) => { const order = anOrder().withLines({ articleId: 'A1001', priceCents: 1_000, qty }).build(); expect(() => priceOrder(order)).toThrow('qty must be 1 to 20'); }); });Run
npx vitest run src/pricing. Each table row is reported as its own test with its values in the name, and the whole file runs in well under a second. To prove the tables have teeth, change>= 2_500to> 2_500inpriceOrder: exactly one row, the 2,500 cent cart, goes red. Change it back. -
Start one real PostgreSQL for the whole run
Install the module with
npm install -D @testcontainers/postgresql pg @types/pg. The image name is required; pin the major version you run in production.TypeScript// test/global-setup.ts: runs once, before any API test file import { PostgreSqlContainer } from '@testcontainers/postgresql'; import type { TestProject } from 'vitest/node'; import { runMigrations, seedReferenceData } from '../src/db/migrate.js'; export default async function setup(project: TestProject) { const container = await new PostgreSqlContainer('postgres:16-alpine').start(); const url = container.getConnectionUri(); await runMigrations(url); // the same migrations production runs await seedReferenceData(url); // articles and SLOT-TEST: read-only, shared by all tests project.provide('databaseUrl', url); return async () => { await container.stop(); }; // teardown after the last file } declare module 'vitest' { export interface ProvidedContext { databaseUrl: string } }TypeScript// vitest.config.api.ts: npx vitest run --config ./vitest.config.api.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, include: ['test/**/*.api-spec.ts'], globalSetup: ['./test/global-setup.ts'], env: { NODE_ENV: 'test' }, // never the real .env values }, });Global setup runs in its own scope, so it cannot share variables with tests.
providepasses the connection string, and tests read it withinject('databaseUrl'). -
Boot the app once per file, with a fake payment gateway
TypeScript// test/orders.api-spec.ts (part 1): setup and helpers import { Test } from '@nestjs/testing'; import type { INestApplication } from '@nestjs/common'; import request from 'supertest'; import type { App } from 'supertest/types.js'; import { randomUUID } from 'node:crypto'; import { Pool } from 'pg'; import { inject } from 'vitest'; import { AppModule } from '../src/app.module.js'; import { setupApp } from '../src/setup.js'; import { AuthService } from '../src/auth/auth.service.js'; import { PaymentGateway } from '../src/payments/payment.gateway.js'; import { FakePaymentGateway } from './fakes/fake-payment.gateway.js'; import { orderBody } from './builders/order-body.js'; let app: INestApplication<App>; let db: Pool; const payments = new FakePaymentGateway(); // keeps charges in a list; can decline the next one beforeAll(async () => { process.env.DATABASE_URL = inject('databaseUrl'); const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(PaymentGateway).useValue(payments) // no real card or wallet network .compile(); app = moduleRef.createNestApplication(); setupApp(app); // the same validation pipes as production await app.init(); db = new Pool({ connectionString: inject('databaseUrl') }); }); afterAll(async () => { await app.close(); await db.end(); }); async function newCustomer() { // unique per test: safe when files run in parallel const id = randomUUID(); await db.query('INSERT INTO customer (id, email) VALUES ($1, $2)', [id, `t-${id}@example.test`]); return { id, auth: { Authorization: `Bearer ${app.get(AuthService).tokenFor(id)}` } }; } const ordersOf = async (customerId: string) => (await db.query('SELECT id, total_cents FROM "order" WHERE customer_id = $1', [customerId])).rows; const post = (auth: object, body: object, key: string = randomUUID()) => request(app.getHttpServer()).post('/orders').set(auth).set('Idempotency-Key', key).send(body);"order"needs quotes in SQL becauseORDERis a keyword.AuthService.tokenForis the app's own token signer, so the test signs in the same way the app checks; the secret comes from the test environment. -
Test the happy path, the negatives and the double submit
TypeScript// test/orders.api-spec.ts (part 2): status, body and side effects describe('POST /orders', () => { it('stores a placed order and charges the card once', async () => { const c = await newCustomer(); const res = await post(c.auth, orderBody()).expect(201); // 2 x 1,250 = 2,500 expect(res.body).toMatchObject({ status: 'placed', totalCents: 2_500, deliveryFeeCents: 0 }); expect(await ordersOf(c.id)).toEqual([{ id: res.body.id, total_cents: 2_500 }]); expect(payments.chargesFor(res.body.id)).toEqual([{ amountCents: 2_500 }]); }); it('rejects qty 21 with 400 and stores nothing', async () => { const c = await newCustomer(); await post(c.auth, orderBody({ lines: [{ articleId: 'A1001', qty: 21 }] })).expect(400); expect(await ordersOf(c.id)).toEqual([]); }); it('rejects a 350 cent cart with 422 and stores nothing', async () => { const c = await newCustomer(); await post(c.auth, orderBody({ lines: [{ articleId: 'A3010', qty: 1 }] })).expect(422); expect(await ordersOf(c.id)).toEqual([]); }); it('turns a double submit with the same key into one order and one charge', async () => { const c = await newCustomer(); const key = randomUUID(); const [first, second] = await Promise.all([post(c.auth, orderBody(), key), post(c.auth, orderBody(), key)]); const ok = [first, second].filter((r) => r.status === 201); expect(ok.length).toBeGreaterThan(0); // the other may be a 409 while the first runs expect(await ordersOf(c.id)).toHaveLength(1); const retry = await post(c.auth, orderBody(), key).expect(201); // a later retry replays the first result expect(retry.body.id).toBe(ok[0].body.id); expect(payments.chargesFor(retry.body.id)).toHaveLength(1); }); it('rejects the same key with a different body with 422', async () => { const c = await newCustomer(); const key = randomUUID(); await post(c.auth, orderBody(), key).expect(201); await post(c.auth, orderBody({ paymentMethod: 'cod' }), key).expect(422); expect(await ordersOf(c.id)).toHaveLength(1); }); });Run
npx vitest run --config ./vitest.config.api.ts. The first run pulls the PostgreSQL image, which takes a while; after that, the container starts in a few seconds and each test takes tens to a few hundred milliseconds. Add the API run to CI next to the unit tests; Stable suites in CI splits them into jobs.
Bring it to your team
Ask these when you review a pull request that adds or changes tests.
- Which boundary values does this rule have, and is there a test row on each side of every one?
- If I run this test alone, then the whole file, then the whole suite, does it give the same result?
- Does this test create its own data, or does it depend on rows another test left behind?
- Are we asserting on what happened (rows, charges, messages) or on which methods were called?
- For this write endpoint, where are the 400, 422 and 409 tests, and do they check nothing was stored?
- What happens when the app sends this request twice with the same idempotency key, and which test proves it?
Red flags
- One shared order or customer object that tests change, and "run them in this order" in the README.
- API tests against an in-memory database or a mocked repository, so SQL and constraints are never tested.
- Tests named "test 1", "works" or "should work", so CI failures say nothing.
- Price rules tested only with round middle values such as 3,000 cents.
- Write endpoints whose tests check the response and never the database.
Practice
Write a test suite that catches broken price rules
Write the body of suite(priceOrder). It receives a priceOrder function that takes { lines, code, firstOrder, staff } (the last three are optional) and returns { subtotalCents, discountCents, deliveryFeeCents, totalCents }, and throws for a cart outside 500 to 50,000 cents. Use the check and checkThrows helpers. The rules: delivery is 150 cents under 2,500 and free from 2,500; FRESH10 is 10% off (rounded down) from 2,000 cents, first order only, and staff get 5% instead, never both. The hidden tests call your suite with a correct priceOrder, which must pass, and with broken versions, each of which your suite must catch by throwing. This style of grading tests by planting bugs is called mutation testing.
// Helpers: each one throws when the check fails.
function check(actual, expected, label) {
if (actual !== expected) throw new Error(`${label}: expected ${expected}, got ${actual}`);
}
function checkThrows(fn, label) {
try { fn(); } catch (e) { return; }
throw new Error(`${label}: expected an error, got none`);
}
// A one-line cart with this subtotal, plus optional flags: cart(2000, { code: 'FRESH10', firstOrder: true })
const cart = (subtotalCents, extra = {}) =>
({ lines: [{ articleId: 'A1001', priceCents: subtotalCents, qty: 1 }], ...extra });
// Must pass for a correct priceOrder and throw for a broken one.
function suite(priceOrder) {
check(priceOrder(cart(1000)).deliveryFeeCents, 150, 'a small cart pays delivery');
}
function makePrice(m = {}) {
return function priceOrder({ lines, code = null, firstOrder = false, staff = false }) {
for (const l of lines) if (l.qty < 1 || l.qty > 20) throw new RangeError('qty must be 1 to 20');
const subtotalCents = lines.reduce((s, l) => s + l.priceCents * l.qty, 0);
const min = m.min || 500, max = m.max || 50000;
if (subtotalCents < min || subtotalCents > max) throw new RangeError('cart must be 500 to 50000 cents');
const fromFresh = m.freshOver ? subtotalCents > 2000 : subtotalCents >= 2000;
const fresh = code === 'FRESH10' && (firstOrder || m.anyOrder) && fromFresh;
let discountCents = 0;
if (staff) discountCents = Math.floor(subtotalCents * 5 / 100) + (m.stack && fresh ? Math.floor(subtotalCents / 10) : 0);
else if (fresh) discountCents = Math.floor(subtotalCents / 10);
const free = m.feeOver ? subtotalCents > 2500 : subtotalCents >= 2500;
const deliveryFeeCents = free ? 0 : 150;
return { subtotalCents, discountCents, deliveryFeeCents, totalCents: subtotalCents - discountCents + deliveryFeeCents };
};
}
test('your suite passes for the correct priceOrder (a check that fails on right answers is a broken check)', () => {
expect(() => suite(makePrice())).not.toThrow();
});
test('catches off-by-one bugs at 2500 (free delivery) and 2000 (FRESH10): test both sides of each boundary', () => {
expect(() => suite(makePrice({ feeOver: true }))).toThrow();
expect(() => suite(makePrice({ freshOver: true }))).toThrow();
});
test('catches FRESH10 on a second order, and FRESH10 stacked on top of the staff 5%', () => {
expect(() => suite(makePrice({ anyOrder: true }))).toThrow();
expect(() => suite(makePrice({ stack: true }))).toThrow();
});
test('catches cart limits that let 499 or 50001 cents through (use checkThrows on both)', () => {
expect(() => suite(makePrice({ min: 499 }))).toThrow();
expect(() => suite(makePrice({ max: 50001 }))).toThrow();
});
Show a solution
function check(actual, expected, label) {
if (actual !== expected) throw new Error(`${label}: expected ${expected}, got ${actual}`);
}
function checkThrows(fn, label) {
try { fn(); } catch (e) { return; }
throw new Error(`${label}: expected an error, got none`);
}
const cart = (subtotalCents, extra = {}) =>
({ lines: [{ articleId: 'A1001', priceCents: subtotalCents, qty: 1 }], ...extra });
function suite(priceOrder) {
// delivery: 150 under 2500, free from 2500
check(priceOrder(cart(2499)).deliveryFeeCents, 150, '2499 pays delivery');
check(priceOrder(cart(2500)).deliveryFeeCents, 0, '2500 is free delivery');
check(priceOrder(cart(2499)).totalCents, 2649, 'the total adds the fee');
// FRESH10: 10% from 2000, first order only
const fresh = { code: 'FRESH10', firstOrder: true };
check(priceOrder(cart(1999, fresh)).discountCents, 0, '1999 gets no FRESH10');
check(priceOrder(cart(2000, fresh)).discountCents, 200, '2000 gets 10%');
check(priceOrder(cart(3000, { code: 'FRESH10', firstOrder: false })).discountCents, 0, 'no FRESH10 on a second order');
// staff: 5% only, never stacked with FRESH10
check(priceOrder(cart(3000, { ...fresh, staff: true })).discountCents, 150, 'staff get 5% only');
// cart limits: both edges accepted, one step outside rejected
check(priceOrder(cart(500)).subtotalCents, 500, '500 is accepted');
check(priceOrder(cart(50000)).subtotalCents, 50000, '50000 is accepted');
checkThrows(() => priceOrder(cart(499)), '499 is rejected');
checkThrows(() => priceOrder(cart(50001)), '50001 is rejected');
}Fix a builder that leaks between tests
This anOrder() builder hands every test the same object, so one test's asStaff() shows up in the next test. Rewrite it so every anOrder() starts from a fresh default, every build() returns a new object, and the built order's lines are copies too. Keep the same methods and the same default: one line of article A1001 at 1,250 cents, quantity 2, no code, not a first order, not staff.
const DEFAULT_ORDER = {
lines: [{ articleId: 'A1001', priceCents: 1250, qty: 2 }],
code: null,
firstOrder: false,
staff: false,
};
function anOrder() {
const order = DEFAULT_ORDER; // every test gets the same object
const builder = {
withLines(...lines) { order.lines = lines; return builder; },
withCode(code) { order.code = code; return builder; },
asFirstOrder() { order.firstOrder = true; return builder; },
asStaff() { order.staff = true; return builder; },
build() { return order; },
};
return builder;
}
const plain = () => ({ lines: [{ articleId: 'A1001', priceCents: 1250, qty: 2 }], code: null, firstOrder: false, staff: false });
test('the default order is a plain cart: 2 x 1250 cents of A1001, no code, not first order, not staff', () => {
expect(anOrder().build()).toEqual(plain());
});
test('a staff order with FRESH10 built in one test does not change the next anOrder() (no shared object)', () => {
anOrder().asStaff().withCode('FRESH10').asFirstOrder().build();
expect(anOrder().build()).toEqual(plain());
});
test('editing a built order, including its lines, does not leak into later builds (copy the lines too)', () => {
const a = anOrder().build();
a.lines[0].qty = 20;
a.lines.push({ articleId: 'A2040', priceCents: 899, qty: 1 });
a.staff = true;
expect(anOrder().build()).toEqual(plain());
});
test('each build() returns a new, equal object, and withLines replaces the default lines', () => {
const b = anOrder().withLines({ articleId: 'A3010', priceCents: 350, qty: 3 });
const x = b.build();
const y = b.build();
expect(x).toEqual(y);
expect(x).not.toBe(y);
x.lines[0].qty = 1;
expect(y.lines).toEqual([{ articleId: 'A3010', priceCents: 350, qty: 3 }]);
});
Show a solution
const DEFAULT_ORDER = {
lines: [{ articleId: 'A1001', priceCents: 1250, qty: 2 }],
code: null,
firstOrder: false,
staff: false,
};
const copyLines = (lines) => lines.map((l) => ({ ...l }));
function anOrder() {
const order = { ...DEFAULT_ORDER, lines: copyLines(DEFAULT_ORDER.lines) }; // fresh per builder
const builder = {
withLines(...lines) { order.lines = copyLines(lines); return builder; },
withCode(code) { order.code = code; return builder; },
asFirstOrder() { order.firstOrder = true; return builder; },
asStaff() { order.staff = true; return builder; },
build() { return { ...order, lines: copyLines(order.lines) }; }, // fresh per build
};
return builder;
}Check yourself
The FRESH10 API test passes on a developer's laptop and fails in CI, where test files run in parallel. Every test uses the seeded customer C-1, and another file places orders for C-1. What is the best fix?
The other file's orders make C-1 a returning customer, so FRESH10 correctly gives nothing. Unique data per test removes the shared state and keeps the parallel speed. Running files one at a time also works, but it makes the suite slower forever to hide a data problem. A retry hides it, and a mocked repository stops testing the real "first order" query.
The team changes the order service from one gateway.charge() call to authorize() then capture(). Checkout works exactly as before, but 14 tests fail with "expected charge to have been called once". What does that tell you about those tests?
Customers are charged the same amount once, so nothing is broken. The tests were tied to how the service talks to the gateway. A fake that records the money taken lets tests assert the outcome, which survives this kind of refactor. More mocks repeat the problem, and a real provider makes tests slow, costly and flaky.
An API test sends the same POST /orders twice with the same Idempotency-Key and asserts both answers are 201 with the same id. It passes, yet Ama still sees duplicate orders for one customer. What is the test missing?
The service can return the stored response while still inserting a second row, so equal ids in the responses prove nothing about what was saved. Counting rows and charges checks the behaviour that matters. A snapshot or timing check still only looks at the response.
Flashcards
Key takeaways
- Keep business rules in pure functions and test them with arrange, act, assert, one behaviour per test and names that read as sentences.
- Build
test.eachtables from boundaries: the last value on one side and the first on the other. - Give every test fresh data with builders and factories; share only read-only reference data.
- Prefer fakes for dependencies with behaviour, stubs for inputs, and mocks only when the call is the outcome.
- Run API tests against the real app and a real PostgreSQL, and assert status, body and side effects, including negative cases and the double submit.
Sources
- Test API reference (test.each), Vitest documentation
- globalSetup, Vitest documentation
- Testing, NestJS documentation
- supertest, GitHub
- PostgreSQL module, Testcontainers for Node.js documentation
- Mocks Aren't Stubs, Martin Fowler, 2007
- The Idempotency-Key HTTP Header Field, IETF HTTPAPI working group draft
- RFC 9110: HTTP Semantics, IETF, 2022