Skip to content
Community content. Review instructions before giving them to an AI agent — treat modules like open-source code.

Unit Testing Rules

Writing unit tests that stay useful: testing behavior not implementation, one assertion focus per test, descriptive names as documentation, arrange-act-assert structure, fast and deterministic execution, disciplined mocking, and edge case coverage.

Mby @markdownersPublished August 21, 2026 · ~4 min read

0 downloads · Used by 0 stacks

A unit test's job is to catch regressions in behavior while allowing implementation to change freely — a test suite that breaks every time you refactor without changing behavior is testing the wrong thing, and will get deleted or ignored the moment it becomes an obstacle.

Test behavior, not implementation

  • Assert on the function's observable output and effects (return value, thrown error, mock calls to external dependencies) — never on private internals, intermediate variables, or implementation details reachable only by reflection or by exporting something solely for the test.
  • If a refactor that doesn't change behavior breaks tests, the tests were coupled to implementation — fix the coupling, not by loosening the assertion but by asserting on the actual contract (inputs and outputs) instead.
  • Treat the function/module's public API as the thing under test — internal helper functions used only by one caller don't need their own test suite; test them through the public behavior that exercises them.

One assertion focus per test

  • Each test should verify one behavior or one logical outcome — multiple expect calls checking facets of the same outcome are fine, but a test asserting several unrelated behaviors makes failures ambiguous (which behavior actually broke?) and encourages skipping the rest of the test on first failure.
  • Split a test that starts accumulating unrelated setup and assertions into multiple focused tests, each named for the specific behavior it verifies.

Descriptive names as documentation

  • Name tests as a sentence describing behavior and condition ("returns 401 when the session token is expired"), not the function under test alone ("test login") — a failing test's name should tell you what broke without opening the test body.
  • Group related tests with describe blocks named after the unit under test, and let each it/test name complete the sentence — the concatenated description should read as a spec.
  • Treat the test suite as living documentation of intended behavior — a new contributor should be able to understand what a module does by reading its test names alone.

Arrange-act-assert

  • Structure every test in three clear phases: arrange (set up inputs, mocks, initial state), act (call the thing under test, ideally one call), assert (check the outcome) — resist interleaving setup and assertions, which makes tests harder to read and to modify.
  • Keep the "act" phase to a single action wherever possible — a test that calls the function under test multiple times with assertions between each call is really multiple tests glued together.
  • Extract repeated arrange logic into test helpers/factories, but keep the specific values that matter to this test's behavior visible in the test itself, not hidden inside a shared helper.

Fast and deterministic

  • No real network calls, real timers, real random values, or real filesystem access in unit tests — mock or fake all of these, since a suite that depends on external state is slow, flaky, and impossible to run offline or in parallel reliably.
  • Use fake/controlled clocks for time-dependent logic (expiry checks, scheduling) rather than sleeping in the test or depending on wall-clock time — a test that passes only when run fast enough is a test that will eventually flake in CI.
  • Seed any randomness used in test data generation so failures are reproducible — a test that randomly fails once a month with no seed to reproduce it is a maintenance tax with no offsetting benefit.
  • Keep the full unit suite fast enough to run on every save during development (seconds, not minutes) — push anything slower (real I/O, real service integration) to a separate integration/e2e suite.

Mocking discipline

  • Mock at the boundary of the unit under test (external services, the network, the filesystem, other modules with side effects) — don't mock the thing you're actually trying to test, or the test proves nothing.
  • Prefer fakes/stubs with realistic behavior over mocks that just record calls, when the interaction pattern matters — a mock that returns whatever makes the assertion pass can hide a real bug in how the unit uses the dependency.
  • Reset or re-create mocks between tests — shared mutable mock state across tests is a common source of order-dependent test flakiness (a test passing only when run after another one).
  • Avoid over-mocking to the point where the test just re-asserts the mock's configured return value — if a test would pass even with the function under test deleted, it isn't testing anything.

Edge cases and error paths

  • Test the boundaries explicitly: empty input, zero, negative numbers, maximum-length strings, empty arrays/null/undefined where the type allows it — the happy path alone leaves the boundaries, where most real bugs live, unverified.
  • Test error paths as first-class behavior: what the function does on invalid input, a thrown dependency error, or a timeout — not just what it does when everything succeeds.
  • When fixing a bug, add a regression test that reproduces it before the fix, confirm it fails, then confirm the fix makes it pass — this both documents the bug and prevents its silent reintroduction later.
Badge

Link back to this module from your own README.

Get it on Markdowners
[![Get it on Markdowners](https://markdowners.com/mdstack-badge.svg)](https://markdowners.com/m/markdowners/unit-testing-rules)

Comments (0)

Sign in to comment. Sign in

No comments yet. Be the first to add one.

Discussions about this module

No discussions about this module yet.

Start a discussion