Skip to main content

Overview

Test code verifies that the system behaves as intended, and it is the safety net that makes continuous change possible. What matters in test code is that it protects the system: a protective test passes when the behavior is correct and fails when the behavior breaks. Its value is not in every test passing, but in preventing unintended behavior before it is released to production. This page explains the principles behind such tests and shows patterns for writing protective tests.

Principles

The purpose of tests is sustainable growth of the software

Automated tests exist to make the growth of the software sustainable: it keeps the project in a state where the effect of a change can be verified with trustworthy results in a short time, so the team can keep changing the system with confidence. Detecting unintended behavior is the mechanism, not the end goal. Quality and speed are therefore not a trade-off. Skipping tests buys a short-lived head start, and the cost returns quickly as unintended behavior slipping in, manual re-verification, and fear of touching existing code. It is not that there is no time to write tests — there is no time because tests are not written. Refactoring and incremental improvement require confidence that existing behavior still holds after a change, and test code provides it. This matters even more when AI coding tools propose changes: whether a proposed change is safe to accept is ultimately answered by the test code, and the quality of the test code determines how much of that work can be delegated.

Passing and failing must both be trustworthy

Test code protects the system only while its results can be trusted in both directions. Two kinds of failures erode trust in test code:

False negative — broken but passing

The behavior is broken but the test passes. Fragmentary assertions or mocking the logic under test cause it; the test reports safety that does not exist, and unintended behavior is released to production.

False positive — correct but failing

The behavior is correct but the test fails. Dependence on implementation details or unstable timing causes it; frequent false positives train the team to ignore failures, until the moment the behavior truly breaks is missed as well.
Strengthen assertions to reduce false negatives, and assert observable behavior rather than internal implementation to reduce false positives. Test code earns trust by keeping both low. The patterns later in this page guard mainly against false negatives.

Coverage is a result, not a goal

Coverage measures which lines test code executes, not whether it asserts the right behavior. A line can run without any assertion that it does the correct thing, so a high percentage can coexist with tests that protect very little. The reliable order is to decide what behavior must not break, then add test cases that would catch it breaking. Coverage rises as a consequence.

Write tests in the same pull request as the implementation

A behavior change and the test that protects it belong in the same pull request. Deferring the test to a follow-up leaves a window where the behavior is unprotected, and in practice the follow-up competes with new work and loses. Review test code at least as rigorously as implementation code: a weak assertion approved today is unintended behavior missed later. See Pull Requests for how to keep such changes reviewable.

Writing tests that protect

The following patterns are cases where a test looks fine but would not catch the bug it is meant to catch — that is, sources of false negatives. Each pattern strengthens the test until it fails for the failures that matter. The same patterns are the recurring weaknesses of test code generated by AI tools: generated tests tend to optimize for a green run rather than for protection, so review them against these patterns before merging, with the same scrutiny as hand-written tests.

1. Assert execution state, not just execution

Asserting only that a callback was called hides three bugs: a handler that fires twice when it should fire once, a handler called with the wrong arguments (another user’s data, for example), and an error-path callback that runs when it should never run. Pin down the count and the arguments, and assert that the handlers that should never be called were never invoked.

2. Assert the whole expected value, not fragments

Checking one field of a result leaves every other field unprotected: an unchecked field that is dropped, renamed, or corrupted passes silently. Compare the entire structure against a literal expected value, so any unintended change to the shape or content of the result fails the test.
Generated tests in particular often assert just enough for the current behavior to pass: the tests pass today, but there is no guarantee against a future extension that breaks an unasserted part of the behavior.
Loose matchers weaken assertions the same way. toBeTruthy and be_truthy pass for a non-empty string, a number, or any other truthy value, so a refactor that quietly changes a boolean return into another type keeps passing. Assert the exact value and type instead.

3. Mock the boundary, not the logic under test

When a green run becomes the goal, an AI coding tool may mock the very logic under test, or adjust the expected value to whatever the implementation currently returns. Such a test passes even if the implementation is wrong, because the implementation itself was treated as the answer key. The proper role of a mock is the opposite: fixing an external boundary — the current time, a network response — so that the output becomes deterministic. Output that depends on the current time, such as a mail body that embeds an expiry date, can only be asserted in fragments until the clock is pinned; once the time is mocked, the whole expected value can be asserted exactly, as in pattern 2. Describe how mocks should be used in each case in the project’s custom instructions, and verify during review that the expected values come from the specification, not from the output.

Pull Requests

Ship the test in the same reviewable unit as the change it protects.

Vibe Coding

Delegate implementation to AI tools on top of a suite you trust.