Learn the Arrange-Act-Assert pattern, write test names that produce self-documenting failure messages, and organise test files alongside source for maximum cohesion.
A test suite is only as valuable as its readability. A failing test that takes five minutes to understand costs your team hours every month. The Arrange-Act-Assert (AAA) pattern, combined with precise test naming, transforms test files from opaque code into living documentation that any developer can parse in seconds.
This lesson covers the structural patterns that separate professional test suites from amateur ones. These are not style preferences — they are engineering decisions that directly affect how quickly bugs are found, diagnosed, and fixed. By the end you will write tests that are immediately understandable to any Jest-literate developer, and organise test files in a way that scales from a 10-file project to a 200-file monorepo.
AAA is the universal structure for a unit test. Every test has exactly three phases:
Separating these phases visually (with blank lines) makes the test scannable:
1import { calculateTax } from './tax-calculator';
2
3test('applies 20% VAT to a positive price', () => {
4 // Arrange
5 const price = 100;
6 const vatRate = 0.20;
7
8 // Act
9 const result = calculateTax(price, vatRate);
10
11 // Assert
12 expect(result).toBe(120);
13});Violations of AAA are easy to spot and signal test design problems:
1// BAD: multiple Acts — two things being tested in one test
2test('user operations', () => {
3 const user = createUser('Alice'); // Act 1
4 expect(user.name).toBe('Alice'); // Assert 1
5 const updated = updateUser(user, 'Bob'); // Act 2
6 expect(updated.name).toBe('Bob'); // Assert 2
7});
8// When this test fails on line 4, you do not know if createUser or updateUser broke.
9
10// GOOD: one Act per test
11test('createUser sets the name from the argument', () => {
12 const user = createUser('Alice');
13 expect(user.name).toBe('Alice');
14});
15
16test('updateUser replaces the name', () => {
17 const user = createUser('Alice'); // Arrange — setup, not Act
18 const updated = updateUser(user, 'Bob'); // Act
19 expect(updated.name).toBe('Bob');
20});The rule of thumb: a test has one reason to fail. If you can describe two different bugs that would cause the same test to fail, split it into two tests.
Test names are the first thing you read when a test fails. A good name makes the failure message self-contained — you understand what broke without opening the source file.
Pattern 1: 'it should [expected behaviour] when [condition]'
1describe('passwordValidator', () => {
2 it('should return valid when password has 8+ characters and a number', () => { ... });
3 it('should return invalid when password is shorter than 8 characters', () => { ... });
4 it('should return invalid when password has no numeric digit', () => { ... });
5 it('should throw TypeError when input is not a string', () => { ... });
6});Pattern 2: Given-When-Then (BDD style)
1describe('ShoppingCart', () => {
2 describe('given an empty cart', () => {
3 it('when an item is added, then the cart has one item', () => { ... });
4 it('when subtotal is calculated, then it returns 0', () => { ... });
5 });
6
7 describe('given a cart with items', () => {
8 it('when an existing item is added again, then quantity increases', () => { ... });
9 it('when an item is removed, then cart length decreases by 1', () => { ... });
10 });
11});Anti-patterns to avoid:
1// BAD: vague — what does 'works' mean?
2test('works', () => { ... });
3
4// BAD: implementation detail — 'calls the function' adds no value
5test('calls calculateTax with price and rate', () => { ... });
6
7// BAD: 'test' prefix is redundant — Jest already knows it's a test
8test('test that discount is applied', () => { ... });
9
10// GOOD: behaviour-focused, specific
11test('returns 0 discount when coupon code is expired', () => { ... });
12test('throws InvalidCouponError when code does not exist in registry', () => { ... });The best test names read as sentences that describe a scenario and its expected outcome. When the CI pipeline fails, the test name alone should tell the oncall engineer what user-facing behaviour broke.
The 'one assertion per test' guideline means each test has one reason to fail, not literally one expect() call. Multiple assertions are acceptable when they collectively verify a single logical outcome.
1// ACCEPTABLE: multiple expects for a single logical outcome
2// If any one fails, the test name 'creates user with correct initial state' still precisely describes the failure
3test('creates user with correct initial state', () => {
4 const user = createUser({ name: 'Alice', email: '[email protected]' });
5
6 expect(user.id).toBeDefined(); // all three describe
7 expect(user.name).toBe('Alice'); // the same single outcome:
8 expect(user.active).toBe(true); // 'user is correctly initialised'
9 expect(user.createdAt).toBeInstanceOf(Date);
10});
11
12// BAD: two unrelated outcomes in one test
13test('user creation and deletion', () => {
14 const user = createUser({ name: 'Alice', email: '[email protected]' });
15 expect(user.id).toBeDefined(); // outcome 1: creation
16
17 deleteUser(user.id);
18 expect(findUser(user.id)).toBeNull(); // outcome 2: deletion
19 // These are independent behaviours — split them
20});A practical heuristic: if the test name requires 'and', it tests too much.
1// 'and' in name = split signal
2test('creates user and sends welcome email', () => { ... }); // SPLIT THIS
3
4// Split correctly:
5test('creates user with a generated UUID', () => { ... });
6test('sends a welcome email after user creation', () => { ... });Two conventions exist for placing test files. Co-location is strongly preferred for application code:
Co-location (recommended):
1src/
2├── modules/
3│ ├── auth/
4│ │ ├── auth.service.ts
5│ │ ├── auth.service.test.ts ← same directory
6│ │ ├── auth.controller.ts
7│ │ └── auth.controller.test.ts
8│ └── cart/
9│ ├── cart.utils.ts
10│ └── cart.utils.test.tsBenefits: when you delete auth.service.ts, you immediately see auth.service.test.ts in the same directory and know to delete it too. Refactoring renames both files. IDE navigation is immediate (open file → Cmd+P → same name + .test).
__tests__ directories (use for shared/global tests):
1src/
2├── __tests__/
3│ ├── integration/
4│ │ └── auth-flow.test.ts
5│ └── setup/
6│ └── jest.setup.ts
7├── modules/
8│ └── auth/
9│ ├── auth.service.ts
10│ └── auth.service.test.ts ← still co-locatedjest.config.ts to cover both:
1const config: Config = {
2 testMatch: [
3 '**/*.test.ts',
4 '**/*.spec.ts',
5 '**/__tests__/**/*.ts',
6 ],
7 testPathIgnorePatterns: [
8 '/node_modules/',
9 '/dist/',
10 '/__tests__/setup/', // setup files are not tests
11 ],
12};When multiple tests need similar objects with slight variations, duplicating object literals creates noise and coupling. Test factories solve this:
1// test-helpers/factories.ts
2import { User } from '../src/types';
3
4// Factory with sensible defaults — override only what matters per test
5export function buildUser(overrides: Partial<User> = {}): User {
6 return {
7 id: 'user-001',
8 name: 'Test User',
9 email: '[email protected]',
10 role: 'student',
11 active: true,
12 createdAt: new Date('2024-01-01'),
13 ...overrides,
14 };
15}
16
17// Usage: each test declares only what it cares about
18test('inactive users cannot enrol in courses', () => {
19 const user = buildUser({ active: false }); // only the relevant property
20 expect(() => enrolUser(user, 'course-1')).toThrow('Account is inactive');
21});
22
23test('admin users can enrol without payment', () => {
24 const admin = buildUser({ role: 'admin' });
25 const result = enrolUser(admin, 'course-1');
26 expect(result.requiresPayment).toBe(false);
27});
28
29test('users get a confirmation email on enrolment', () => {
30 const user = buildUser({ email: '[email protected]' });
31 const emailSpy = jest.spyOn(emailService, 'send');
32 enrolUser(user, 'course-1');
33 expect(emailSpy).toHaveBeenCalledWith(
34 expect.objectContaining({ to: '[email protected]' })
35 );
36});The factory pattern keeps Arrange sections to one or two lines while making the test's intent crystal clear: only overridden properties are relevant to the scenario being tested.
A password validation module with a complete test suite demonstrating AAA structure, precise naming, test factories, and correct assertion granularity.
1// src/password-validator.ts
2export interface PasswordValidationResult {
3 valid: boolean;
4 strength: 'weak' | 'medium' | 'strong';
5 errors: string[];
6}
7
8export function validatePassword(password: string): PasswordValidationResult {
9 if (typeof password !== 'string') {
10 throw new TypeError('Password must be a string');
11 }
12
13 const errors: string[] = [];
14
15 if (password.length < 8) {
16 errors.push('Password must be at least 8 characters long');
17 }
18 if (!/[A-Z]/.test(password)) {
19 errors.push('Password must contain at least one uppercase letter');
20 }
21 if (!/[0-9]/.test(password)) {
22 errors.push('Password must contain at least one number');
23 }
24 if (!/[!@#$%^&*]/.test(password)) {
25 errors.push('Password must contain at least one special character (!@#$%^&*)');
26 }
27
28 const valid = errors.length === 0;
29 let strength: 'weak' | 'medium' | 'strong' = 'weak';
30 if (errors.length === 0) strength = 'strong';
31 else if (errors.length === 1) strength = 'medium';
32
33 return { valid, strength, errors };
34}
35
36// src/password-validator.test.ts
37import { validatePassword } from './password-validator';
38
39// Factory: builds a password meeting all requirements by default
40function buildValidPassword(overrides: { length?: number; noUppercase?: boolean; noNumber?: boolean; noSpecial?: boolean } = {}): string {
41 let pwd = 'Secure1!';
42 if (overrides.length) pwd = 'A1!'.padEnd(overrides.length, 'a');
43 if (overrides.noUppercase) pwd = pwd.toLowerCase();
44 if (overrides.noNumber) pwd = pwd.replace(/[0-9]/g, 'x');
45 if (overrides.noSpecial) pwd = pwd.replace(/[!@#$%^&*]/g, 'x');
46 return pwd;
47}
48
49describe('validatePassword', () => {
50 describe('given a password that meets all requirements', () => {
51 it('should return valid: true with no errors', () => {
52 // Arrange
53 const password = 'Secure1!';
54
55 // Act
56 const result = validatePassword(password);
57
58 // Assert
59 expect(result.valid).toBe(true);
60 expect(result.errors).toHaveLength(0);
61 });
62
63 it('should return strength: strong', () => {
64 const result = validatePassword('Secure1!');
65 expect(result.strength).toBe('strong');
66 });
67 });
68
69 describe('given a password shorter than 8 characters', () => {
70 it('should return valid: false', () => {
71 const result = validatePassword('Ab1!');
72 expect(result.valid).toBe(false);
73 });
74
75 it('should include the length error message', () => {
76 const result = validatePassword('Ab1!');
77 expect(result.errors).toContain('Password must be at least 8 characters long');
78 });
79 });
80
81 describe('given a password missing only one requirement', () => {
82 it('should return strength: medium when only uppercase is missing', () => {
83 const result = validatePassword('secure1!');
84 expect(result.strength).toBe('medium');
85 expect(result.errors).toHaveLength(1);
86 });
87
88 it('should return strength: medium when only a number is missing', () => {
89 const result = validatePassword('SecurePass!');
90 expect(result.strength).toBe('medium');
91 });
92 });
93
94 describe('given a non-string input', () => {
95 it('should throw TypeError with a descriptive message', () => {
96 // Arrange
97 const badInput = 12345 as unknown as string;
98
99 // Act + Assert (combined for throw assertions)
100 expect(() => validatePassword(badInput)).toThrow(TypeError);
101 expect(() => validatePassword(badInput)).toThrow('Password must be a string');
102 });
103 });
104
105 describe('given an empty string', () => {
106 it('should return all four error messages', () => {
107 const result = validatePassword('');
108 expect(result.errors).toHaveLength(4);
109 expect(result.strength).toBe('weak');
110 });
111 });
112});Each test follows strict AAA with a blank line separating phases. Test names use 'given/should' to describe precondition and expected outcome. Each test has one reason to fail: strength and errors are tested in separate its even though they come from the same function call. The factory function buildValidPassword lets each test override only the property relevant to its scenario, keeping Arrange sections minimal and intent clear.
The next lesson covers testing asynchronous code: Promises with .resolves/.rejects, async/await in test bodies, and how to prevent Jest from silently passing tests when async assertions are never awaited.
Your progress is saved in this browser, on this device only.