Lesson 44 of 54

Testing and Test-Driven Development

Test Naming and Organization

I was pair programming with a junior dev. We hit a failing test. The name was test1. We had to read the whole test body to understand what it was supposed to do.

Test names are documentation. When they fail, they're the first thing you see. Make them count.

Descriptive Names That Document Behavior

A test name should complete the sentence: "It ______."

  • it('returns 404 when user does not exist')
  • it('throws when password is empty')
  • it('applies discount for orders over 100')
  • it('test1')
  • it('works')
  • it('handles the edge case') ✗ — Which edge case?

The name should tell you what behavior is being verified. No need to read the body for the happy path.

Given-When-Then Naming

For complex scenarios, use Given-When-Then structure in the name:

Given [initial state], When [action], Then [expected outcome].

Typescript
it('given expired session, when user requests data, then returns 401', () => {
  const session = createSession({ expiresAt: yesterday() });
  const response = api.getData(session);
  expect(response.status).toBe(401);
});

it('given cart with taxable items, when calculating total, then includes tax', () => {
  const cart = createCart({ region: 'CA', items: taxableItems });
  const total = calculateTotal(cart);
  expect(total.tax).toBeGreaterThan(0);
});

This format scales well for integration tests where context matters.

Organizing Tests by Feature and Behavior

Don't organize by method. Organize by behavior.

Typescript
// BAD: mirroring implementation
describe('OrderService', () => {
  describe('calculateTotal', () => {
    it('test 1', () => {});
    it('test 2', () => {});
  });
  describe('applyDiscount', () => {
    it('test 1', () => {});
  });
});

// GOOD: grouping by behavior
describe('OrderService', () => {
  describe('calculateTotal', () => {
    it('sums item prices', () => {});
    it('includes tax when in taxable region', () => {});
    it('excludes tax for exempt customers', () => {});
  });

  describe('discount application', () => {
    it('applies 10% when total exceeds 100', () => {});
    it('applies 5% bonus for 5+ items', () => {});
    it('does not stack with promotional discounts', () => {});
  });
});

When you read the describe blocks, you see a specification. "calculateTotal sums item prices, includes tax when in taxable region, excludes tax for exempt customers."

Test File Structure

One File Per Source File (Convention)

OrderService.tsOrderService.test.ts or OrderService.spec.ts

This keeps tests close to the code. When you change OrderService, the test file is right there.

When to Split

Large modules. If OrderService has 800 lines and 50 tests, consider splitting:

  • OrderService.calculation.test.ts
  • OrderService.discounts.test.ts
  • OrderService.validation.test.ts

Integration tests. Keep them separate from unit tests. OrderService.integration.test.ts or a dedicated __tests__/integration/ folder.

File Layout

Typescript
// OrderService.test.ts

import { OrderService } from './OrderService';

describe('OrderService', () => {
  let service: OrderService;

  beforeEach(() => {
    service = new OrderService(/* deps */);
  });

  describe('calculateTotal', () => {
    // tests
  });

  describe('applyDiscount', () => {
    // tests
  });
});

Top-level describe matches the module. Nested describes group behaviors. beforeEach at the appropriate level.

Using describe Blocks Effectively

Nest by Behavior, Not by Method

Typescript
describe('UserService', () => {
  describe('when user exists', () => {
    it('returns user data', () => {});
    it('includes last login timestamp', () => {});
  });

  describe('when user does not exist', () => {
    it('returns null', () => {});
    it('does not throw', () => {});
  });

  describe('when database is unavailable', () => {
    it('throws ServiceUnavailableError', () => {});
  });
});

The structure tells a story. Different scenarios, different outcomes.

Avoid Deep Nesting

More than three levels of describe gets hard to follow.

Typescript
// Too deep
describe('OrderService', () => {
  describe('calculateTotal', () => {
    describe('when cart has items', () => {
      describe('when in taxable region', () => {
        describe('when customer is not exempt', () => {
          it('includes tax', () => {});
        });
      });
    });
  });
});

// Flatter: put context in the test name
describe('OrderService', () => {
  describe('calculateTotal', () => {
    it('includes tax when in taxable region and customer is not exempt', () => {});
  });
});

describe for Setup, it for Behavior

Use describe to group tests that share setup. Use it for the actual behavior.

Typescript
describe('PaymentProcessor', () => {
  describe('with valid card', () => {
    const validCard = { number: '4242424242424242', expiry: '12/25' };

    it('charges the correct amount', () => {});
    it('returns transaction id', () => {});
  });

  describe('with expired card', () => {
    const expiredCard = { number: '4242424242424242', expiry: '01/20' };

    it('throws CardExpiredError', () => {});
  });
});

Naming Conventions Across the Suite

Be consistent. Pick a style and stick to it.

StyleExample
Behavior-focusedit('returns 404 when user does not exist')
Given-When-Thenit('given invalid input, when validate, then returns errors')
Should-styleit('should return 404 when user does not exist')

I prefer behavior-focused without "should"—it's redundant. "It returns 404" is clearer than "It should return 404." But if your team uses "should," stay consistent.


Key insight: Test names are executable documentation. Use descriptive names that complete "It ______." Organize by behavior, not by method. Use describe blocks to group scenarios. Keep nesting shallow. Consistency across the suite matters more than any single convention.