Master professional unit testing discipline: eliminate brittle tests, detect and fix test interdependence, avoid over-mocking, and maintain a test suite as a first-class engineering asset.
A test suite is a liability as much as an asset. Tests that are hard to understand, coupled to implementation details, or interdependent create friction that slows development and erodes trust in the test suite itself. When developers start marking tests as skipped or writing code without tests because the suite is painful to work with, the investment has been lost.
This lesson consolidates the best practices from the entire module into a set of actionable rules, explains the anti-patterns that most commonly appear in real codebases, and gives you the tools to audit and improve an existing test suite. The goal is a suite that developers actively want to run and maintain because it saves time, not one that wastes it.
Over-mocking replaces so many real collaborators with mocks that the test no longer verifies real behaviour — it only verifies that mocks were called.
1// OVER-MOCKED: every dependency is mocked including pure utilities
2test('creates a user', async () => {
3 const mockHash = jest.fn().mockReturnValue('hashed-pw');
4 const mockUuid = jest.fn().mockReturnValue('user-123');
5 const mockValidate = jest.fn().mockReturnValue(true);
6 const mockRepo = { save: jest.fn().mockResolvedValue({ id: 'user-123' }) };
7
8 const user = await createUser(
9 { email: '[email protected]', password: 'pass' },
10 { hash: mockHash, uuid: mockUuid, validate: mockValidate, repo: mockRepo }
11 );
12
13 expect(mockHash).toHaveBeenCalledWith('pass');
14 expect(mockUuid).toHaveBeenCalled();
15 expect(mockValidate).toHaveBeenCalled();
16 // This test passes even if createUser is completely wrong —
17 // it only verifies that mocks were invoked, not that the output is correct.
18});
19
20// BETTER: mock only external I/O (database); use real pure functions
21test('creates a user with a hashed password and generated id', async () => {
22 const mockRepo = { save: jest.fn().mockResolvedValue(undefined) };
23
24 const user = await createUser(
25 { email: '[email protected]', password: 'SecurePass1!' },
26 mockRepo
27 );
28
29 expect(user.id).toMatch(/^[0-9a-f-]{36}$/); // real UUID shape
30 expect(user.passwordHash).not.toBe('SecurePass1!'); // real hash happened
31 expect(user.email).toBe('[email protected]');
32 expect(mockRepo.save).toHaveBeenCalledWith(user);
33});Rule: mock at the I/O boundary (database, HTTP, filesystem, clock). Do not mock pure functions — test them directly.
Tests that assert on internal state, private methods, or call counts on pure functions break every time you refactor — even when the observable behaviour is unchanged.
1// BAD: tests internal state
2test('stores items in the _items array', () => {
3 const cart = new ShoppingCart();
4 cart.add({ id: '1', price: 10, qty: 1 });
5 // @ts-ignore accessing private field — red flag
6 expect(cart._items.length).toBe(1);
7});
8
9// GOOD: tests observable behaviour
10test('increases item count when an item is added', () => {
11 const cart = new ShoppingCart();
12 cart.add({ id: '1', price: 10, qty: 1 });
13 expect(cart.itemCount()).toBe(1);
14});
15
16// BAD: tests that a specific private method was called
17test('calls _applyVatRule when calculating total', () => {
18 const cart = new ShoppingCart();
19 const spy = jest.spyOn(cart as any, '_applyVatRule');
20 cart.add({ id: '1', price: 100, qty: 1 });
21 cart.total();
22 expect(spy).toHaveBeenCalled(); // implementation detail
23});
24
25// GOOD: tests the output, not the mechanism
26test('total includes 20% VAT for UK customers', () => {
27 const cart = new ShoppingCart({ country: 'UK' });
28 cart.add({ id: '1', price: 100, qty: 1 });
29 expect(cart.total()).toBe(120);
30});Litmus test: if you can change how the code works internally without changing what it does externally, and your test breaks, you are testing implementation details.
Tests that pass in isolation but fail when run in certain orders share mutable state. Jest's --randomize flag detects this.
1# Run tests in random order to find interdependence
2npx jest --randomize
3# If different runs produce different results, you have interdependent tests1// BAD: shared mutable state between tests
2const db: Record<string, User> = {}; // module-level — shared across tests!
3
4test('creates a user', () => {
5 db['1'] = { id: '1', name: 'Alice' };
6 expect(Object.keys(db)).toHaveLength(1);
7});
8
9test('starts with empty database', () => {
10 expect(Object.keys(db)).toHaveLength(0); // FAILS if previous test ran first
11});
12
13// GOOD: reset shared state in beforeEach
14let db: Record<string, User>;
15
16beforeEach(() => {
17 db = {}; // fresh for each test
18});
19
20test('creates a user', () => {
21 db['1'] = { id: '1', name: 'Alice' };
22 expect(Object.keys(db)).toHaveLength(1);
23});
24
25test('starts with empty database', () => {
26 expect(Object.keys(db)).toHaveLength(0); // always 0 — fresh db
27});
28
29// ALSO BAD: uncleaned module-level mocks
30jest.mock('./email');
31const sendEmail = require('./email').sendEmail as jest.Mock;
32
33test('A sends email', () => {
34 callFeature();
35 expect(sendEmail).toHaveBeenCalledTimes(1); // passes
36});
37
38test('B counts email calls', () => {
39 callFeature();
40 expect(sendEmail).toHaveBeenCalledTimes(1); // FAILS — accumulates from test A
41});
42
43// FIX: add to jest.config.ts:
44// clearMocks: true — clears call counts after each testA test that always passes regardless of what the code does provides false confidence.
1// BAD: no assertions — always passes
2test('processes the order', async () => {
3 await processOrder({ id: 'ORD-001', items: [] });
4 // Nothing asserted — this test can never fail
5});
6
7// BAD: assertion that can never fail
8test('returns something', () => {
9 const result = calculateTotal([10, 20]);
10 expect(result).toBeDefined(); // undefined is extremely unlikely — not useful
11 expect(typeof result).toBe('number'); // any number passes, including NaN and Infinity
12});
13
14// BAD: assertion that always passes due to wrong negation
15test('does not throw', () => {
16 expect(() => dangerousOperation()).not.toThrow();
17 // This passes even when dangerousOperation has a serious bug
18 // that does not happen to throw (e.g., returns wrong data silently)
19});
20
21// GOOD: specific, can-fail assertions
22test('returns the sum of prices in the order', async () => {
23 const result = await processOrder({
24 id: 'ORD-001',
25 items: [
26 { productId: 'p1', price: 10, qty: 2 },
27 { productId: 'p2', price: 25, qty: 1 },
28 ],
29 });
30 expect(result.subtotal).toBe(45);
31 expect(result.status).toBe('pending');
32});
33
34// IMPORTANT: for async tests, use expect.assertions() as a safety net
35test('calls the callback exactly once', async () => {
36 expect.assertions(1); // if the callback is never called, the test FAILS
37 await runWithCallback((result) => {
38 expect(result).toBe('success');
39 });
40});Technical practices alone do not produce good test suites — team habits do. These are the practices that separate teams with healthy test suites from teams where the suite becomes a burden.
1// 1. Tests must be reviewed like production code
2// In every PR review, check:
3// - Does this test have a clear name that reads as a spec?
4// - Does it follow AAA?
5// - Is it testing behaviour or implementation?
6// - Can it fail for exactly one reason?
7// - Does it clean up after itself?
8
9// 2. The test should be simpler than the code it tests
10// If your test has conditionals, loops, or complex setup,
11// it is probably testing too much at once — split it.
12
13// BAD: conditional logic in test
14test('processes items', () => {
15 const items = getTestItems();
16 for (const item of items) {
17 if (item.type === 'digital') {
18 expect(processItem(item).delivery).toBe('instant');
19 } else {
20 expect(processItem(item).delivery).toBe('shipping');
21 }
22 }
23});
24
25// GOOD: separate tests for each case
26test('digital items have instant delivery', () => {
27 expect(processItem({ type: 'digital' }).delivery).toBe('instant');
28});
29test('physical items have shipping delivery', () => {
30 expect(processItem({ type: 'physical' }).delivery).toBe('shipping');
31});
32
33// 3. Enforce coverage gates in CI
34// jest.config.ts:
35// coverageThreshold: { global: { branches: 80, functions: 85 } }
36// This makes coverage regressions impossible to merge.
37
38// 4. Naming convention checklist:
39// ✅ 'returns X when Y'
40// ✅ 'throws Z when A is invalid'
41// ✅ 'calls service.method with correct arguments'
42// ❌ 'works'
43// ❌ 'test1'
44// ❌ 'handles the case'
45
46// 5. Test file size discipline:
47// If a test file exceeds ~200 lines, the module is probably too large.
48// Extract modules, then the test files naturally split.A before/after transformation of a test suite exhibiting all five anti-patterns: over-mocking, implementation details, interdependence, vacuous assertions, and complex conditional test logic.
1// BEFORE: test suite with multiple anti-patterns
2// src/checkout.test.ts (BAD VERSION)
3
4let checkoutService: CheckoutService;
5let emailsSent: string[] = []; // shared mutable state — anti-pattern!
6
7beforeAll(() => {
8 checkoutService = new CheckoutService(); // shared instance — anti-pattern!
9});
10
11test('test1', async () => { // vague name — anti-pattern!
12 const order = await checkoutService.createOrder({ items: [], userId: 'u1' });
13 expect(order).toBeDefined(); // vacuous assertion — anti-pattern!
14});
15
16test('checkout flow', async () => { // tests multiple things — anti-pattern!
17 // Accesses private method — implementation detail!
18 const spy = jest.spyOn(checkoutService as any, '_validateInventory');
19
20 const order = await checkoutService.createOrder({
21 items: [{ productId: 'p1', qty: 2, price: 10 }],
22 userId: 'u1',
23 });
24 expect(spy).toHaveBeenCalled(); // implementation detail!
25
26 await checkoutService.confirmOrder(order.id);
27 emailsSent.push('confirmation'); // shared state mutation!
28});
29
30test('email was sent', () => {
31 // Depends on previous test having run first — interdependence!
32 expect(emailsSent.length).toBeGreaterThan(0);
33});
34
35// ============================================================
36// AFTER: refactored to best practices
37// src/checkout.test.ts (GOOD VERSION)
38
39function buildCheckoutService() {
40 const emailService = { send: jest.fn().mockResolvedValue(undefined) };
41 const inventoryService = {
42 check: jest.fn().mockResolvedValue({ available: true }),
43 };
44 const service = new CheckoutService({ emailService, inventoryService });
45 return { service, emailService, inventoryService };
46}
47
48function buildOrderItems(overrides = {}) {
49 return [{ productId: 'p1', qty: 1, price: 29.99, ...overrides }];
50}
51
52describe('CheckoutService', () => {
53 describe('createOrder', () => {
54 it('returns an order with a generated id and pending status', async () => {
55 const { service } = buildCheckoutService();
56
57 const order = await service.createOrder({
58 items: buildOrderItems(),
59 userId: 'user-001',
60 });
61
62 expect(order.id).toMatch(/^[0-9a-f-]{36}$/);
63 expect(order.status).toBe('pending');
64 expect(order.userId).toBe('user-001');
65 });
66
67 it('calculates subtotal as sum of price * qty for all items', async () => {
68 const { service } = buildCheckoutService();
69
70 const order = await service.createOrder({
71 items: [
72 { productId: 'p1', qty: 2, price: 10 },
73 { productId: 'p2', qty: 1, price: 25 },
74 ],
75 userId: 'user-001',
76 });
77
78 expect(order.subtotal).toBe(45);
79 });
80
81 it('throws when inventory is unavailable', async () => {
82 const { service, inventoryService } = buildCheckoutService();
83 inventoryService.check.mockResolvedValueOnce({ available: false });
84
85 await expect(
86 service.createOrder({ items: buildOrderItems(), userId: 'user-001' })
87 ).rejects.toThrow('Item p1 is out of stock');
88 });
89 });
90
91 describe('confirmOrder', () => {
92 it('sends a confirmation email to the user after order is confirmed', async () => {
93 const { service, emailService } = buildCheckoutService();
94 const order = await service.createOrder({
95 items: buildOrderItems(),
96 userId: 'user-001',
97 });
98
99 await service.confirmOrder(order.id);
100
101 expect(emailService.send).toHaveBeenCalledWith(
102 expect.objectContaining({
103 to: expect.stringContaining('user-001'),
104 subject: expect.stringContaining('confirmed'),
105 })
106 );
107 });
108
109 it('updates order status to confirmed', async () => {
110 const { service } = buildCheckoutService();
111 const order = await service.createOrder({
112 items: buildOrderItems(),
113 userId: 'user-001',
114 });
115
116 await service.confirmOrder(order.id);
117 const updated = await service.getOrder(order.id);
118
119 expect(updated.status).toBe('confirmed');
120 });
121 });
122});The refactored version eliminates every anti-pattern: buildCheckoutService() creates a fresh service instance per test (no shared state), factory functions produce test data with sensible defaults, each test has one specific assertion that can fail for exactly one reason, no private method spying (inventoryService is injected and controlled at the public interface), and no test depends on another test's side effects. Run jest --randomize on the refactored version — it passes in any order.
With unit testing fundamentals complete, the next module covers integration testing: testing Express and NestJS REST APIs end-to-end with Supertest, setting up in-memory databases, and verifying that modules work correctly when wired together.
Your progress is saved in this browser, on this device only.