Clean Code

Meaningful Test Concepts: Best Practices for Writing Tests in TypeScript

A test failed. Its name: test1. Its body: 47 lines with 12 assertions covering three different features. I couldn't tell which behavior was broken without...

19 Apr 2024

Meaningful Test Concepts: Best Practices for Writing Tests in TypeScript

A test failed. Its name: test1. Its body: 47 lines with 12 assertions covering three different features. I couldn't tell which behavior was broken without stepping through the entire thing line by line.

That test was worse than no test. It gave me a false sense of coverage while hiding the actual problem behind noise.

What makes a test meaningful

A meaningful test has a clear concept. It tests one behavior. Its name tells you what broke when it fails. You read the failure message and know exactly what to fix.

One test, one concept

Just as functions follow the Single Responsibility Principle, tests should too. Each test verifies a single behavior or scenario.

Typescript
describe('Calculator', () => {
  it('should add two numbers correctly', () => {
    const calculator = new Calculator();
    const result = calculator.add(2, 3);
    expect(result).toBe(5);
  });
});

One setup. One action. One assertion. When this test fails, there's no ambiguity about what's broken.

Don't combine "it should add correctly AND handle negative numbers AND return zero for empty input" into one test. That's three tests wearing a trench coat.

Name tests like bug reports

The test name should describe the behavior, not the implementation. When it fails in CI at 2am, the name is the first thing someone reads.

Typescript
describe('Calculator', () => {
  it('should correctly add two positive numbers', () => {});
  it('should return zero when adding zero to any number', () => {});
  it('should handle negative numbers in addition', () => {});
});

Compare that to it('should work') or it('test case 3'). Useless when it fails.

Test edge cases explicitly

The happy path is the easy part. The bugs live in the edges: empty inputs, null values, boundary numbers, unexpected types.

Typescript
describe('ArrayUtils', () => {
  it('should return undefined for an empty array', () => {
    const result = ArrayUtils.getLastElement([]);
    expect(result).toBeUndefined();
  });

  it('should return the last element of a non-empty array', () => {
    const result = ArrayUtils.getLastElement([1, 2, 3, 4, 5]);
    expect(result).toBe(5);
  });
});

Each edge case gets its own test with its own descriptive name.

Follow Arrange-Act-Assert

Structure every test with three clear sections:

Typescript
describe('UserService', () => {
  it('should retrieve a user by ID from the database', () => {
    // Arrange — set up the world
    const userId = '123';
    const databaseMock = createDatabaseMock();
    databaseMock.seed({ id: userId, name: 'Alice' });

    // Act — do the thing
    const user = UserService.getUserById(userId, databaseMock);

    // Assert — check the result
    expect(user.id).toBe(userId);
    expect(user.name).toBe('Alice');
  });
});

AAA makes tests scannable. You can see the setup, the action, and the expectation at a glance. When a test fails, you know immediately whether the problem is in the setup, the code, or the assertion.

The trade-off

Meaningful tests take more time to write. One test per concept means more test functions, more names to invent, more setup code.

But vague, multi-concept tests cost more in the long run. They fail cryptically. They pass when they shouldn't. They make refactoring scary because you don't know what they're actually protecting.

Write tests that tell you exactly what broke and why. Your future self debugging at midnight will be grateful.