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.
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.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.Related pages
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.