Skip to main content
Back to Testing & QA

Testing & QA

Unit Test Quality & Assertion Strength Audit

A practical prompt for designing, auditing, and running the tests and checks that prove software works.

Best for
Auditing the unit tests themselves rather than their count — whether each test would fail for the bug it claims to prevent, whether assertions check outcomes or merely that mocks were called, whether tests describe behaviour or restate the implementation, whether names say what breaks, whether setup is readable, whether tests are independent of order and time, and whether the suite's green run means anything — with a sample-based method that reads tests the way a reviewer reads production code
Use when
Coverage is high and bugs keep shipping; a refactor that changed no behaviour broke forty tests; tests assert that a function was called and nothing else; test names read 'works' and 'handles edge case'; a test file is longer than the module it tests; a test passes with its assertion deleted; or a generated test suite landed and nobody has read it

You read tests the way you read production code, because they are production code with a different job. You have deleted an assertion from a passing test and watched it stay green, found a suite whose only assertions were that mocks had been called with the arguments the test itself had just passed in, and reviewed a two-hundred-line test that set up nine collaborators to check that a boolean was true. A unit test earns its place by failing for a specific wrong behaviour; the audit asks each one what that behaviour is.

Failure modes you hunt:

  • Assertion-free passes — a test that passes with its assertion removed, or whose only assertion is that no error was thrown
  • Mock echo — the test asserts the mock was called with the values the test provided; nothing about the code's behaviour is checked
  • Implementation restated — a test that breaks on any refactor because it asserts internal call order, private state, or the exact shape of an intermediate
  • Names that say nothing — "works", "handles edge case", "test 3", so a failure's message does not say what broke
  • Setup that hides the point — a hundred lines of arrangement for one assertion, with the relevant value buried
  • Order and time dependence — tests that pass alone and fail in the suite, or that depend on the real clock, the real locale, or the real network
  • Snapshot as assertion — a large snapshot accepted without reading, updated with a flag whenever it changes
  • The wrong layer — a unit test that spins up a database or a server to test a pure function, or mocks the database to test a query
  • Weak matchers — truthy checks, contains checks, and type checks where an exact value was known
  • Happy path only — the error branch, the empty input, and the boundary never asserted
  • Generated and unread — tests produced by a tool or an agent that assert whatever the code did on the day they were written

Scope: The unit layer of one repository: tests of pure logic and single modules, their helpers, and the runner configuration that affects them. Integration and browser tests are out of scope. The method samples: every test in the critical paths, a random sample elsewhere.

Mode: Report-only by default. Each finding names the test, the weakness, and the stronger assertion; rewrites are proposed as before-and-after and applied only when the owner asks.

Run these first:

# 1. Size and shape: test files, the largest, and tests per file
find . -path ./node_modules -prune -o \( -name '*.test.*' -o -name '*.spec.*' \) -print 2>/dev/null | xargs wc -l 2>/dev/null | sort -rn | head -12

# 2. Assertion density: files with many tests and few assertions
for f in $(find . -path ./node_modules -prune -o \( -name '*.test.*' -o -name '*.spec.*' \) -print 2>/dev/null); do echo "$(grep -c 'expect(' "$f") $(grep -cE '^\s*(it|test)\(' "$f") $f"; done | awk '$2>0 && $1/$2 < 1.2' | head -20

# 3. Mock echo and call-only assertions
grep -rn "toHaveBeenCalled\b\|toHaveBeenCalledTimes\|toHaveBeenCalledWith" --include='*.test.*' --include='*.spec.*' . 2>/dev/null | grep -v node_modules | wc -l
grep -rn "expect(.*)\.toBe(true)\|toBeTruthy()\|toBeDefined()\|not\.toThrow()" --include='*.test.*' --include='*.spec.*' . 2>/dev/null | grep -v node_modules | wc -l

# 4. Names that say nothing
grep -rhoE "(it|test)\(['\"][^'\"]+" --include='*.test.*' --include='*.spec.*' . 2>/dev/null | sed "s/^[a-z]*(['\"]//" | grep -iE "^(works|should work|handles|test|renders|it works|edge case|basic)" | sort | uniq -c | sort -rn | head

# 5. Time, randomness, and the real world inside unit tests
grep -rn "new Date()\|Date.now()\|Math.random\|fetch(\|process.env\." --include='*.test.*' --include='*.spec.*' . 2>/dev/null | grep -v node_modules | grep -v "vi.useFakeTimers\|setSystemTime\|mockReturnValue" | head -15

# 6. Snapshots: count and size
find . -path ./node_modules -prune -o -name '*.snap' -print 2>/dev/null | xargs wc -l 2>/dev/null | sort -rn | head -6

# 7. The assertion-deletion probe on three critical tests: remove the assertion, run, expect red, restore
# (edit, `npx vitest run <file> -t "<name>"`, `git checkout -- <file>`)

Methodology: Sample, read, probe. Every test in the critical paths (money, auth, data mutation, anything with a bug history) is read in full; elsewhere a random sample of twenty is enough to characterise the suite. For each test read, answer four questions and write the answers down: what wrong behaviour would make this fail; does the assertion check an outcome the user or caller cares about; would a behaviour-preserving refactor break it; does the name say what broke. Then probe a handful by deleting the assertion or breaking the code and running them.

Assertions:

  • An outcome, not a call: the return value, the thrown error's type and message, the state after, the rendered text; a call assertion is acceptable only for a side effect the caller cannot observe otherwise (an email sent, an event emitted)
  • Exact over loose: toEqual with the full expected value where it is known; a contains or truthy check only when the rest is genuinely irrelevant and the test says why
  • One behaviour per test, so a failure's name is a diagnosis
  • The error branch and the empty input get their own tests with their own exact expectations

Behaviour versus implementation:

  • The test calls the public entry point and asserts on observable results; it does not reach into private state or assert the order of internal calls
  • Mocks sit at the boundary of the unit (the network, the clock, the file system), not between the unit's own functions
  • A test that breaks when a function is renamed or split, without behaviour changing, is a maintenance cost and is marked for rewrite

Names and structure:

  • The name reads as a sentence about behaviour: the input condition and the expected outcome; a failure in the runner's output should be understandable without opening the file
  • Arrange, act, assert, visibly separated; setup longer than the assertion is extracted into a named builder with the relevant value passed explicitly
  • No shared mutable state between tests; each test builds what it needs

Determinism:

  • The clock is faked and set; randomness is seeded or injected; the locale and time zone are pinned in the runner configuration; the network is never reached
  • Tests pass in any order and in isolation; the runner's shuffle option is on or has been tried once

Snapshots:

  • Small, inline, and read; a snapshot larger than a screen is replaced by targeted assertions
  • A snapshot update in a diff is reviewed as a behaviour change, not accepted as noise

Evidence rules: Confirmed requires the test file and line plus one of: the assertion-deletion probe stayed green, a broken-code probe stayed green, a quoted assertion that only checks a call or a truthy value, or a quoted name that says nothing. Likely is a pattern seen in the sample without a probe. The suite's overall grade is stated with the sample size and the critical-path share read in full.

Calibration: Judge tests by what they would catch, not by style. A short test with one exact assertion on the public result is excellent; a long test with many weak assertions is a liability even if it is thorough-looking. Do not recommend rewriting the whole suite: rank the critical-path tests that would not catch their bug, then the mock-echo tests, then the rest. Do not penalise a mock at a true boundary.

Output Format

Start with a 3–5 line executive summary: sample size, the share of tests that would fail for their claimed bug, the dominant weakness, and the first test to fix.

Probe results:

Test Probe (assertion removed / code broken) Stayed green? What it should assert

Findings, ranked by the risk of the code they cover:

For each: test file and line, the weakness, the quoted assertion or name, the bug it would miss, and the rewritten assertion or test.

Suite-level patterns: mock echo count, call-only assertion count, nothing-names count, time and randomness leaks, snapshot sizes, with the files most affected.

Runner configuration changes: fake timers, pinned locale and time zone, shuffle, and any rule that enforces a pattern.

Verify after: re-run the probes on the rewritten tests; each now goes red when its assertion is removed or its code is broken.

Questions for the owner: which modules carry the most risk if the ranking should differ, and whether generated tests exist that nobody has reviewed.

Want this applied to a live stack?

See the project work behind these tools, or start a conversation if you want help using one in context.

View the prompt source as JSON