Skip to content

Unit Test Guidelines

ContributorsRadu Cioienaru, Myles Henaghan

A test that raises coverage but cannot fail when the behaviour is wrong is worse than no test: it costs maintenance, and it buys confidence that was never earned.

This guidance consolidates behaviour-oriented unit testing for Python (pytest) and TypeScript (Vitest). The anti-patterns below were distilled from real coverage pushbacks on full-stack engagements — patterns that raise coverage without raising confidence.

Scope

Stack Location Runner
Python project test tree (often tests/) pytest + coverage gate (if configured)
TypeScript / React frontend source tree Vitest + Testing Library, jsdom

The principles and anti-patterns sections apply to both. The Python specifics and TypeScript specifics sections cover what differs.

Commands

Team members work on Windows PowerShell, Git Bash, and Linux. Commands below are given PowerShell-first. Do not assume a Unix shell.

Task PowerShell Bash / zsh
Python, one file uv run pytest tests\test_x.py uv run pytest tests/test_x.py
Python, with coverage uv run pytest tests\test_x.py --cov=src --cov-report=term-missing same, / separators
Python, full gate uv run prek run coverage --hook-stage pre-push --all-files identical
UI tests cd ui; npm test cd ui && npm test
UI watch mode cd ui; npm run test:watch cd ui && npm run test:watch

PowerShell notes:

  • && is not a statement separator in Windows PowerShell 5.1. Use ; or PowerShell 7.
  • Use uv run ... everywhere. Never .venv\Scripts\activate, never source venv/bin/activate.
  • Bare python may resolve to the Microsoft Store stub. uv run avoids this.

Principles

Language-agnostic. Adapted from the hoen-library unit-test skill.

Name the behaviour, not the implementation

Pattern: does something when condition. Drop filler like "should", "correctly", "properly".

// Bad
it('should correctly return the user when findById is called with a valid id', ...)
// Good
it('returns the user when found', ...)
# Bad
def test_compute_rolling_window_works_properly(self): ...
# Good
def test_returns_empty_when_month_outside_window(self): ...

Several assertions describing one coherent behaviour belong in one test. Do not split them purely to reach one assertion per test.

Avoid duplicate coverage

Do not add tests that exercise the same path with trivial input variations. Extend the existing test, or parameterise — @pytest.mark.parametrize in Python, it.each in Vitest.

Assert observable behaviour

Return values, side effects, raised errors, externally visible state. Avoid asserting internals or mock call counts by default. Skip trivial delegation tests unless argument correctness is the behaviour under test.

Share fixtures, override only what differs

Prefer named fixture factories over repeated inline literals. Name them for their meaning: base_user, expired_session, invalid_token.

Hard rules

Both stacks.

  • Do not modify production code to make a test pass. If the code is untestable, say so and propose the source change separately.
  • Only add or update test files, unless the task explicitly includes a refactor.
  • Always mock what crosses a process or network edge: database, HTTP, message buses, vendor SDKs. Never let a unit test open a real connection.
  • Never mock inside that edge. Patching internal logic makes the test assert its own setup.
  • Scope every patch so it is undone at teardown — context manager, decorator, or a fixture that auto-restores. Never leave global state modified.
  • Aim for 100% coverage of the file under test. Where that is not reachable without breaking a rule above, state which lines are uncovered and why.

Python specifics

Tooling

  • Always use uv — uv run pytest, uv run ruff. Never .venv\Scripts\activate or source venv/bin/activate.

Mocking

  • Use unittest.mock for mocking: from unittest.mock import MagicMock, patch. The one exception is environment variables, where pytest's monkeypatch fixture is preferred because it restores state at teardown.
  • Scope every patch with a decorator or context manager. Never assign onto an imported module, class, or function in a test body — see anti-pattern 5.
  • Do not use pytest.importorskip in new tests.
  • Do not inject or mutate sys.modules, and do not build import-time shims.
  • Import modules unconditionally at module level. No try/except ImportError, no conditional imports, no defensive import handling. A failing import is an acceptable and informative outcome.

Existing importorskip and patch.dict(sys.modules, ...) uses in this repo were audited and found justified — they guard genuinely optional Docker and SDK dependencies. They are grandfathered, not a precedent.

Design

  • Test public behaviour. Do not test private functions directly unless the helper is genuinely complex and has no reasonable public path.
  • Prefer fewer tests with several related assertions over many tiny repetitive ones.

TypeScript / Vitest specifics

Tooling

  • vi.fn() for dependency mocks, vi.mock() for module boundaries.
  • Build a fresh mock set and component/service instance in beforeEach.
  • Use expect.objectContaining(...) when only selected fields matter.
  • Query through Testing Library roles and labels, not CSS selectors or test IDs, so the test breaks when the accessible behaviour breaks.

Clocks

Vitest has first-class fake timers; Python does not. Use them, and always restore:

beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date(NOW));
});
afterEach(() => vi.useRealTimers());

This is the TS answer to anti-pattern 1. The Python answer is an injectable parameter, because there is no equivalent built-in and patching datetime.date.today is worse than changing our own signature.

Async

React Query and user events are asynchronous. Assert with await waitFor(...) or findBy* queries rather than a bare expect after a state change, and await every userEvent call. A missing await produces a test that passes for the wrong reason.

Python / TypeScript nuances

Concern Python / pytest TypeScript / Vitest
Mock library unittest.mock (patch, MagicMock) vi.fn, vi.mock, vi.spyOn
Restore after patch Context manager or decorator vi.restoreAllMocks(); restoreMocks: true
Env vars monkeypatch.setenv (auto-restored) vi.stubEnv + vi.unstubAllEnvs
Freezing time No built-in — inject a parameter vi.useFakeTimers + vi.setSystemTime
Parameterised cases @pytest.mark.parametrize it.each
Module-level patching Banned (sys.modules) vi.mock is the supported mechanism, hoisted
Coverage gate project threshold (if configured) project threshold (if configured)
Runs in CI when wired in pipeline when wired in pipeline

When one stack runs in CI and the other does not, treat that as a gap to close, not a standard. vi.mock being legitimate in TS while sys.modules mutation is banned in Python is the one place the two stacks genuinely diverge on principle: Vitest owns the module registry and restores it, whereas hand-mutating sys.modules has no teardown contract.

Anti-patterns

Each of these is drawn from a real finding. Examples reference this repo; swap them for local equivalents if this skill moves to another codebase.

1. Hidden clock dependence

The one that broke a build with no code change behind it.

compute_rolling_window() derived an 18-month window from date.today() and projected strictly forward. A test seeded a position at 2026-08 and asserted it appeared. Correct in August 2026, impossible from 2026-09-01.

# BAD — passes in the month it was written, expires silently
self.conn.execute("INSERT INTO position_rows (..., product_month, status) VALUES (..., '2026-08', 'OPEN')")
result = compute_rolling_window(self.conn, include_physical=False)
self.assertGreater(len(result), 0)
# GOOD — the window is an input, so the test states its own premise
result = compute_rolling_window(self.conn, include_physical=False, base_month="2026-08")
self.assertGreater(len(result), 0)

Hardcoding a month that happens to be this month is the natural move when the goal is to make a line execute. It is not the natural move when the goal is to pin behaviour, because pinning behaviour forces the question "relative to what?" — and asking that surfaces the clock dependency immediately.

Rule: any window derived from the current date must be injectable. If the function under test calls date.today() and offers no override, that is a source defect to raise, not a value to guess.

Smell: a hardcoded YYYY-MM or date literal in a fixture, where the same literal also appears in the assertion's expected result.

2. Mocking past the boundary

Mock what crosses a process or network edge. Nothing inside it.

# BAD — tests/test_scenario_api.py
@patch("app.api.routers.scenario.run_scenario")
def test_instrument_bump(self, mock_compute):
    """Instrument bump should move the P&L."""
    result = {**_MOCK_SCENARIO_RESULT, "shocked_pnl_nzd": 8000.0, "delta_nzd": -2000.0, ...}
    mock_compute.return_value = result
    resp = self.client.post("/portfolio/what-if", json={...})
    data = resp.json()
    if data["instruments"]:
        self.assertNotEqual(data["delta_nzd"], 0.0)

The docstring says the bump should move the P&L. The bump logic never runs — the -2000.0 was handed to the mock two lines earlier. The test asserts that a MagicMock returns what it was configured to return.

The sibling test_valid_request_no_bumps is the same shape: it asserts base_pnl_nzd == shocked_pnl_nzd where both values come from a shared _MOCK_SCENARIO_RESULT literal. It cannot fail.

# GOOD — same file, further down. Mock the connection, run the real function.
request = ScenarioRequest(
    as_of="2026-06-02",
    trade_mode="LIVE",
    bumps=[ParameterBump(instrument_key="SGX WMP", delta_pct=-10.0)],
)
with patch("app.services.scenario_service._get_readonly_connection", return_value=self.conn):
    result = run_scenario(request)

self.assertNotAlmostEqual(result["delta_nzd"], 0.0, places=0)

_get_readonly_connection is the external edge. Everything above it is real. The same file contains both patterns, which makes it a useful side-by-side.

Smells:

  • The patch target's function name also appears in the test module's name. If test_whatif.py patches run_scenario, the test covers the wrapper.
  • The expected value appears in the mock setup within the same test body.
  • An assertion sits behind an if, so it can pass vacuously when the guard is false.

Reference implementation: tests/test_domain_service.py drives real logic against in-memory SQLite. Copy that shape.

3. Assertions on shape rather than value

# BAD — tests/test_admin_reference.py
def test_reference_returns_structure(self):
    resp = self.client.get("/admin/reference", headers=self._h())
    self.assertEqual(resp.status_code, 200)
    data = resp.json()
    self.assertIn("streams", data)
    self.assertIn("instruments", data)
    self.assertIn("contracts", data)

A serialiser change breaks this. A pricing error never will.

# GOOD — keep the plumbing check, add a computed value
    data = resp.json()
    self.assertIn("instruments", data)
    self.assertEqual(len(data["instruments"]), 3)
    self.assertAlmostEqual(data["instruments"][0]["mt_per_lot"], 5.0)

This test named itself honestly — structure is what it tests. That is the point: the name is accurate and the test is still nearly worthless as a regression signal.

assertIsInstance(x, str), assertIsInstance(x, dict), and assertGreater(len(x), 0) as the only assertion are the recurring forms. Type and shape are nearly free to keep correct and nearly worthless as a regression signal.

Rule: at least one assertion per test must compare against an independently derived expected value — one you worked out from the inputs, not one you copied from a passing run.

4. Expected values harvested from output

If the expected number was obtained by running the code and pasting the result, the test asserts only that the code still does what it did. It cannot detect that what it did was wrong. Derive the expectation from the inputs, or from a worked example in the ticket.

5. Unscoped monkey patching

Every patch in this skill is monkey patching — rebinding a name at runtime. That is fine when it is scoped and aimed at a boundary. It stops being fine in three specific ways.

Patching that outlives the test. A bare assignment has no teardown, so the change leaks into every test that runs afterwards:

# BAD — never restored; makes the suite order-dependent
import app.services.book_service as book_service

book_service.DEFAULT_MONTHS_FORWARD = 3
# GOOD — restored on exit, whichever way the test ends
with patch.object(book_service, "DEFAULT_MONTHS_FORWARD", 3):
    ...

The symptom is a test that passes alone and fails in the full run, or vice versa. It is expensive to diagnose because the failing test is not the guilty one. Always use patch / patch.object as a decorator or context manager, and never assign onto an imported module or class in a test body.

Patching third-party or stdlib internals. Reaching into someone else's module binds your test to their private structure, and a version bump breaks it with an error that points at your test rather than at the upgrade:

# BAD — patches the stdlib to control our own logic
with patch("datetime.date.today", return_value=date(2026, 8, 15)):
    result = compute_rolling_window(conn)

This is why anti-pattern 1 prescribes an injectable base_month instead. The parameter is a supported interface; datetime.date.today is not. Prefer changing our own signature over patching something we do not own.

Patching what you are asserting on. Covered in anti-pattern 2 — if the patch target is inside the module under test, the assertion is measuring the patch.

Where it is legitimate: the external boundary (_get_readonly_connection, an HTTP client, an SDK entry point), and environment variables. For env vars, pytest's monkeypatch fixture is the right tool — monkeypatch.setenv and monkeypatch.delenv are automatically undone at test teardown, which is exactly the scoping property this section is asking for. tests/test_telemetry_setup.py uses it correctly throughout.

Workflow

  1. Read the target file and identify its public surface and its external edges.
  2. Write tests against the public surface, mocking only at those edges.
  3. Run the target file with coverage, then the full gate before pushing. See Commands for the PowerShell and bash forms.
  4. For unrelated files, spawn parallel agents rather than working sequentially.

Environment prerequisites

Some Python suites depend on native drivers (for example ODBC). When imports fail with missing shared libraries on Linux CI, install the documented OS packages before debugging application code.

Reviewing coverage-raising work

When the goal is a coverage number, the incentive sits in the ticket, not the engineer. Reviewers should apply a stronger bar than usual:

  • Does each new test have at least one value assertion?
  • Would the test fail if the computation were wrong, not just absent?
  • Does any patch target live inside the module under test?
  • Is any patch applied by bare assignment rather than a context manager?
  • Does any fixture hardcode a date near the date of authorship?
  • In TS, is every userEvent and async assertion awaited?

Prefer framing tickets around behaviours to pin rather than a percentage to hit.