Lesson 43 of 54

Testing and Test-Driven Development

Anatomy of a Clean Test

I once debugged a test failure for an hour. The test had 12 assertions. When it failed, the message said "expected false to be true." Which of the 12? I had to add console.log and re-run to find out.

A clean test fails in a way that tells you exactly what broke.

Arrange-Act-Assert

Every test has three phases. Name them explicitly.

Arrange — Set up the world. Create test data, mocks, dependencies.

Act — Do the thing. Call the function, trigger the behavior.

Assert — Check the outcome. Verify the result.

Typescript
it('applies discount when cart exceeds threshold', () => {
  // Arrange
  const cart = { total: 150, itemCount: 3 };
  const discountService = new DiscountService();

  // Act
  const result = discountService.calculateDiscount(cart);

  // Assert
  expect(result).toBe(15);
});

When the test fails, you know which phase to inspect. The structure is predictable. Any developer can read it.

When Arrange Gets Long

If your Arrange section is 20 lines, something's wrong. Either:

  • The function has too many dependencies (design smell)
  • You need a test helper or factory
Typescript
// Before: noisy arrange
it('creates order with correct totals', () => {
  const user = { id: 'u1', email: 'test@example.com', address: { ... } };
  const items = [
    { sku: 'A', price: 10, quantity: 2 },
    { sku: 'B', price: 25, quantity: 1 },
  ];
  const payment = { method: 'card', last4: '4242' };
  const shipping = { method: 'standard', region: 'US' };
  // ... 10 more lines

  const order = createOrder(user, items, payment, shipping);

  expect(order.total).toBe(45);
});

// After: extract a factory
it('creates order with correct totals', () => {
  const order = createTestOrder({ items: [
    { price: 10, quantity: 2 },
    { price: 25, quantity: 1 },
  ]});

  const result = createOrder(order.user, order.items, order.payment, order.shipping);

  expect(result.total).toBe(45);
});

One Assertion Per Test (Mostly)

One assertion per test means: one behavior per test. When the test fails, you know which behavior broke.

Typescript
// Hard to diagnose
it('validates user registration', () => {
  const result = validateUser({ email: 'bad', password: 'short' });
  expect(result.valid).toBe(false);
  expect(result.errors).toContain('Invalid email');
  expect(result.errors).toContain('Password too short');
});

// Better: one behavior each
it('rejects invalid email format', () => {
  const result = validateUser({ email: 'bad', password: 'ValidPass123!' });
  expect(result.valid).toBe(false);
  expect(result.errors).toContain('Invalid email');
});

it('rejects password shorter than 8 characters', () => {
  const result = validateUser({ email: 'good@example.com', password: 'short' });
  expect(result.valid).toBe(false);
  expect(result.errors).toContain('Password too short');
});

When to Break the Rule

Multiple assertions on the same logical outcome. If you're verifying one behavior that has multiple observable effects, multiple assertions are fine.

Typescript
it('returns user with hashed password and no plaintext', () => {
  const user = createUser({ email: 'a@b.com', password: 'secret123' });
  expect(user.passwordHash).toBeDefined();
  expect(user.passwordHash).not.toBe('secret123');
  expect(user.password).toBeUndefined();
});

These three assertions verify one thing: "password was hashed and not stored in plaintext."

Setup is expensive. If creating the test context takes 2 seconds, and you're asserting five properties of the same result, one test with five assertions may be pragmatic. Document why.

Test Isolation

Each test must be independent. Run in any order. Run alone. Same result.

The Shared Mutable State Trap

Typescript
// BAD: shared state
describe('ShoppingCart', () => {
  let cart: ShoppingCart;

  beforeEach(() => {
    cart = new ShoppingCart();
  });

  it('adds item', () => {
    cart.addItem({ id: '1', price: 10 });
    expect(cart.itemCount).toBe(1);
  });

  it('calculates total', () => {
    // cart might have state from previous test if order changes!
    cart.addItem({ id: '1', price: 10 });
    expect(cart.total).toBe(10);
  });
});

If tests run in parallel or order changes, cart might have leftover state. Each test should build its own world.

Avoid Test Interdependencies

Typescript
// BAD: test 2 depends on test 1
it('creates user', () => {
  user = await userService.create({ email: 'test@example.com' });
  expect(user.id).toBeDefined();
});

it('updates user', () => {
  user.name = 'Updated';
  await userService.update(user);  // Depends on previous test!
  expect(user.name).toBe('Updated');
});

// GOOD: each test is self-contained
it('creates user', async () => {
  const user = await userService.create({ email: 'test@example.com' });
  expect(user.id).toBeDefined();
});

it('updates user', async () => {
  const user = await userService.create({ email: 'update@example.com' });
  user.name = 'Updated';
  await userService.update(user);
  const updated = await userService.findById(user.id);
  expect(updated.name).toBe('Updated');
});

Setup and Teardown Best Practices

beforeEach vs beforeAll

beforeEach — Runs before every test. Use when each test needs a fresh instance. Default choice.

beforeAll — Runs once for the whole describe. Use only for expensive setup that never changes (e.g., starting a test database). Be careful: mutable state in beforeAll will leak between tests.

Clean Up After Yourself

If you create files, open connections, or modify global state, clean up in afterEach or afterAll.

Typescript
describe('FileProcessor', () => {
  const tempDir = path.join(os.tmpdir(), `test-${Date.now()}`);

  beforeAll(() => {
    fs.mkdirSync(tempDir, { recursive: true });
  });

  afterAll(() => {
    fs.rmSync(tempDir, { recursive: true });
  });

  it('processes file', () => {
    const filePath = path.join(tempDir, 'input.txt');
    fs.writeFileSync(filePath, 'content');
    const result = processFile(filePath);
    expect(result.lineCount).toBe(1);
  });
});

Avoid Setup That Hides What the Test Needs

Typescript
// BAD: setup obscures what this test cares about
beforeEach(() => {
  user = createUser({ email: 'a@b.com', role: 'admin', plan: 'enterprise' });
  order = createOrder({ total: 100, status: 'pending' });
  payment = createPayment({ method: 'card' });
});

it('allows admin to refund', () => {
  // Which of these mattered? Unclear.
  refundService.processRefund(user, order, payment);
  expect(order.status).toBe('refunded');
});

// GOOD: test shows its requirements
it('allows admin to refund', () => {
  const admin = createUser({ role: 'admin' });
  const order = createOrder({ status: 'pending' });
  refundService.processRefund(admin, order, createPayment());
  expect(order.status).toBe('refunded');
});

When the setup is in the test, the test documents what it needs.


Key insight: A clean test has clear Arrange-Act-Assert structure, one logical behavior per test, and complete isolation. Extract factories when Arrange gets long. Use multiple assertions only when they verify one outcome. Never let tests depend on each other.