Skip to main content

On a phone? for plain text and static diagrams.

Unit testing in development

Prove every change, layer by layer, before anyone else has to.

Ship one feature through the pipeline

Run a clean change, or inject a bug and watch which layer stops it.

1 of 13

Requirement

Agree on the problem before anyone writes code. A requirement that cannot be checked cannot be cleared, so testability starts here.

Evidence to move on: Story and rules reviewed by product and engineering.

Read how this stage works

Run log

Nothing has run yet. Pick a change and press Run pipeline.

Part 01

Testing is not a final activity

Every stage produces evidence that the next stage depends on. Click through the pipeline to see what each stage is for, what the developer does, an example, and what must exist before moving on.

Requirement

1 / 13

Purpose

Agree on the problem before anyone writes code. A requirement that cannot be checked cannot be cleared, so testability starts here.

What the developer must do

  • Write the user story and the business rules in plain language.
  • Ask "how will we know this works?" about every sentence.
  • Raise gaps with the product owner now, not during UAT.

What should be tested

  • Nothing runs yet. You are deciding what will be tested later.
  • Every rule must be checkable with an input and an expected outcome.

Example

requirement.md8 lines
1As a signed-in customer
2I want to place an order for a product
3So that the stock is reserved and I get a confirmation
4
5Rules
6- The server calculates the total, never the client
7- Quantity must be between 1 and the available stock
8- A failed order must change nothing

Exit criteria (the evidence)

  • Story and rules reviewed by product and engineering.
  • No rule relies on words like "fast" or "appropriate" without a number.
  • Open questions are answered, not assumed.

Common mistakes

  • Starting to code from a one-line ticket.
  • Writing rules that only describe the happy path.
  • Leaving permissions out because "everyone is logged in".

Part 02

A small, realistic application

An Order Management app, small enough to read in one sitting and rich enough to need every kind of test. Ten business rules decide what we have to prove.

Frontend
  • Next.js
  • TypeScript
  • React
  • TanStack Query
  • Zustand
  • Zod
Backend
  • NestJS
  • TypeScript
  • Prisma
  • PostgreSQL
Testing
  • Vitest
  • Testing Library
  • supertest
  • Playwright

Screens

  • Login
  • Product listing
  • Product detail
  • Create order form
  • Order list
  • Order detail

Backend modules

  • Authentication
  • Product API
  • Order API
  • User API

Ten business rules, and where each one is proven

The best layer is the cheapest one that can truly prove the rule. Notice that rule 9 can only be proven against a real database, and that Playwright is the main proof for none of them.
Business rules and the test layers that prove them
#RuleUnitIntegrationE2E
01User must be authenticated to create an orderThe guard logic is unit tested, but only a real request proves the route is actually protected.SupportsProvesSupports
02Product must existA service branch with a mocked repository is enough. One integration case confirms the 404 mapping.ProvesSupports-
03Product must be activePure branching logic, cheap to prove in a unit test.ProvesSupports-
04Quantity must be greater than 0Proven at three levels for three reasons: the util, the DTO, and what the user sees.ProvesSupportsSupports
05Quantity cannot exceed available stockA mock can only prove we asked for a conditional update. Only real SQL proves the boundary and the race.SupportsProvesSupports
06Order total is calculated by the backendUnit proves the arithmetic, integration proves the stored total.ProvesProves-
07Client is never trusted for the totalSend a hostile total through real HTTP and check what was stored.SupportsProves-
08Creating an order reduces stockThe database row is the only honest evidence.SupportsProves-
09A failed order must not partially update the databaseTransactions and rollback cannot be proven with mocks at all.-Proves-
10Users only view their own orders unless adminThe rule is unit tested; the 403 and the admin override are proven over HTTP; one journey proves the user sees a denial.ProvesProvesSupports

Database

Four tables. The money is stored in cents as an integer, and each line keeps the price at the time of purchase.
prisma/schema.prisma46 lines
1enum Role {
2 USER
3 ADMIN
4}
5
6enum OrderStatus {
7 CONFIRMED
8 CANCELLED
9}
10
11model User {
12 id String @id @default(cuid())
13 email String @unique
14 passwordHash String
15 role Role @default(USER)
16 orders Order[]
17}
18
19model Product {
20 id String @id @default(cuid())
21 name String
22 priceCents Int
23 stock Int
24 active Boolean @default(true)
25 items OrderItem[]
26}
27
28model Order {
29 id String @id @default(cuid())
30 userId String
31 user User @relation(fields: [userId], references: [id])
32 status OrderStatus @default(CONFIRMED)
33 totalCents Int
34 items OrderItem[]
35 createdAt DateTime @default(now())
36}
37
38model OrderItem {
39 id String @id @default(cuid())
40 orderId String
41 order Order @relation(fields: [orderId], references: [id])
42 productId String
43 product Product @relation(fields: [productId], references: [id])
44 quantity Int
45 unitPriceCents Int
46}

Part 03

One feature, end to end: Create Order

Start from the requirement. Every later test traces back to a sentence written here, which is why the acceptance criteria come before the code.

Requirement

As a signed-in customer I want to place an order for a product, so that the stock is reserved and I get a confirmation.

Acceptance criteria

Each one names an observable outcome, and points at the business rules it proves.
  1. AC1rules 1, 6, 8
    Given
    an authenticated user
    When
    they submit a valid order
    Then
    the order is created and the stock is reduced
  2. AC2rules 5, 9
    Given
    insufficient stock
    When
    the user submits the order
    Then
    the API rejects the request and nothing changes
  3. AC3rules 1
    Given
    an unauthenticated user
    When
    they submit an order
    Then
    the API returns 401
  4. AC4rules 4
    Given
    a quantity of 0 or less
    When
    the user submits an order
    Then
    validation rejects the request
  5. AC5rules 6, 7
    Given
    the frontend sends a manipulated total
    When
    the order is created
    Then
    the backend ignores it and calculates the correct total

Technical design

One request, followed from the browser to the database. Each hop is a place where something can go wrong, and so a place to decide which test will watch it.
  1. Order form: Zod checks the text
  2. toCreateOrderPayload: a number, no total
  3. POST /orders
  4. JwtAuthGuard
  5. ValidationPipe and DTO
  6. OrdersService.create
  7. Prisma transaction: check, reduce stock, create
  8. 201 with totalCents
  9. TanStack Query refreshes the list

API contract

The contract is agreed before either side is built. It has no total on purpose.
POST /orders22 lines
1POST /orders
2Authorization: Bearer <jwt>
3
4// Request. There is no total field: the server owns the money.
5{
6 "items": [{ "productId": "ckx1a9", "quantity": 2 }]
7}
8
9// 201 Created
10{
11 "id": "ckz82f",
12 "status": "CONFIRMED",
13 "totalCents": 2500,
14 "items": [{ "productId": "ckx1a9", "quantity": 2, "unitPriceCents": 1250 }]
15}
16
17// Errors
18// 400 validation failed (quantity <= 0, not an integer, empty items)
19// 401 missing or invalid token
20// 404 product does not exist
21// 422 product is inactive
22// 409 quantity exceeds available stock

Frontend implementation

Next.js, Zod, TanStack Query and Zustand. The component validates and sends, it never calculates money.

Zod validates what the form gives us, which is always text. It improves the experience; the server still validates again.

order.schema.ts6 lines
1import { z } from "zod";
2
3export const orderFormSchema = z.object({
4 productId: z.string().min(1, "Select a product"),
5 quantity: z.string().regex(/^[1-9]\d*$/, "Enter a whole number of at least 1"),
6});

Backend and database interaction

NestJS and Prisma. The service holds the rules, and one transaction makes the stock change and the order creation succeed or fail together.

The server validation is the one that counts. With whitelist: true, unknown fields such as a fake total are stripped.

create-order.dto.ts14 lines
1import { Type } from "class-transformer";
2import { ArrayMinSize, IsInt, IsNotEmpty, IsString, Min, ValidateNested } from "class-validator";
3
4export class OrderItemDto {
5 @IsString() @IsNotEmpty() productId!: string;
6 @IsInt() @Min(1) quantity!: number;
7}
8
9export class CreateOrderDto {
10 @ArrayMinSize(1)
11 @ValidateNested({ each: true })
12 @Type(() => OrderItemDto)
13 items!: OrderItemDto[];
14}

Part 04

Unit testing lab

A unit test proves our own logic in isolation, in milliseconds. Pick the units that carry a decision, then walk the same seven-point checklist for each one.

Next.js: what to test

  • Order form renderingOrderForm.test.tsx
  • Form validationorder.schema.test.ts
  • Loading stateOrderForm.test.tsx
  • Error state and API error handlingOrderForm.test.tsx
  • Successful submissionOrderForm.test.tsx
  • Invalid quantity, empty product selectionorder.schema.test.ts
  • Zustand state behaviourorder-draft.store.test.ts
  • TanStack Query behaviour we rely onuseCreateOrder.test.tsx

NestJS: what to test

  • OrdersServiceorders.service.spec.ts
  • OrdersControllerorders.controller.spec.ts
  • DTO validationcreate-order.dto.spec.ts
  • Authentication guardjwt-auth.guard.spec.ts
  • Authorization logiccan-view-order.spec.ts
  • Order calculation utilityorder-total.spec.ts

The checklist for every unit

Before a unit is done, ask each question. The chips on every test file below show which ones it answers.
  • Happy path
  • Invalid input
  • Boundary condition
  • Error handling
  • Business rule
  • Dependency behaviour
  • Security / authorization

Test our logic

  • Which error does the service throw for an inactive product?
  • Does the form refuse quantity 0, and does it skip the API call?
  • Does the guard turn a bad token into 401?

Not framework internals

  • That useState stores a value. React tests that.
  • That class-validator knows what IsInt means. We only test that OUR DTO uses it.
  • That TanStack Query retries. We test what we do when it succeeds or fails.

Next.js: component, schema, store and hook

Vitest with Testing Library. Select a file to see which checklist items it covers.

A pure schema is the cheapest place to test input rules. Notice the boundary: 0 and 1.

  • Happy path
  • Invalid input
  • Boundary condition
  • Business rule
order.schema.test.ts18 lines
1import { describe, expect, it } from "vitest";
2import { orderFormSchema } from "./order.schema";
3
4const parse = (quantity: string) => orderFormSchema.safeParse({ productId: "p1", quantity });
5
6describe("orderFormSchema quantity", () => {
7 it.each(["1", "5", "120"])("accepts %s", (q) => {
8 expect(parse(q).success).toBe(true);
9 });
10
11 it.each(["", "0", "-1", "1.5", "abc", "01"])("rejects '%s'", (q) => {
12 expect(parse(q).success).toBe(false);
13 });
14
15 it("requires a product", () => {
16 expect(orderFormSchema.safeParse({ productId: "", quantity: "1" }).success).toBe(false);
17 });
18});

NestJS: util, DTO, service, controller, guard and authorization

The repository is replaced here, so the tests stay fast and focus on our branching.

The order total drives money, so this util gets the most exhaustive tests. This is the file Bug #1 breaks.

  • Happy path
  • Invalid input
  • Boundary condition
  • Business rule
order-total.spec.ts28 lines
1import { describe, expect, it } from "vitest";
2import { calculateOrderTotal } from "./order-total";
3
4describe("calculateOrderTotal", () => {
5 it("returns the unit price for a single item", () => {
6 expect(calculateOrderTotal([{ unitPriceCents: 1250, quantity: 1 }])).toBe(1250);
7 });
8
9 it("multiplies unit price by quantity", () => {
10 expect(calculateOrderTotal([{ unitPriceCents: 1000, quantity: 3 }])).toBe(3000);
11 });
12
13 it("sums several lines", () => {
14 const lines = [
15 { unitPriceCents: 1250, quantity: 2 },
16 { unitPriceCents: 400, quantity: 3 },
17 ];
18 expect(calculateOrderTotal(lines)).toBe(3700);
19 });
20
21 it("returns 0 for an empty list", () => {
22 expect(calculateOrderTotal([])).toBe(0);
23 });
24
25 it.each([0, -1, 1.5])("rejects quantity %s", (quantity) => {
26 expect(() => calculateOrderTotal([{ unitPriceCents: 1000, quantity }])).toThrow(RangeError);
27 });
28});

Part 05

Good tests and bad tests

A green test only helps if it can turn red. Each pair below tests the same thing; one would survive the bug, the other would not.

01Checking that a function was called

Passes even when the total is wrong.

Bad

bad: orders.service.spec.ts7 lines
1it("creates an order", async () => {
2 const { service, tx } = setup();
3
4 await service.create("u1", body(2));
5
6 expect(tx.order.create).toHaveBeenCalled();
7});

Better

better: orders.service.spec.ts8 lines
1it("stores the server-calculated total and the price at purchase time", async () => {
2 const order = await setup().service.create("u1", body(2));
3
4 expect(order.totalCents).toBe(2500);
5 expect(order.items).toEqual([
6 { productId: "p1", quantity: 2, unitPriceCents: 1250 },
7 ]);
8});

The bad test only proves that some code ran. Change the total to zero and it stays green. The better test pins the behaviour a business owner cares about.

02Testing React instead of our code

Breaks on every markup change, catches nothing.

Bad

bad: OrderForm.test.tsx10 lines
1it("stores quantity in state", () => {
2 const { result } = renderHook(() => useState("1"));
3 act(() => result.current[1]("2"));
4 expect(result.current[0]).toBe("2");
5});
6
7it("renders", () => {
8 const { container } = render(<OrderForm products={products} />);
9 expect(container).toMatchSnapshot();
10});

Better

better: OrderForm.test.tsx8 lines
1it("rejects quantity 0 without calling the API", async () => {
2 const user = setup();
3
4 await fill(user, "0");
5
6 expect(await screen.findByRole("alert")).toHaveTextContent("whole number");
7 expect(api.post).not.toHaveBeenCalled();
8});

React already guarantees that useState stores a value. A snapshot fails when someone changes a class name and still passes when validation is deleted. Test what the user sees and what the app does.

03Mocking everything

Gives false confidence about the database.

Bad

bad: unit test with Prisma fully mocked8 lines
1it("reduces stock", async () => {
2 const { service, prisma, tx } = setup();
3
4 await service.create("u1", body(2));
5
6 expect(prisma.$transaction).toHaveBeenCalledTimes(1);
7 expect(tx.product.updateMany).toHaveBeenCalledTimes(1);
8});

Better

better: integration test with real PostgreSQL8 lines
1it("reduces stock in the database", async () => {
2 const lamp = await seedProduct(prisma, { stock: 5 });
3
4 await post(user, { items: [{ productId: lamp.id, quantity: 2 }] }).expect(201);
5
6 const after = await prisma.product.findUniqueOrThrow({ where: { id: lamp.id } });
7 expect(after.stock).toBe(3);
8});

With every collaborator mocked, nothing checks the SQL. The where clause could be wrong, the update could be missing, the transaction could not roll back, and this test stays green. Use mocks for unit tests of our branching, then prove persistence against a real database.

If a test would still pass after you delete the code it is testing, it is not testing that code.

Part 06

Integration testing lab

Unit tests prove the pieces. Integration tests prove the pieces work together on a real stack, and they are the only place a transaction, a rollback or a race can be checked.

Unit

One class, everything around it replaced.

  1. OrdersService
  2. Repositorymocked

Integration

The whole path, with nothing replaced.

  1. HTTP
  2. Controller
  3. Service
  4. Prisma
  5. PostgreSQL

POST /orders: what to verify

Both the response and the database state, for every case.
  • Valid request
  • Missing authentication
  • Invalid product
  • Inactive product
  • Quantity = 0
  • Quantity > stock
  • Correct order total
  • Stock reduction
  • Database transaction
  • Database rollback
  • Correct HTTP status
  • Correct response structure

Integration tests with Vitest, supertest and PostgreSQL

Each test arranges real rows, sends a real request and then reads the rows back.

A disposable PostgreSQL that lives in memory. Never point integration tests at a shared development database.

setup (docker + vitest)20 lines
1# docker-compose.test.yml
2services:
3 db:
4 image: postgres:16
5 environment: { POSTGRES_PASSWORD: test, POSTGRES_DB: orders_test }
6 ports: ["5433:5432"]
7 tmpfs: /var/lib/postgresql/data # fast and disposable
8
9# package.json
10"test:int": "prisma migrate deploy && vitest run --config vitest.int.config.ts"
11
12# test/helpers.ts
13export const resetDb = (prisma: PrismaService) =>
14 prisma.$transaction([
15 prisma.orderItem.deleteMany(), prisma.order.deleteMany(),
16 prisma.product.deleteMany(), prisma.user.deleteMany(),
17 ]);
18
19export const seedProduct = (prisma: PrismaService, o: Partial<Product> = {}) =>
20 prisma.product.create({ data: { name: "Desk lamp", priceCents: 1250, stock: 5, active: true, ...o } });

Why checking only HTTP 201 is not enough

  • A 201 says the handler finished. It does not say the stock changed, the total is stored correctly or the rows belong to the right user.
  • Bug 4 below returns a perfect 201 with the right body while never touching the database. Only a read after the request catches it.
  • A rollback test needs a failure in the middle of the work, then a read to prove nothing was left behind.

Part 07

E2E testing with Playwright

End-to-end tests are the most expensive and the most realistic. Spend them on journeys a real user cannot live without, not on rules that a faster test already proves.

The journey

One path through the real system, from the first page to the final proof.
  1. Login
  2. Product page
  3. Select product
  4. Enter quantity
  5. Submit order
  6. Order confirmation
  7. Open order
  8. Verify details

Critical scenarios

Five journeys. That is the whole suite for this feature.
  1. 01Successful order creation
  2. 02Insufficient stock
  3. 03Invalid quantity
  4. 04Unauthorized access
  5. 05User cannot access another user's order

Where does each check belong?

Playwright does not replace unit tests, and it should not test every business rule.
Which test layer each check belongs to
CheckLayerWhy
Quantity 0 shows the right messageUnitInstant, no browser, no server.
Total equals price times quantityUnit, then IntegrationPure logic first, then the stored value.
The 409 and the stock boundaryIntegrationNeeds real SQL and a real transaction.
The rule: only the owner or admin may read an orderUnit and IntegrationProve the rule and the route without a browser.
A customer can place an order and view itE2EOnly a browser joins every layer.
Another user's order page shows a denialE2EOne journey. The rule itself is tested lower down.

Playwright tests

Seed data through the API, keep every test independent, and use auto-waiting assertions instead of sleeps.

Five critical journeys. Each one is something a real user does and a real business would be hurt by.

  • Happy path
  • Invalid input
  • Error handling
  • Security / authorization
create-order.e2e.ts59 lines
1import { expect, test } from "@playwright/test";
2import { loginAs, seed } from "./support";
3
4test.describe("Create order @p0", () => {
5 test("customer places an order and sees it in their order list", async ({ page }) => {
6 const { customer, lamp } = await seed({ stock: 5, priceCents: 1250 });
7 await loginAs(page, customer);
8
9 await page.goto("/products");
10 await page.getByRole("link", { name: lamp.name }).click();
11 await page.getByLabel("Quantity").fill("2");
12 await page.getByRole("button", { name: "Place order" }).click();
13
14 await expect(page.getByRole("status")).toContainText("confirmed");
15 await page.getByRole("link", { name: "View order" }).click();
16 await expect(page.getByText(lamp.name)).toBeVisible();
17 await expect(page.getByTestId("order-total")).toContainText("25.00");
18 });
19
20 test("insufficient stock shows an error", async ({ page }) => {
21 const { customer, lamp } = await seed({ stock: 1 });
22 await loginAs(page, customer);
23
24 await page.goto("/products/" + lamp.id);
25 await page.getByLabel("Quantity").fill("2");
26 await page.getByRole("button", { name: "Place order" }).click();
27
28 await expect(page.getByRole("alert")).toContainText("Insufficient stock");
29 await expect(page.getByRole("button", { name: "Place order" })).toBeEnabled();
30 });
31
32 test("invalid quantity is stopped before any request", async ({ page }) => {
33 const { customer, lamp } = await seed({ stock: 5 });
34 await loginAs(page, customer);
35 const posts: string[] = [];
36 page.on("request", (r) => r.method() === "POST" && r.url().includes("/orders") && posts.push(r.url()));
37
38 await page.goto("/products/" + lamp.id);
39 await page.getByLabel("Quantity").fill("0");
40 await page.getByRole("button", { name: "Place order" }).click();
41
42 await expect(page.getByRole("alert")).toContainText("whole number");
43 expect(posts).toHaveLength(0);
44 });
45
46 test("unauthenticated visitor is sent to the login page", async ({ page }) => {
47 await page.goto("/orders");
48 await expect(page).toHaveURL(/\/login/);
49 });
50
51 test("customer cannot open another user's order", async ({ page }) => {
52 const { customer, orderOfSomeoneElse } = await seed({ stock: 5, orderForAnotherUser: true });
53 await loginAs(page, customer);
54
55 await page.goto("/orders/" + orderOfSomeoneElse.id);
56
57 await expect(page.getByRole("heading", { name: "You don't have access to this order" })).toBeVisible();
58 });
59});

Part 08

Regression testing

Regression testing asks one question: did my change break something that previously worked? Group the suite by business risk, then decide how often each group has to run.

P0

Critical business flows

  • Login
  • Authentication
  • Create order
  • View order
  • Critical permission flow

Runs: Every pull request

P1

Important flows

  • Product search
  • Product filtering
  • Order update
  • User management

Runs: Before UAT

P2

Secondary flows

  • Less frequently used features
  • Admin utilities
  • Edge workflows

Runs: Full regression, before UAT

Which tests run when

Cheap tests run often, expensive tests run at the moments that matter.
  1. 01Every developer changeUnit testsSeconds, in watch mode
  2. 02Every PRUnit + Integration + critical E2E (@p0)About 10 minutes
  3. 03Before UATFull regression: P0 + P1 + P2Under an hour
  4. 04Before productionRelease regression on the release candidate, then a production smoke testPlus a watched deploy

Tag the suite once, select it anywhere

A tag on each test lets the same suite serve every stage.
regression tags10 lines
1test("customer places an order", { tag: "@p0" }, async ({ page }) => { /* ... */ });
2test("admin can deactivate a product", { tag: "@p1" }, async ({ page }) => { /* ... */ });
3test("export orders as CSV", { tag: "@p2" }, async ({ page }) => { /* ... */ });
4
5# every PR
6pnpm test:e2e --grep @p0
7# before UAT: full regression
8pnpm test:e2e
9# release candidate
10pnpm test:e2e --grep "@p0|@p1"

Part 09

When tests fail, on purpose

Five bugs, each caught by a different layer. For every one: run the tests, read the failure, then apply the fix and watch it go green. Bug 1 runs the real function in your browser.

Lab: break it on purpose

The order total is wrong

src/orders/order-total.ts

The change: A refactor of the reduce dropped the multiplication by quantity.

with the bug / src/orders/order-total.ts6 lines
1export function calculateOrderTotal(lines: Line[]): number {
2 return lines.reduce((sum, l) => {
3 assertQuantity(l.quantity);
4 return sum + l.unitPriceCents;
5 }, 0);
6}
$ vitest run order-total.spec.ts

Part 10

Testing clearance

A feature is not ready because the developer feels good about it. It is ready when every line below has evidence behind it. Tick the lines as you would in a real clearance.

Lab: clear the feature

This lab builds on When tests fail. Finish it first, then come back.

Not cleared yet

0 of 34 lines have evidence, 34 open

Code0/4
Unit0/6
Integration0/7
E2E0/4
Regression0/3
Code review0/5
Clearance0/5

Part 11

Final team exercise

A new requirement, no solution. The team applies the framework from this lab to something unseen, then compares its answers with the reference.

Lab: the team exercise

This lab builds on Clearance. Finish it first, then come back.

Go to Clearance

New requirement

“Allow users to cancel an existing order.”

Do not read the reference answers yet. Give the team ten minutes, write answers for every item, then open each card and compare.

0 of 10 reference answers opened

Review their answers with this lens

  • Did every business rule get at least one test, and at the cheapest layer that can prove it?
  • Is there a failure and a permission test, not only a happy path?
  • Which rules were put in Playwright that belong in unit or integration tests?
  • Which database behaviour (restore stock, rollback, idempotency) was left to mocks?
  • Does each clearance line name its evidence, or only say "tested"?
  • Is there a reason for every test? If not, what would you delete?

Part 12

Every test should have a reason to exist

Leave the lab with one picture in your head: the path a feature takes, and the evidence that lets it move on.

  1. Requirement
  2. Design
  3. Development
  4. Unit
  5. Integration
  6. E2E
  7. Regression
  8. Clearance
  9. UAT
  10. Release

Testing is not about proving that the code works.

Testing is about creating confidence that the software behaves correctly under expected, unexpected and changed conditions.