Understand what unit testing is, why it is essential for production software, and how the testing pyramid guides your test strategy for balanced, maintainable test suites.
Every production codebase that survives more than six months without collapsing under its own weight has one thing in common: a disciplined test suite. Unit tests are the foundation of that suite. They run in milliseconds, catch regressions the moment they are introduced, and give developers the confidence to refactor mercilessly without fear of breaking working behavior.
This lesson answers three questions: what exactly is a unit test, why should you care, and how does it fit into the broader testing strategy embodied by the testing pyramid. By the end you will understand the FIRST principles that distinguish a high-quality unit test from a fragile one, and you will have written your first real Jest test against a TypeScript function. The skills in this lesson directly underpin every subsequent lesson in this module.
A unit test exercises the smallest testable piece of your codebase in isolation from all external dependencies — no database, no network, no filesystem, no random values, no real clock. In JavaScript and TypeScript a 'unit' is typically a pure function, a class method, or a composable hook.
The critical word is *isolation*. If a test fails, you should be able to pinpoint the broken logic without reading a stack trace that crosses three modules. That isolation is what makes unit tests run in under 10 milliseconds each, and what makes a suite of 1,000 tests complete in seconds.
1// Pure function — easiest to test; no dependencies to isolate
2export function calculateDiscount(price: number, percentOff: number): number {
3 if (percentOff < 0 || percentOff > 100) {
4 throw new RangeError(`percentOff must be 0-100, got ${percentOff}`);
5 }
6 return price * (1 - percentOff / 100);
7}
8
9// Unit test — no database, no HTTP, no file I/O
10import { calculateDiscount } from './pricing';
11
12test('applies 20% discount correctly', () => {
13 expect(calculateDiscount(100, 20)).toBe(80);
14});
15
16test('throws RangeError for negative discount', () => {
17 expect(() => calculateDiscount(100, -5)).toThrow(RangeError);
18});Contrast this with an integration test that calls a real pricing API or reads from a database — that test is 100x slower and fails whenever the network is slow, even if your logic is correct. Unit tests never have that problem because they own all their inputs and outputs.
Mike Cohn's testing pyramid describes the optimal distribution of test types by count, speed, and cost. The pyramid has three layers:
Base — Unit Tests (70%): Hundreds or thousands of them. Each runs in under 5 ms. They test logic in isolation and give pinpoint failure messages. Cost per test: very low. Value per dollar: extremely high.
Middle — Integration Tests (20%): Dozens to hundreds. Each runs in 50–500 ms. They test how two or more modules cooperate — a service calling a real database, a controller parsing a real HTTP request. Cost per test: moderate.
Top — End-to-End Tests (10%): Tens, rarely hundreds. Each runs in 2–30 seconds. They test complete user flows through a real browser against a real backend. Cost per test: high. Fragile to environment changes.
1 /\
2 / \ E2E (~10%)
3 /----\ - Real browser + real API
4 / \ - Slow (seconds)
5 /--------\ Integration (~20%)
6 / \ - Real DB / real HTTP
7 /------------\ Unit (~70%)
8 / \ - Pure logic, fully mocked
9/________________\ - Milliseconds, thousands of themThe pyramid is not a rigid rule but a heuristic. A codebase with all E2E tests and no unit tests is an 'ice cream cone' anti-pattern: slow, expensive, and fragile. A codebase with only unit tests misses integration bugs.
For a typical Next.js + NestJS project, aim for: 500+ unit tests, 100+ integration tests, 30+ E2E tests. That ratio gives fast feedback locally (unit tests run on every file save) and high confidence on CI (integration + E2E run before merging).
Tim Ottinger and Brett Schuchert coined the FIRST acronym to describe properties every unit test must have:
F — Fast: A unit test must complete in milliseconds. A test that takes 2 seconds is not a unit test — something leaked in (database, HTTP call, filesystem). Fast tests run on every save, giving instant feedback.
I — Isolated / Independent: Tests must not share state. If test A stores data in a module-level variable that test B reads, running tests in a different order produces different results. Each test must set up its own state and tear it down.
R — Repeatable: Running the same test 100 times must produce the same result every time. Tests that call Date.now(), Math.random(), or real APIs are not repeatable. Mock or inject these values.
S — Self-Validating: The test must produce a clear pass or fail without a human reading output. expect(result).toBe(42) is self-validating. console.log(result) is not.
T — Timely: Tests should be written alongside or just before the code they test. Tests written months after the code skip known edge cases because the author has forgotten the intent.
1// VIOLATES Isolated — uses module-level mutation
2let counter = 0;
3test('A', () => { counter++; expect(counter).toBe(1); });
4test('B', () => { expect(counter).toBe(0); }); // FAILS if A ran first
5
6// CORRECT — each test is independent
7test('A', () => { const c = 0 + 1; expect(c).toBe(1); });
8test('B', () => { const c = 0; expect(c).toBe(0); });
9
10// VIOLATES Repeatable — real Date produces flaky results
11test('creates timestamp', () => {
12 const ts = createRecord();
13 expect(ts.createdAt).toBe(Date.now()); // fails by microseconds
14});
15
16// CORRECT — mock the system clock
17beforeEach(() => jest.useFakeTimers());
18afterEach(() => jest.useRealTimers());
19
20test('creates timestamp', () => {
21 jest.setSystemTime(new Date('2024-01-01T00:00:00Z'));
22 const ts = createRecord();
23 expect(ts.createdAt).toBe(1704067200000);
24});The compounding cost of untested code is best understood through the concept of technical debt interest. When you add a feature without tests, you spend 30 minutes. Six months later, when you change a related function and accidentally break the feature, you spend 4 hours debugging because there is no test to immediately point at the regression. That 4-hour debugging session is the interest payment on the 30-minute debt.
Unit tests are executable documentation. They describe the expected behavior of a function in code that cannot lie. Comments go stale — they describe what the author intended but do not fail when the code diverges. Tests fail immediately when behavior changes unexpectedly.
1// 'Documentation' that can lie:
2/**
3 * Formats a price with currency symbol.
4 * Always returns 2 decimal places.
5 */
6export function formatPrice(amount: number, currency: string): string {
7 return `${currency}${amount}`; // BUG: no decimal rounding
8}
9
10// Tests that CANNOT lie — they break when the code is wrong:
11describe('formatPrice', () => {
12 it('formats USD with 2 decimal places', () => {
13 expect(formatPrice(9.9, '$')).toBe('$9.90'); // FAILS — reveals bug
14 });
15
16 it('formats zero amount correctly', () => {
17 expect(formatPrice(0, '€')).toBe('€0.00');
18 });
19
20 it('handles large amounts', () => {
21 expect(formatPrice(1000, '$')).toBe('$1,000.00');
22 });
23});The test suite also enables fearless refactoring. When you rewrite formatPrice using Intl.NumberFormat, you run the tests and immediately know if the behavior changed. Without tests, you manually QA every entry point — a process that slows down and becomes less thorough as deadlines approach.
Jest is the dominant JavaScript test runner, used by React, NestJS, and most of the Node.js ecosystem. Configuring it with TypeScript via ts-jest takes under 5 minutes.
1# Install Jest + TypeScript support
2npm install --save-dev jest ts-jest @types/jest typescript1// jest.config.ts (at project root)
2import type { Config } from 'jest';
3
4const config: Config = {
5 preset: 'ts-jest',
6 testEnvironment: 'node',
7 roots: ['<rootDir>/src'],
8 testMatch: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'],
9 collectCoverageFrom: [
10 'src/**/*.ts',
11 '!src/**/*.d.ts',
12 '!src/**/index.ts',
13 ],
14 coverageThreshold: {
15 global: { branches: 80, functions: 80, lines: 80, statements: 80 },
16 },
17};
18
19export default config;1// src/math.ts
2export function add(a: number, b: number): number {
3 return a + b;
4}
5
6export function divide(a: number, b: number): number {
7 if (b === 0) throw new Error('Division by zero');
8 return a / b;
9}
10
11// src/math.test.ts
12import { add, divide } from './math';
13
14describe('add', () => {
15 it('returns the sum of two positive numbers', () => {
16 expect(add(2, 3)).toBe(5);
17 });
18
19 it('handles negative numbers', () => {
20 expect(add(-1, 1)).toBe(0);
21 });
22});
23
24describe('divide', () => {
25 it('divides two numbers correctly', () => {
26 expect(divide(10, 2)).toBe(5);
27 });
28
29 it('throws on division by zero', () => {
30 expect(() => divide(5, 0)).toThrow('Division by zero');
31 });
32});Run npx jest to confirm both tests pass. Then npx jest --coverage to see line-by-line coverage output in the terminal and a summary table.
A complete shopping cart utility module tested with Jest and TypeScript, demonstrating isolation, edge case coverage, and immutability verification.
1// src/cart.ts
2export interface CartItem {
3 id: string;
4 name: string;
5 price: number;
6 quantity: number;
7}
8
9export interface Cart {
10 items: CartItem[];
11 couponCode?: string;
12}
13
14export function addItem(cart: Cart, item: CartItem): Cart {
15 const existing = cart.items.find((i) => i.id === item.id);
16 if (existing) {
17 return {
18 ...cart,
19 items: cart.items.map((i) =>
20 i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i
21 ),
22 };
23 }
24 return { ...cart, items: [...cart.items, item] };
25}
26
27export function removeItem(cart: Cart, itemId: string): Cart {
28 return { ...cart, items: cart.items.filter((i) => i.id !== itemId) };
29}
30
31export function calculateSubtotal(cart: Cart): number {
32 return cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
33}
34
35export function applyCoupon(subtotal: number, code: string): number {
36 const discounts: Record<string, number> = {
37 SAVE10: 0.1,
38 SAVE20: 0.2,
39 HALFOFF: 0.5,
40 };
41 const rate = discounts[code];
42 if (!rate) throw new Error(`Invalid coupon: ${code}`);
43 return subtotal * (1 - rate);
44}
45
46// src/cart.test.ts
47import { addItem, removeItem, calculateSubtotal, applyCoupon, Cart } from './cart';
48
49const emptyCart: Cart = { items: [] };
50const sampleItem = { id: 'p1', name: 'Widget', price: 10.00, quantity: 1 };
51const anotherItem = { id: 'p2', name: 'Gadget', price: 25.00, quantity: 2 };
52
53describe('addItem', () => {
54 it('adds a new item to an empty cart', () => {
55 const cart = addItem(emptyCart, sampleItem);
56 expect(cart.items).toHaveLength(1);
57 expect(cart.items[0]).toEqual(sampleItem);
58 });
59
60 it('increases quantity when the same item is added again', () => {
61 const cart = addItem(emptyCart, sampleItem);
62 const updated = addItem(cart, { ...sampleItem, quantity: 3 });
63 expect(updated.items).toHaveLength(1);
64 expect(updated.items[0].quantity).toBe(4);
65 });
66
67 it('does not mutate the original cart', () => {
68 const original = addItem(emptyCart, sampleItem);
69 addItem(original, anotherItem); // discard result
70 expect(original.items).toHaveLength(1);
71 });
72
73 it('adds multiple distinct items', () => {
74 const cart = addItem(addItem(emptyCart, sampleItem), anotherItem);
75 expect(cart.items).toHaveLength(2);
76 });
77});
78
79describe('removeItem', () => {
80 it('removes an item by id', () => {
81 const cart = addItem(addItem(emptyCart, sampleItem), anotherItem);
82 const updated = removeItem(cart, 'p1');
83 expect(updated.items).toHaveLength(1);
84 expect(updated.items[0].id).toBe('p2');
85 });
86
87 it('returns unchanged cart when id does not exist', () => {
88 const cart = addItem(emptyCart, sampleItem);
89 const updated = removeItem(cart, 'nonexistent');
90 expect(updated.items).toHaveLength(1);
91 });
92});
93
94describe('calculateSubtotal', () => {
95 it('returns 0 for empty cart', () => {
96 expect(calculateSubtotal(emptyCart)).toBe(0);
97 });
98
99 it('calculates total correctly for multiple items', () => {
100 const cart = addItem(addItem(emptyCart, sampleItem), anotherItem);
101 // 10.00 * 1 + 25.00 * 2 = 60.00
102 expect(calculateSubtotal(cart)).toBe(60);
103 });
104});
105
106describe('applyCoupon', () => {
107 it('applies SAVE10 to subtract 10%', () => {
108 expect(applyCoupon(100, 'SAVE10')).toBe(90);
109 });
110
111 it('applies HALFOFF to reduce by 50%', () => {
112 expect(applyCoupon(80, 'HALFOFF')).toBe(40);
113 });
114
115 it('throws an error for an invalid coupon code', () => {
116 expect(() => applyCoupon(100, 'FAKE99')).toThrow('Invalid coupon: FAKE99');
117 });
118});This example demonstrates immutable cart operations (every function returns a new cart without mutating the input), edge case coverage (empty cart, duplicate items, invalid coupons), and clean test organisation using describe blocks. Each test has exactly one reason to fail. The immutability check on addItem proves the function does not mutate the caller's data — a class of bug that causes subtle UI state bugs in React. Run npx jest --coverage to see 100% branch coverage for this module.
The next lesson covers Jest's core API in depth: describe/it/expect, the most useful matchers (toBe vs toEqual, toContain, toMatchObject, toThrow), and how to structure test files with beforeEach and afterEach hooks for clean setup and teardown.
Your progress is saved in this browser, on this device only.