Unit Testing

Expected level: Proficient

A unit test checks one small piece of behaviour, such as a function or a class, in isolation from databases, networks and the file system. It runs in milliseconds and fails for one clear reason.

Anatomy of a test

Every mainstream language has a test framework, and the structure is nearly the same everywhere: Arrange, Act, Assert.

test "discount is not applied below the minimum order":
    order = new Order(total = 40)          # arrange
    result = applyDiscount(order)          # act
    assert result.total == 40              # assert

Run the tests with whatever your project uses, for example npm test, pytest, dotnet test, mvn test or go test ./.... The command is the same in PowerShell and Bash.

What makes a good unit test

  • Test one behaviour per test, with a name that states it. A failure should be understandable from the name alone.
  • Keep it fast and isolated: no real network, clock, database or state shared between tests.
  • Keep it deterministic: no dependence on the current time, random values or test order.
  • Assert on outputs and visible effects. Do not assert on which private methods were called.
  • Cover the edges: empty input, null, zero, boundaries, very large values, invalid input and error paths, as well as the happy path. See Error Handling.

Isolating dependencies

When code calls something slow or unpredictable, replace it in the test with a test double. A stub returns canned answers, a fake is a simple working substitute (such as an in-memory repository) and a mock also verifies that it was called in a certain way. Passing dependencies in as parameters, called dependency injection, makes this easy. Overusing mocks leads to tests that pass while the real system is broken, which is what integration tests are for.

Habits for working with unit tests

  • Write a failing test that reproduces a bug before you fix it. It then stays as a regression test.
  • Test-driven development (write the test first, then the code, then refactor) is a useful technique, but you can write good tests without it.
  • Use code coverage to spot untested areas, and do not make it a goal.
  • Keep tests green and run them before every push. A skipped or flaky test erodes trust. See Test Automation Principles.
  • Hard-to-test code usually signals too much coupling. See Modularity, Coupling and Cohesion.

Try it

Take a small pure function, for example one that validates a postal code or calculates a price. In your language’s test framework, write four tests: a normal case, an empty or null input, a boundary value and an invalid input. Run them, then deliberately break the function and confirm the right test fails with a readable message.

Common mistakes

  • Overusing mocks, so tests pass while the real system is broken.
  • Testing only the happy path and skipping edges and error paths.
  • Making coverage a goal instead of a tool for spotting untested areas.
  • Leaving skipped or flaky tests in the suite.

Check yourself

  • What are the three steps of the Arrange, Act, Assert structure?
  • How do a stub, a fake and a mock differ?
  • Why write a failing test before fixing a bug?

Related topics

Terms

Further reading