Unit Testing Deep Dive

Master unit testing with Jest: mocking, spies, test organization, and best practices

What Makes a Good Unit Test?

A unit test focuses on testing a single "unit" of code in isolation. This could be a function, a method, or a component. The key is isolating the code under test from its dependencies.

✅ Good Unit Test Characteristics

  • • Fast (milliseconds)
  • • Isolated (no external dependencies)
  • • Repeatable (same result every time)
  • • Self-validating (clear pass/fail)
  • • Timely (written with or before code)

🎯 FIRST Principles

  • Fast - Run quickly
  • Isolated - Independent of others
  • Repeatable - Consistent results
  • Self-validating - No manual checking
  • Timely - Written at the right time

Jest Basics

// math.js - Simple functions to test
export function add(a, b) {
  return a + b;
}

export function divide(a, b) {
  if (b === 0) throw new Error('Division by zero');
  return a / b;
}

export function isEven(num) {
  return num % 2 === 0;
}

export function factorial(n) {
  if (n < 0) throw new Error('Negative number');
  if (n === 0 || n === 1) return 1;
  return n * factorial(n - 1);
}
// math.test.js - Basic tests
import { add, divide, isEven, factorial } from './math';

describe('Math utilities', () => {
  describe('add', () => {
    test('adds positive numbers', () => {
      expect(add(2, 3)).toBe(5);
    });

    test('adds negative numbers', () => {
      expect(add(-2, -3)).toBe(-5);
    });

    test('adds mixed numbers', () => {
      expect(add(-2, 3)).toBe(1);
    });
  });

  describe('divide', () => {
    test('divides numbers correctly', () => {
      expect(divide(10, 2)).toBe(5);
    });

    test('throws error on division by zero', () => {
      expect(() => divide(10, 0)).toThrow('Division by zero');
    });
  });

  describe('isEven', () => {
    test.each([
      [2, true],
      [3, false],
      [0, true],
      [-2, true],
      [-3, false],
    ])('isEven(%i) returns %s', (num, expected) => {
      expect(isEven(num)).toBe(expected);
    });
  });

  describe('factorial', () => {
    test('calculates factorial correctly', () => {
      expect(factorial(0)).toBe(1);
      expect(factorial(1)).toBe(1);
      expect(factorial(5)).toBe(120);
    });

    test('throws error for negative numbers', () => {
      expect(() => factorial(-1)).toThrow('Negative number');
    });
  });
});

Jest Matchers Reference

Common Matchers

// Equality
expect(value).toBe(5);                // Strict equality (===)
expect(value).toEqual({ a: 1 });       // Deep equality
expect(value).not.toBe(5);             // Negation

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeLessThan(5);
expect(value).toBeCloseTo(0.3);        // Floating point

// Strings
expect(str).toMatch(/pattern/);
expect(str).toContain('substring');

// Arrays
expect(arr).toContain(item);
expect(arr).toHaveLength(3);

// Objects
expect(obj).toHaveProperty('key');
expect(obj).toMatchObject({ a: 1 });

// Functions
expect(fn).toThrow();
expect(fn).toThrow('Error message');
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith(arg1, arg2);

Async Matchers

// Promises
test('async test', async () => {
  await expect(promise).resolves.toBe(value);
  await expect(promise).rejects.toThrow();
});

// Alternative syntax
test('async test', () => {
  return expect(promise).resolves.toBe(value);
});

// Using async/await
test('async test', async () => {
  const result = await asyncFunction();
  expect(result).toBe(expected);
});

// Testing callbacks
test('callback test', (done) => {
  function callback(data) {
    expect(data).toBe('result');
    done();
  }
  
  asyncFunction(callback);
});

Mocking with Jest

Mocks replace dependencies with controlled implementations for isolated testing.

Mock Functions

// Creating mock functions
const mockFn = jest.fn();

// Mock with return value
const mockFn = jest.fn(() => 'return value');
const mockFn = jest.fn().mockReturnValue('value');

// Mock with different return values
const mockFn = jest.fn()
  .mockReturnValueOnce('first')
  .mockReturnValueOnce('second')
  .mockReturnValue('default');

console.log(mockFn()); // 'first'
console.log(mockFn()); // 'second'
console.log(mockFn()); // 'default'
console.log(mockFn()); // 'default'

// Mock async functions
const mockAsync = jest.fn().mockResolvedValue('success');
const mockAsyncError = jest.fn().mockRejectedValue(new Error('failed'));

// Checking mock calls
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(4);
expect(mockFn).toHaveBeenCalledWith(arg1, arg2);
expect(mockFn).toHaveBeenLastCalledWith(arg);

// Accessing call data
console.log(mockFn.mock.calls);        // All calls: [[args1], [args2]]
console.log(mockFn.mock.results);      // All results
console.log(mockFn.mock.instances);    // All instances (for constructors)

Mocking Modules

// api.js - Module to mock
export async function fetchUser(id) {
  const response = await fetch('/api/users/' + id);
  return response.json();
}

export async function createUser(data) {
  const response = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(data),
  });
  return response.json();
}
// userService.js - Code that uses the API
import { fetchUser, createUser } from './api';

export async function getUserName(id) {
  const user = await fetchUser(id);
  return user.name;
}

export async function registerUser(name, email) {
  const user = await createUser({ name, email });
  return user.id;
}
// userService.test.js - Test with mocked API
import { getUserName, registerUser } from './userService';
import * as api from './api';

// Mock the entire module
jest.mock('./api');

describe('User Service', () => {
  beforeEach(() => {
    // Clear all mocks before each test
    jest.clearAllMocks();
  });

  test('getUserName returns user name', async () => {
    // Setup mock return value
    api.fetchUser.mockResolvedValue({
      id: 1,
      name: 'Alice',
      email: 'alice@example.com',
    });

    const name = await getUserName(1);

    expect(name).toBe('Alice');
    expect(api.fetchUser).toHaveBeenCalledWith(1);
    expect(api.fetchUser).toHaveBeenCalledTimes(1);
  });

  test('registerUser returns new user id', async () => {
    api.createUser.mockResolvedValue({
      id: 123,
      name: 'Bob',
      email: 'bob@example.com',
    });

    const id = await registerUser('Bob', 'bob@example.com');

    expect(id).toBe(123);
    expect(api.createUser).toHaveBeenCalledWith({
      name: 'Bob',
      email: 'bob@example.com',
    });
  });

  test('handles API errors', async () => {
    api.fetchUser.mockRejectedValue(new Error('Network error'));

    await expect(getUserName(1)).rejects.toThrow('Network error');
  });
});

Spies and Partial Mocks

// Spy on a method without changing implementation
const obj = {
  method: () => 'original',
};

const spy = jest.spyOn(obj, 'method');

console.log(obj.method()); // 'original' - still works
expect(spy).toHaveBeenCalled();

// Spy and change implementation
const spy = jest.spyOn(obj, 'method').mockReturnValue('mocked');
console.log(obj.method()); // 'mocked'

// Restore original
spy.mockRestore();
console.log(obj.method()); // 'original'

// Partial module mock
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'), // Keep other exports
  fetchData: jest.fn(),              // Mock only this one
}));

Testing Async Code

// Example async functions
async function fetchData() {
  const response = await fetch('/api/data');
  if (!response.ok) throw new Error('Failed to fetch');
  return response.json();
}

function fetchDataCallback(callback) {
  setTimeout(() => {
    callback({ data: 'result' });
  }, 100);
}

// Testing with async/await
test('fetches data successfully', async () => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ data: 'test' }),
  });

  const data = await fetchData();
  expect(data).toEqual({ data: 'test' });
});

// Testing error cases
test('throws error on failed fetch', async () => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: false,
  });

  await expect(fetchData()).rejects.toThrow('Failed to fetch');
});

// Testing with callbacks
test('callback receives data', (done) => {
  function callback(data) {
    try {
      expect(data).toEqual({ data: 'result' });
      done();
    } catch (error) {
      done(error);
    }
  }

  fetchDataCallback(callback);
});

// Testing with promises
test('returns a promise', () => {
  return expect(fetchData()).resolves.toEqual({ data: 'test' });
});

Setup and Teardown

describe('Database tests', () => {
  let db;

  // Run once before all tests in this describe block
  beforeAll(async () => {
    db = await connectDatabase();
  });

  // Run once after all tests in this describe block
  afterAll(async () => {
    await db.disconnect();
  });

  // Run before each test
  beforeEach(async () => {
    await db.clear();
    await db.seed();
  });

  // Run after each test
  afterEach(async () => {
    await db.clearLogs();
  });

  test('can insert user', async () => {
    const user = { name: 'Alice' };
    await db.users.insert(user);
    
    const found = await db.users.findOne({ name: 'Alice' });
    expect(found.name).toBe('Alice');
  });

  test('can delete user', async () => {
    await db.users.insert({ name: 'Bob' });
    await db.users.delete({ name: 'Bob' });
    
    const found = await db.users.findOne({ name: 'Bob' });
    expect(found).toBeNull();
  });
});

Test Organization Best Practices

📁 File Structure

src/
  components/
    Button/
      Button.jsx
      Button.test.jsx
      Button.styles.css
  utils/
    math.js
    math.test.js
  __tests__/
    integration/
      userFlow.test.js

Keep tests close to the code they test, or in a dedicated __tests__ folder.

✍️ Test Naming

// ❌ Bad: Unclear what's being tested
test('it works', () => { ... });
test('test1', () => { ... });

// ✅ Good: Describes behavior clearly
test('returns user name when user exists', () => { ... });
test('throws error when user not found', () => { ... });
test('calculates total with discount applied', () => { ... });

// ✅ Good: Using describe blocks for organization
describe('ShoppingCart', () => {
  describe('addItem', () => {
    test('adds item to empty cart', () => { ... });
    test('increments quantity when item already exists', () => { ... });
    test('throws error when item is invalid', () => { ... });
  });

  describe('removeItem', () => {
    test('removes item completely', () => { ... });
    test('decrements quantity when count > 1', () => { ... });
  });
});

Code Coverage

// Run tests with coverage
npm test -- --coverage

// Output example:
// -------------------|---------|----------|---------|---------|
// File               | % Stmts | % Branch | % Funcs | % Lines |
// -------------------|---------|----------|---------|---------|
// All files          |   85.5  |   78.2   |   90.1  |   84.8  |
//  math.js           |   100   |   100    |   100   |   100   |
//  userService.js    |   80.5  |   66.7   |   85.0  |   79.2  |
// -------------------|---------|----------|---------|---------|

⚠️ Coverage Isn't Everything

  • • 100% coverage doesn't mean bug-free code
  • • Focus on testing important behavior, not hitting a number
  • • Aim for 70-80% coverage as a reasonable target
  • • Critical paths should have high coverage

💡 Unit Testing Best Practices

  • ✓ Test one thing per test
  • ✓ Use descriptive test names that explain the behavior
  • ✓ Arrange-Act-Assert pattern for clarity
  • ✓ Mock external dependencies to keep tests fast and isolated
  • ✓ Test edge cases and error conditions
  • ✓ Keep tests simple and readable
  • ✓ Don't test implementation details, test behavior
  • ✓ Run tests frequently during development

📚 More Testing Topics

Explore all 6 testing topics to build a comprehensive understanding of software testing.

View All Topics