Master Jest's core API: describe blocks, it/test functions, expect assertions, and beforeEach/afterEach lifecycle hooks for writing well-organised, maintainable test suites.
Jest is the de-facto standard test runner for JavaScript and TypeScript projects. Facebook built it to replace a fragmented ecosystem of Jasmine, Mocha, Chai, and Sinon with a single zero-configuration tool. Understanding Jest's structure is not optional — every other tool in the testing ecosystem (React Testing Library, Supertest, Playwright component testing) builds on top of Jest's vocabulary.
This lesson maps Jest's core building blocks to the code you write every day. You will understand why describe exists (grouping, not magic), what the difference between toBe and toEqual is (reference equality vs deep equality), and how to use lifecycle hooks without accidentally coupling tests together. By the end you will be able to read any Jest test file and immediately understand its structure, and write your own test files that are easy for teammates to extend.
Jest uses describe to group related tests and it (alias test) to define individual test cases. The nesting creates a readable hierarchy that maps to the module structure being tested.
1// src/string-utils.ts
2export const truncate = (str: string, max: number): string =>
3 str.length <= max ? str : str.slice(0, max) + '...';
4
5export const capitalize = (str: string): string =>
6 str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
7
8export const countWords = (str: string): number =>
9 str.trim() === '' ? 0 : str.trim().split(/\s+/).length;
10
11// src/string-utils.test.ts
12import { truncate, capitalize, countWords } from './string-utils';
13
14describe('string-utils', () => {
15 describe('truncate', () => {
16 it('returns the original string when shorter than max', () => {
17 expect(truncate('Hello', 10)).toBe('Hello');
18 });
19
20 it('truncates and appends ellipsis when longer than max', () => {
21 expect(truncate('Hello, World!', 5)).toBe('Hello...');
22 });
23
24 it('returns the original string when equal to max', () => {
25 expect(truncate('Hello', 5)).toBe('Hello');
26 });
27 });
28
29 describe('capitalize', () => {
30 it('capitalises the first letter and lowercases the rest', () => {
31 expect(capitalize('hELLO')).toBe('Hello');
32 });
33 });
34
35 describe('countWords', () => {
36 it('returns 0 for an empty string', () => {
37 expect(countWords('')).toBe(0);
38 });
39
40 it('counts words separated by multiple spaces', () => {
41 expect(countWords('one two three')).toBe(3);
42 });
43 });
44});Describe blocks nest naturally. Each test's full name in Jest output becomes the path: string-utils > truncate > returns the original string when shorter than max. This makes skimming a failure report effortless because the failure name tells you exactly which function and which behaviour broke.
Jest ships with over 30 matchers. Knowing which to use prevents subtle false positives:
**toBe** — strict equality using Object.is. Use for primitives (numbers, strings, booleans, null, undefined). Never use for objects or arrays.
**toEqual** — deep structural equality. Use for objects and arrays where reference identity does not matter.
**toStrictEqual** — like toEqual but also checks for undefined properties and array holes. Prefer for TypeScript objects.
1test('toBe vs toEqual', () => {
2 const a = { x: 1 };
3 const b = { x: 1 };
4
5 expect(a).not.toBe(b); // different references — PASSES
6 expect(a).toEqual(b); // same structure — PASSES
7 expect(a).toStrictEqual(b); // same structure, same types — PASSES
8
9 expect(1 + 1).toBe(2); // primitive — use toBe
10});
11
12test('string matchers', () => {
13 const msg = 'Hello, World!';
14 expect(msg).toContain('World'); // substring
15 expect(msg).toMatch(/^Hello/); // regex
16 expect(msg).toHaveLength(13); // length
17 expect(msg).not.toContain('Goodbye'); // negation
18});
19
20test('array matchers', () => {
21 const arr = [1, 2, 3, 4, 5];
22 expect(arr).toHaveLength(5);
23 expect(arr).toContain(3);
24 expect(arr).toEqual(expect.arrayContaining([2, 4])); // subset
25});
26
27test('object matchers', () => {
28 const user = { id: 1, name: 'Alice', role: 'admin', createdAt: new Date() };
29 // toMatchObject checks a subset of properties
30 expect(user).toMatchObject({ name: 'Alice', role: 'admin' });
31});
32
33test('error matchers', () => {
34 const dangerous = () => { throw new TypeError('bad input'); };
35 expect(dangerous).toThrow();
36 expect(dangerous).toThrow('bad input'); // message substring
37 expect(dangerous).toThrow(TypeError); // error class
38 expect(dangerous).toThrow(/bad/); // regex on message
39});Lifecycle hooks run before or after tests to set up shared state or clean up resources. The golden rule: **prefer beforeEach over beforeAll** for state that tests might mutate, because beforeAll runs once and mutations leak between tests.
1import { UserService } from './user-service';
2
3describe('UserService', () => {
4 let service: UserService;
5
6 // Runs before every single test — guarantees a fresh instance
7 beforeEach(() => {
8 service = new UserService();
9 });
10
11 // Runs after every test — good for clearing mocks
12 afterEach(() => {
13 jest.clearAllMocks();
14 });
15
16 it('creates a user with a generated id', () => {
17 const user = service.create({ name: 'Alice', email: '[email protected]' });
18 expect(user.id).toBeDefined();
19 expect(user.name).toBe('Alice');
20 });
21
22 it('returns undefined for unknown ids', () => {
23 const user = service.findById('nonexistent');
24 expect(user).toBeUndefined();
25 });
26
27 it('lists only active users', () => {
28 service.create({ name: 'Alice', email: '[email protected]' });
29 service.create({ name: 'Bob', email: '[email protected]' });
30 service.deactivate('alice-id');
31 // Because beforeEach creates a fresh service, we start from zero
32 expect(service.listActive()).toHaveLength(2); // both users start active
33 });
34});beforeAll / afterAll are appropriate for expensive setup that cannot be done per-test (spinning up a database connection, compiling WebAssembly). Never use them for mutable state that tests modify.
1describe('DB-heavy tests', () => {
2 let db: Database;
3
4 beforeAll(async () => {
5 db = await Database.connect(process.env['TEST_DATABASE_URL']!);
6 });
7
8 afterAll(async () => {
9 await db.disconnect();
10 });
11
12 beforeEach(async () => {
13 await db.truncateAll(); // still reset data per-test
14 });
15});Understanding Jest's CLI flags dramatically improves your development workflow:
1# Run all tests once
2npx jest
3
4# Watch mode — re-runs only tests affected by changed files
5npx jest --watch
6
7# Run a single test file
8npx jest src/cart.test.ts
9
10# Run tests matching a name pattern (-t = test name)
11npx jest -t 'calculates subtotal'
12
13# Coverage report in terminal + HTML
14npx jest --coverage
15
16# Fail if coverage drops below thresholds
17npx jest --coverage --coverageThreshold='{"global":{"lines":80}}'
18
19# Run tests in a specific directory
20npx jest src/modules/auth
21
22# Show verbose output (each test name, not just file names)
23npx jest --verbose
24
25# Run tests in band (single process) — needed for tests sharing global state
26npx jest --runInBand
27
28# Update snapshots
29npx jest --updateSnapshotpackage.json configuration (recommended):
1{
2 "scripts": {
3 "test": "jest",
4 "test:watch": "jest --watch",
5 "test:coverage": "jest --coverage",
6 "test:ci": "jest --coverage --runInBand --forceExit"
7 }
8}In CI (GitHub Actions, GitLab CI), always use test:ci. The --runInBand flag prevents Jest from forking worker processes, which can exhaust memory on small CI runners. --forceExit prevents Jest from hanging when async code does not clean up open handles.
Jest's failure output is information-dense once you know how to read it:
1FAIL src/cart.test.ts
2 calculateSubtotal
3 x calculates total correctly for multiple items (12 ms)
4
5 ● calculateSubtotal > calculates total correctly for multiple items
6
7 expect(received).toBe(expected)
8
9 Expected: 60
10 Received: 35
11
12 45 | it('calculates total correctly for multiple items', () => {
13 46 | const cart = addItem(addItem(emptyCart, sampleItem), anotherItem);
14 > 47 | expect(calculateSubtotal(cart)).toBe(60);
15 | ^
16 48 | });
17
18 at Object.<anonymous> (src/cart.test.ts:47:36)Reading checklist:
The most common mistake is misreading Expected and Received. Jest prints Expected as what *you wrote in the test* and Received as what *the code actually returned*. If Received is wrong, fix the implementation. If Expected is wrong, fix the test.
1// Pattern: use descriptive test names so the failure output reads like a spec
2it('returns the discounted price when a valid coupon is applied', () => {
3 // Failure output: "returns the discounted price when a valid coupon is applied"
4 // This sentence tells you exactly what went wrong without reading the code
5 expect(applyDiscount(100, 'SAVE20')).toBe(80);
6});A TypeScript email validation module covered with a full Jest test suite demonstrating all core matchers, lifecycle hooks, and test organisation patterns.
1// src/email-validator.ts
2export interface ValidationResult {
3 valid: boolean;
4 error?: string;
5 normalised?: string;
6}
7
8export function validateEmail(email: string): ValidationResult {
9 if (typeof email !== 'string' || email.trim() === '') {
10 return { valid: false, error: 'Email must be a non-empty string' };
11 }
12
13 const trimmed = email.trim().toLowerCase();
14 const emailRegex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/;
15
16 if (!emailRegex.test(trimmed)) {
17 return { valid: false, error: `'${trimmed}' is not a valid email address` };
18 }
19
20 return { valid: true, normalised: trimmed };
21}
22
23export function validateEmailList(emails: string[]): {
24 valid: string[];
25 invalid: Array<{ email: string; error: string }>;
26} {
27 const valid: string[] = [];
28 const invalid: Array<{ email: string; error: string }> = [];
29
30 for (const email of emails) {
31 const result = validateEmail(email);
32 if (result.valid && result.normalised) {
33 valid.push(result.normalised);
34 } else {
35 invalid.push({ email, error: result.error! });
36 }
37 }
38
39 return { valid, invalid };
40}
41
42// src/email-validator.test.ts
43import { validateEmail, validateEmailList } from './email-validator';
44
45describe('validateEmail', () => {
46 describe('valid emails', () => {
47 const validEmails = [
48 '[email protected]',
49 '[email protected]',
50 '[email protected]',
51 ' [email protected] ',
52 ];
53
54 it.each(validEmails)('accepts valid email: %s', (email) => {
55 const result = validateEmail(email);
56 expect(result.valid).toBe(true);
57 expect(result.normalised).toBeDefined();
58 expect(result.error).toBeUndefined();
59 });
60
61 it('normalises email to lowercase and trims whitespace', () => {
62 const result = validateEmail(' [email protected] ');
63 expect(result.normalised).toBe('[email protected]');
64 });
65 });
66
67 describe('invalid emails', () => {
68 it('rejects empty string', () => {
69 const result = validateEmail('');
70 expect(result.valid).toBe(false);
71 expect(result.error).toContain('non-empty string');
72 });
73
74 it('rejects whitespace-only string', () => {
75 const result = validateEmail(' ');
76 expect(result.valid).toBe(false);
77 });
78
79 it('rejects email without @ symbol', () => {
80 const result = validateEmail('notanemail');
81 expect(result.valid).toBe(false);
82 expect(result.error).toMatch(/not a valid email/);
83 });
84
85 it('rejects email without domain extension', () => {
86 const result = validateEmail('user@nodot');
87 expect(result.valid).toBe(false);
88 });
89
90 it('returns a result matching the error shape', () => {
91 expect(validateEmail('bad')).toMatchObject({
92 valid: false,
93 error: expect.any(String),
94 });
95 });
96 });
97});
98
99describe('validateEmailList', () => {
100 it('separates valid from invalid emails', () => {
101 const result = validateEmailList([
102 '[email protected]',
103 'not-an-email',
104 '[email protected]',
105 ]);
106
107 expect(result.valid).toHaveLength(2);
108 expect(result.valid).toContain('[email protected]');
109 expect(result.valid).toContain('[email protected]');
110
111 expect(result.invalid).toHaveLength(1);
112 expect(result.invalid[0]).toMatchObject({
113 email: 'not-an-email',
114 error: expect.stringContaining('not a valid'),
115 });
116 });
117
118 it('returns empty arrays for empty input', () => {
119 const result = validateEmailList([]);
120 expect(result.valid).toEqual([]);
121 expect(result.invalid).toEqual([]);
122 });
123
124 it('handles a list of only invalid emails', () => {
125 const result = validateEmailList(['a', 'b', 'c']);
126 expect(result.valid).toHaveLength(0);
127 expect(result.invalid).toHaveLength(3);
128 });
129});This example shows: (1) nested describe blocks that mirror the module's two exported functions, (2) it.each for data-driven tests against multiple valid email addresses, (3) toMatchObject for checking a subset of object properties without caring about extra fields, (4) toContain and toMatch for flexible string assertions, (5) toBeDefined and toBeUndefined for optional fields. Run npx jest --verbose to see all test names in the hierarchy, then npx jest --coverage to confirm 100% function and branch coverage.
The next lesson formalises the Arrange-Act-Assert pattern, covers test naming conventions that produce self-documenting failure messages, and explores how to organise test files alongside source files for maximum cohesion.
Your progress is saved in this browser, on this device only.