1. Courses
  2. Engineering Roles
  3. Test Automation and Quality Engineering
  4. Stop 2

40 minIntermediate

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.each tables 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 , 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 , 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?

larger cart, in cents Cart minimum 499: rejected 500: accepted FRESH10 starts 1999: no 10% 2000: 200 off Free delivery 2499: fee 150 2500: fee 0 Cart maximum 50000: accepted 50001: rejected
Four rules, eight rows. Each boundary gets the last value on one side and the first value on the other, which is where an off-by-one comparison gives a different answer. These eight rows are the 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 , 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 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?

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 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() and vi.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?

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 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. 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 , 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?

Test + supertest NestJS app, in process Controller, pipes OrderService Fake payments Postgres container global setup POST /orders charge SQL starts reads the row
One API test. Global setup starts a PostgreSQL container once and runs the migrations. Each test sends real HTTP to the in-process app, where only the payment gateway is replaced by a fake, then reads the order row straight from the database to check the side effect.
App Orders DB POST, key K insert, key K 201 lost no signal retry, key K find key K order 81 same 201, id 81 1 order row, 1 charge
The double submit. The first response is lost on a weak connection, so the app retries with the same idempotency key. The service finds the key, returns the first order and creates nothing new. The API test sends the request twice and asserts one row and one charge.

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.

  1. 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.

  2. 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;
    }
  3. 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_500 to > 2_500 in priceOrder: exactly one row, the 2,500 cent cart, goes red. Change it back.

  4. 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 }
    }

    Global setup runs in its own scope, so it cannot share variables with tests. provide passes the connection string, and tests read it with inject('databaseUrl').

  5. 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 because ORDER is a keyword. AuthService.tokenFor is the app's own token signer, so the test signs in the same way the app checks; the secret comes from the test environment.

  6. 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 .

JavaScript exerciseRuns in your browser with tests
// 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');
}
Ctrl + Enter runs
Show a solution
JavaScript
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');
}
Pass the exercise to finish this task.

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.

JavaScript exerciseRuns in your browser with tests
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;
}
Ctrl + Enter runs
Show a solution
JavaScript
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;
}
Pass the exercise to finish this task.

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 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?

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?

Flashcards

Open my review

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.each tables 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

Episode transcriptOne tap, one order, one charge · about 12 minutes · tap any line to play from there
  1. NadiaImran, festival eve. A customer is in a lift with one bar of signal. She taps Pay. The spinner turns for ten seconds, and the app quietly sends the order again. What does she get?
  2. ImranIf we're lucky, one order. If we're not, two baskets packed, two bags on a rider's bike, and two charges on her wallet.
  3. NadiaAnd every price test was green that night. The discount maths was perfect. Today is about the two kinds of tests that would have caught it: unit tests for the rules, and API tests for everything around them.
  4. NadiaPicture a restaurant kitchen. Each cook tastes their own sauce at their station. Seconds per taste, and if it's too salty, you know exactly which pot to fix.
  5. ImranAnd someone checks the whole plate before it goes out.
  6. NadiaThe head chef, at the pass. Right dish, right table, ticket marked as served. And every order comes on a numbered ticket. If a waiter shouts ticket eighty-one twice, the cooks say, already cooking eighty-one. Nobody makes a second plate.
  7. ImranSo the cooks tasting are unit tests, the pass is the API test, and the ticket number is the thing that stops the double order.
  8. NadiaExactly. The ticket number is called an idempotency key. We'll get to it. Start with the cooks.
  9. ImranRemind me of the checkout rules. I always mix them up.
  10. NadiaEverything is in whole cents. Delivery costs a hundred and fifty cents under twenty-five hundred, and it's free from twenty-five hundred. The FRESH10 code gives ten percent off from two thousand cents, first order only, and never together with the staff discount. Staff get five percent.
  11. NadiaAnd a cart must be between five hundred and fifty thousand cents, with one to twenty of each item. The team keeps all of that in one pure function called price order. No database, no network, no clock. Just inputs and a result.
  12. ImranWhy does that matter so much?
  13. NadiaBecause a pure function is the cheapest thing in the codebase to test. About a millisecond per test. So you test every rule here, and the slower API tests only check the wiring.
  14. ImranAnd how do you write a good one?
  15. NadiaThree habits. Arrange, act, assert: build the input, call the function once, check the result. One behaviour per test. And a name that reads like a sentence, such as charges a hundred and fifty cents delivery on a twenty-four ninety-nine cart. That name is the bug report you'll read in CI.
  16. ImranOkay, so which carts do I test? I'd pick a nice round one. Three thousand cents, free delivery, done.
  17. NadiaThat's the trap. Three thousand passes whether the code is right or wrong. The classic bug is an off-by-one. The rule says from twenty-five hundred, which means greater than or equal, and someone writes greater than.
  18. ImranSo the cart of exactly twenty-five hundred pays a fee it shouldn't.
  19. NadiaRight. So for every boundary you test the last value on one side and the first on the other. If you can see your screen, the first diagram shows four rules as four rows, each with two test values.
  20. ImranAnd if I'm driving?
  21. NadiaThen picture pairs. Four ninety-nine and five hundred. Nineteen ninety-nine and two thousand. Twenty-four ninety-nine and twenty-five hundred. Fifty thousand and fifty thousand and one.
  22. ImranEight test cases. That's a lot of copy and paste.
  23. NadiaIt isn't, with Vitest's test each. You write the test once and give it a table of rows. Each row runs as its own test, and its values go into the name. When the twenty-five hundred row goes red, you know exactly which edge broke.
  24. ImranNext thing. Every one of those tests needs an order. Can't I make one order at the top of the file and reuse it?
  25. NadiaEveryone tries that. It works on day one. Then one test sets the staff flag to true for its own case, and every test after it is now pricing a staff order.
  26. ImranAnd the FRESH10 test fails.
  27. NadiaBut only when the whole file runs. Run it alone and it passes. That's a shared mutable fixture, and it's one of the biggest reasons tests pass alone and fail together.
  28. ImranSo what do you use instead?
  29. NadiaA builder. You call an order, then say only what matters: as first order, with code FRESH10, build. The default is a plain twenty-five dollar cart with no code and no staff flag, so any test that needs FRESH10 has to say so out loud.
  30. NadiaAnd the important part: every call gives you a brand new object, including new copies of the lines inside it. Nothing one test does can reach another test.
  31. ImranWhat about things like the list of articles and their prices? Does every test need its own rice?
  32. NadiaNo. Reference data that nobody changes, like articles and delivery slots, can be seeded once for the whole run. Anything a test creates or changes, like customers and orders, belongs to that test alone.
  33. ImranNow the bit I never get straight. Stubs, spies, mocks, fakes. Aren't they all just mocks?
  34. NadiaPeople say mock for all of them, but they do different jobs. A stub hands back a canned answer, like a clock that always says six in the evening. A spy records how it was called.
  35. ImranAnd the other two?
  36. NadiaA mock is set up to expect certain calls. And a fake is a small working version, like a payment gateway that keeps a list of charges in memory.
  37. ImranWhen does the difference matter?
  38. NadiaWhen the code changes. Say the service used to call charge once, and now it calls authorize and then capture, which wallets often need. The customer pays exactly the same. But every test that checked charge was called once goes red.
  39. ImranAnd with the fake?
  40. NadiaThe fake learns the two new methods once, and the tests still ask, is there one charge of twenty-five hundred for this order? Still true, still green. Fakes let you check outcomes. Mocks make you check calls.
  41. ImranSo, stub the inputs, fake the busy dependencies, and only mock when the call itself is the point. Like checking one SMS went to the right number.
  42. NadiaThat's it. And one thing you never fake in an API test is your own database. The SQL is part of what you're testing.
  43. ImranWhich brings us to the pass. The API tests.
  44. NadiaThe NestJS testing module builds the real app inside the test process, and swaps only the payment gateway for the fake. Then supertest sends real HTTP requests to it. And the database is a real PostgreSQL, started in a throwaway container by a library called Testcontainers, once for the whole run.
  45. ImranAnd if two test files run at the same time against that one database?
  46. NadiaThat's why every test creates its own customer with a random ID, and only looks at that customer's rows. No test ever steps on another, even in parallel.
  47. ImranWhat does a good API test check? The status code?
  48. NadiaThree things. The status, the body, and the side effect. After a two oh one, the test reads the order row from the database and the charges from the fake. Saved twice and not saved at all both look fine from the response alone.
  49. ImranAnd the negative tests?
  50. NadiaWritten on purpose. Four hundred when the request is malformed, like a quantity of twenty-one. Four twenty-two when it's well formed but breaks a rule, like a three hundred and fifty cent cart.
  51. ImranAnd four oh nine?
  52. NadiaWhen it clashes with the current state, like cancelling an order that's already delivered. And each negative test also checks that nothing was stored.
  53. ImranSo now our customer in the lift.
  54. NadiaIf you can see your screen, the third diagram shows it as three columns: the app, the order service, the database. The first request saves the order with key K. The response is lost. The app retries with the same key K. The service finds K and sends back the same order. One row, one charge.
  55. NadiaAnd here's what happened to us on the first night of a festival rush. Thirty-one customers charged twice. The service did have a key column, but it checked it in code: read first, then insert. Two requests a few milliseconds apart both read nothing there yet, and both inserted.
  56. ImranSo the check existed and still let it through.
  57. NadiaYes. The fix started with a failing API test: send the same request twice at the same moment, then count rows and charges. Two rows, red. Then a unique index on the customer and the key, so the database itself refuses the second insert. Green.
  58. ImranAnd a mocked repository would never have caught that.
  59. NadiaNever. Only real PostgreSQL enforces the index.
  60. ImranWalk me through the Build it.
  61. NadiaFirst, the price rules in one function. Then a builder for its input and a small factory for request bodies. Then the unit tests as boundary tables. A trick to prove they have teeth: change greater than or equal to greater than, and exactly one row goes red.
  62. NadiaThen a global setup file that starts the Postgres container, runs the real migrations, seeds the articles, and hands the connection string to the tests. Then the API test file: boot the app with the fake, create a customer per test, and test the happy path, the four hundred, the four twenty-two and the double submit.
  63. ImranAnd in my next review, what do I ask?
  64. NadiaThree questions. Is there a test row on both sides of every boundary? If I run this test alone, then the whole suite, do I get the same answer? And for this write endpoint, which test proves a retry with the same key creates one order?
  65. NadiaNow a question for you. Take a second. An API test sends the same request twice with the same key and checks both answers have the same order ID. Is that enough?
  66. ImranIt sounds enough.
  67. NadiaIt isn't. The service could return the stored answer and still insert a second row. Same ID in both responses, two orders in the database. You have to count the rows and the charges.
  68. ImranLet me try the takeaways. One, keep the rules in a pure function and test every boundary from both sides with a table. Two, every test builds its own fresh data. Three, API tests use the real app and a real database, with only outside services faked.
  69. NadiaGood. One nuance on the third: always check the side effect as well as the response. That's where the double charge was hiding.
  70. ImranNext time?
  71. NadiaEnd-to-end tests with Playwright. A few journeys through the real app, and how to make them wait the right way.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Unit-test starter

A function with a test file: the happy path, the edges and the errors, grouped and named clearly.

Open in the Code Lab
Next stopEnd-to-end tests with Playwright
Was this lesson helpful?
Questions and discussion

Loading the discussion…