Unit Testing: Deterministic Fixtures, Actions & Oracles

A durable unit test isolates its own fixture, performs one meaningful action, and checks externally meaningful behavior with an independent oracle. Its value is fast, specific feedback that survives refactoring rather than maximum isolation or assertion count.

A unit is not necessarily one method or one class. It is the smallest coherent behavior worth naming and controlling. A durable unit test asks that behavior a question, supplies every relevant input explicitly, and observes the answer through the same contract a caller depends on. The test should fail when behavior changes accidentally and stay green when only internal structure changes.

The four phases #

Most xUnit tests have four phases; Arrange–Act–Assert names the first three and often leaves teardown implicit.

Setup creates the subject and inputs. Exercise invokes the behavior. Verify compares observations with the oracle. Teardown releases resources the fixture owns. The separation is conceptual, not a demand for comments or blank lines. A test is readable when its decisive values and action are visible without tracing a hidden web of setup hooks.

typescript
test("rejects a debit that would exceed the limit", () => {
  const account = openAccount({ balance: 40, overdraftLimit: 10 });

  const result = account.debit(51);

  expect(result).toEqual({ ok: false, reason: "limit-exceeded" });
  expect(account.balance).toBe(40);
});

The two assertions express one outcome: rejection leaves the account unchanged. “One assertion per test” would split one fact into two incomplete tests. The better rule is one reason to fail.

A fixture must belong to the test #

Tests should pass individually, in any order, repeatedly and in parallel. The strongest way to get that property is a fresh fixture: construct new in-memory objects and allocate unique external resources for each test. Cleanup is necessary when a resource cannot be discarded, but cleanup is weaker than creation because an interrupted test may never run it and because some state is not reversible.

Shared fixtures can be justified when creation is expensive. Make the shared part immutable, then layer per-test mutable state above it. If one test can modify something another test observes, the suite has encoded an execution order even if the runner usually hides it.

Use setup helpers to hide irrelevant mechanics, not decisive facts. Compare:

typescript
const order = validOrder();
order.lines[0].quantity = 0;

with:

typescript
const order = orderWithLine({ quantity: 0 });

The second names the condition and keeps unrelated defaults elsewhere. A global beforeEach that mutates ten fields makes every test inherit facts it cannot see.

Control every source of nondeterminism #

A deterministic test returns the same result when behavior and inputs do not change. The usual hidden inputs are:

  • wall-clock time, time zone and locale;
  • random seeds and generated identifiers;
  • environment variables, filesystem contents and process globals;
  • thread scheduling and completion timing;
  • network services and shared databases;
  • iteration order not guaranteed by the contract.

Pass a clock or timestamp to time-dependent behavior, pass a seeded generator when the exact sequence matters, and allocate unique temporary resources. Preserve the failing seed for randomized or property-based tests. Never “fix” an order-dependent test by forcing the current order unless order is actually part of the contract.

Assert behavior, not construction #

An oracle should be independent enough to catch the defect. Reimplementing the production formula inside the expected-value calculation makes both sides likely to share the same mistake. Prefer a hand-derived example, an invariant, a trusted reference, or an observable postcondition.

Useful observation styles include:

Style Best for Typical mistake
exact example boundary rules and known cases testing only the happy path
invariant/property broad input spaces writing a property too weak to matter
state/output verification value transformations and domain behavior inspecting private fields
interaction verification a required message at a true boundary freezing incidental call order
snapshot/approval large structured output approving noise nobody reviewed

Private methods, field layout and collaborator call sequences are structure. If a refactor preserves the public result, a behavioral test should normally survive. The exception is when the interaction itself is the contract: committing exactly once, publishing an audit record, or never sending after cancellation.

Names should encode the claim #

A useful name identifies the condition and result: debit_above_available_limit_is_rejected. The class or module already supplies context, so repeating every type and method name adds noise. Failure output should show expected and actual domain values, not merely false was not true.

Table-driven tests are appropriate when many examples share one behavior. Separate tests are clearer when setup, action or failure diagnosis differs. Parameterization is compression only when each row remains intelligible in the failure report.

Test smells are design signals #

  • Slow unit test: the boundary includes I/O or real waiting that the name does not admit.
  • Interacting tests: mutable fixture state leaks between cases.
  • Mystery guest: a file, database row or environment variable influences the result but is invisible in the test.
  • Assertion roulette: many unrelated checks fail without identifying which claim broke.
  • Fragile test: harmless refactoring requires widespread test edits, often because mocks encode structure.
  • Obscure test: setup overwhelms the behavior and oracle.
  • Flaky test: at least one input or completion condition is uncontrolled.

Do not delete an old test merely because it has not failed recently. First determine which distinct behavior it protects. Redundant tests can be combined or removed; a quiet test may simply be guarding stable but critical behavior.

A practical review checklist #

For each unit test, ask:

  1. What behavior claim does the name make?
  2. Which line performs the action?
  3. Which inputs are hidden outside the fixture?
  4. Why is the expected result trustworthy?
  5. Would the test pass alone and in parallel?
  6. Would renaming a private method or splitting a class leave it unchanged?
  7. Does a failure point to one small region and print enough evidence to reproduce it?

These questions are more durable than a framework's attribute names. Runners and assertion syntax change; fixture ownership, controlled inputs and credible oracles remain the mechanism.

Sources

  1. bookxUnit Test Patterns: Refactoring Test Code
  2. articleTest Desiderata
  3. docs.NET — Best practices for writing unit tests

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close