A unit test exercises one piece of code in isolation. It calls a function, asserts the result, and tells you in milliseconds whether the behavior is right. They form the wide base of the test pyramid: thousands run in seconds, fail with pinpoint accuracy, and let you refactor without fear. Done well, they're the cheapest insurance in software.
← Back to TestingThree sections, in order. Set up inputs and dependencies; call the unit under test once; assert on the result. Mixing them — branching logic, multiple acts, scattered assertions — turns the test into a riddle when it fails.
test('discount of 10% on $100 returns $90', () => {
// Arrange
const cart = new Cart([{ price: 100 }]);
// Act
const total = cart.totalWithDiscount(0.10);
// Assert
expect(total).toBe(90);
});
Assert on what the code does, not how. Tests that verify "the method called X then called Y" break on every refactor; tests that verify "given input A, return B" survive any internal rewrite that preserves the contract.
You can have multiple expect calls, but they should describe one behavior. A failure should make it obvious what broke. Tests that assert ten unrelated things fail uninformatively — and the cleanup after a failure mid-test is awful.
shouldRejectExpiredTokens() > testToken(). Patterns: "given X, when Y, then Z" / "X returns Y when Z" / Ruby's describe ... it. The name is what shows up in CI failure logs; make it tell you what's broken.
email.send called once with these args?"Mock-heavy tests verify implementation details — they couple to call sequences and method names. Fakes (an in-memory repo, an in-memory clock, a fake email sender that stores sent messages) let tests assert on outcomes. They're more work to write once and pay back across hundreds of tests.
Mocking a third-party library directly couples your test to that library's exact shape. Mock at your own abstraction — the port / interface you wrote — and let the adapter to the third-party library be tested separately, perhaps with a real instance.
Write a failing test (red). Write the smallest code that passes (green). Improve the design without changing behavior (refactor). Loop in minutes, not days. Forces you to think about the API before the implementation, and gives you a regression test for every behavior by construction.
Logic-heavy code with clear inputs and outputs — pricing rules, parsers, business calculations, algorithms. The cycle is short and the tests stay relevant.
Spike work where you don't yet know the design. UI exploration. Code that's mostly orchestration of slow external services. TDD isn't a cult — use it where it earns its keep, skip it where it doesn't.
A mature unit-test suite runs in seconds, asserts on behavior, requires no setup beyond importing, and gives you confidence to delete or rewrite any module knowing the suite will catch regressions. New code has tests as a non-optional part of the PR. Tests that get hard to write are signals about the design itself — usually a missing abstraction or excessive coupling.
Pair unit tests with the rest of the pyramid: integration for component interactions, E2E for full-system flows. Unit tests can't catch wiring mistakes; the higher tiers can't run a thousand iterations of edge cases. Each tier earns its keep.