Many codebases have thousands of tests that provide little value — they test implementation details, break on every refactor, and never catch real bugs. Here’s how to write tests that actually earn their keep.

Test Behavior, Not Implementation

The most important rule: test what the code does, not how it does it.

// ❌ Testing implementation — breaks if you refactor internals
test('uses Map to store users', () => {
  const service = new UserService();
  service.addUser({ name: 'Alice' });
  
  // Testing internal data structure!
  expect((service as any).userMap.size).toBe(1);
  expect((service as any).userMap.get('alice')).toBeDefined();
});

// ✅ Testing behavior — survives any internal refactor
test('added user can be retrieved by name', () => {
  const service = new UserService();
  service.addUser({ name: 'Alice', email: '[email protected]' });
  
  const user = service.findByName('Alice');
  expect(user).toEqual({ name: 'Alice', email: '[email protected]' });
});

The AAA Pattern

Every test should follow Arrange, Act, Assert:

test('applies discount for orders over $100', () => {
  // Arrange — set up the scenario
  const order = createOrder([
    { name: 'Widget', price: 75 },
    { name: 'Gadget', price: 50 },
  ]);
  const pricing = new PricingService();

  // Act — perform the action
  const total = pricing.calculateTotal(order);

  // Assert — verify the result
  expect(total).toBe(112.50); // 125 - 10% discount
});

Test Names as Documentation

Test names should describe the scenario and expected outcome:

// ❌ Vague names
test('calculateTotal works', () => { });
test('test user creation', () => { });
test('should work correctly', () => { });

// ✅ Descriptive names — read them like sentences
test('applies 10% discount when order total exceeds $100', () => { });
test('throws ValidationError when email format is invalid', () => { });
test('returns empty array when user has no orders', () => { });
test('preserves item order when sorting by date', () => { });

Test Edge Cases, Not Just Happy Paths

describe('divide', () => {
  // Happy path
  test('divides two positive numbers', () => {
    expect(divide(10, 2)).toBe(5);
  });

  // Edge cases — this is where bugs hide
  test('throws when dividing by zero', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });

  test('handles negative numbers', () => {
    expect(divide(-10, 2)).toBe(-5);
  });

  test('returns zero when numerator is zero', () => {
    expect(divide(0, 5)).toBe(0);
  });

  test('handles decimal results', () => {
    expect(divide(1, 3)).toBeCloseTo(0.333, 3);
  });
});

One Assertion Per Concept

// ❌ Multiple unrelated assertions — when it fails, which part broke?
test('user creation', () => {
  const user = createUser('Alice', '[email protected]');
  expect(user.name).toBe('Alice');
  expect(user.email).toBe('[email protected]');
  expect(user.id).toBeDefined();
  expect(user.createdAt).toBeInstanceOf(Date);
  expect(user.role).toBe('user');
  expect(user.isActive).toBe(true);
});

// ✅ Focused tests — each failure tells you exactly what broke
test('creates user with provided name and email', () => {
  const user = createUser('Alice', '[email protected]');
  expect(user.name).toBe('Alice');
  expect(user.email).toBe('[email protected]');
});

test('assigns default role and active status to new users', () => {
  const user = createUser('Alice', '[email protected]');
  expect(user.role).toBe('user');
  expect(user.isActive).toBe(true);
});

test('generates unique ID and timestamp for new users', () => {
  const user = createUser('Alice', '[email protected]');
  expect(user.id).toMatch(/^[a-f0-9-]{36}$/);
  expect(user.createdAt).toBeInstanceOf(Date);
});

Avoid Test Interdependence

Each test must work in isolation:

// ❌ Tests depend on each other and share state
let sharedUser: User;

test('creates user', () => {
  sharedUser = createUser('Alice', '[email protected]');
  expect(sharedUser).toBeDefined();
});

test('updates user name', () => {
  // Fails if first test fails!
  sharedUser.name = 'Bob';
  expect(sharedUser.name).toBe('Bob');
});

// ✅ Each test is self-contained
test('creates user with valid data', () => {
  const user = createUser('Alice', '[email protected]');
  expect(user).toBeDefined();
});

test('updates user name', () => {
  const user = createUser('Alice', '[email protected]');
  user.name = 'Bob';
  expect(user.name).toBe('Bob');
});

The Test Pyramid

  • Many unit tests: Fast, focused, test individual functions
  • Some integration tests: Test how components work together
  • Few E2E tests: Test critical user journeys end-to-end

The pyramid shape means most bugs should be caught by unit tests, which are cheap and fast.

“A test that never fails adds no value. A test that always fails adds negative value. The goal is tests that fail when — and only when — something is actually broken.” — Kent Beck