An effective unit test exercises one meaningful behavior through a small public boundary, controls slow or nondeterministic dependencies, and makes the expected outcome obvious. It should fail when behavior breaks, not when harmless implementation details change. Unit tests are one layer: integration and end-to-end tests remain necessary for wiring and real user paths.
Who this is for: Developers writing automated tests for functions, modules, components, and domain rules that change frequently.
- Choose examples from rules, boundaries, and past failures rather than chasing a coverage percentage alone.
- Assert observable outputs and effects while avoiding mocks of the unit's own internal steps.
- Keep tests deterministic, isolated, readable, and fast enough to run during ordinary development.
Define the behavior under test
Begin with a rule in plain language: an expired coupon is rejected, a subtotal excludes cancelled lines, or a parser reports an unknown status. Select the smallest public function or module boundary that expresses the rule. A unit can include several collaborating functions when splitting them would expose implementation rather than useful behavior.
Name the test around condition and outcome. Arrange only the data needed, perform one meaningful action, and assert the visible result. This structure is guidance, not ceremony. A test with many unrelated assertions likely covers several behaviors and will produce an ambiguous failure when one changes.
Select cases with reasoning
Cover a representative normal case, important boundaries, invalid inputs, and business exceptions. For numeric ranges, test just below, at, and just above a threshold. For state machines, test allowed and forbidden transitions. Add a regression case when a real defect reveals a missing distinction, then fix the implementation.
Coverage can identify code never executed, but it does not show whether assertions are meaningful. One test can execute every line while checking the wrong outcome. Use coverage as a question generator. Risk, complexity, and consequences should drive depth. Generated combinations can help pure algorithms, while curated cases communicate domain intent better for many rules.
Control dependencies at the boundary
Time, randomness, network calls, files, and process-wide configuration can make a unit test slow or unpredictable. Pass a clock, identifier generator, repository, or sender through a clear dependency boundary when the code needs control. Use a simple fake or stub that behaves like the dependency contract required by the test.
Avoid mocking every internal function call. Tests that assert call order inside the unit prevent refactoring while offering little product confidence. Mock external effects when necessary, and assert the meaningful request sent to that boundary. Separate contract or integration tests should verify that a fake still represents the real dependency closely enough.
Make failures useful and stable
A test should produce the same result regardless of run order, timezone, machine speed, or unrelated tests. Reset shared state, use fixed time deliberately, await asynchronous work, and avoid arbitrary sleeps. If concurrency is the behavior, expose a controllable synchronization point and assert the final invariant.
Prefer focused equality or structured matchers that show the difference. Large snapshots can hide meaningful changes among formatting noise and may be approved without review. Keep test data close enough to understand, but use builders when repeated setup obscures the rule. Run the suite in continuous integration and keep flaky tests as defects, not accepted background noise.
Test a shipping discount rule
Orders receive free standard shipping at a subtotal threshold, but gift cards and cancelled lines do not count.
- Express the unit as a function from active purchasable lines and delivery method to a shipping charge decision.
- Test one subtotal below the threshold, exactly at it, and above it with standard delivery.
- Add cases where gift-card value or a cancelled line would incorrectly push the raw total over the threshold.
- Test that express delivery remains charged if that is the written rule, even above the standard threshold.
- Refactor the calculation and confirm tests keep passing without asserting private helper calls.
Unit test case card
Use this card to justify each test instead of adding examples mechanically.
- Rule: observable behavior, public boundary, business consequence, and source of expected result.
- Case: normal input, boundary, invalid input, exception, or regression represented.
- Dependencies: real pure collaborator, fake, stub, fixed clock, deterministic identifier, and cleanup.
- Assertion: returned value, state change, emitted command, error, and details intentionally ignored.
- Quality: independent order, useful failure, runtime cost, refactoring tolerance, and missing higher-level test.
Common mistakes
- Asserting private helper calls so harmless refactoring breaks tests without changing behavior.
- Using arbitrary time delays to wait for asynchronous code and accepting intermittent failures.
- Treating high line coverage as proof that important rules and boundaries are correct.
Try one
Choose unit test cases for a password-reset token that expires after thirty minutes and can be used once. Explain what unit tests cannot establish.
Good cases use a fixed clock to test just before, exactly at, and after the documented boundary, plus already-used and wrong-token cases. They assert one successful transition and deliberate rejection reasons without real waiting. Unit tests cannot prove database uniqueness, transaction behavior, email links, or the complete browser flow, so those require integration and end-to-end coverage.
Sources
- GitHub test workflow guidanceGitHub's documented workflow for running repeatable Node.js tests in continuous integration.
- GitHub testing pull requests guidanceGitHub's documented example for test matrices, coverage artifacts, and pull request checks.
- TypeScript function handbookThe TypeScript handbook chapter on function types, overloads, unknown values, and assignability.