Writing a Testing Policy a Team Will Follow
Most testing policies are ignored, and the reason is almost always the same: they are written as aspirations rather than as decisions. “Write meaningful tests” cannot be applied to a pull request; “every new API route has a test at the integration tier” can. A policy earns its place when a reviewer can point at it to settle a disagreement in one sentence, and when the answer it gives is the same whoever is reviewing. This guide covers writing rules that are decidable, keeping the document to a page, recording exceptions instead of debating them, and revising it from evidence rather than from opinion. It sits under test ownership models.
Root Cause Analysis
Unfollowed policies share three properties. They are long, so nobody has read them; they are vague, so two reasonable reviewers reach different conclusions; and they have no exception mechanism, so the first time following them is genuinely wrong, the whole document loses authority.
Length is the easiest to fix and the most consequential. A policy that takes ten minutes to read will be read once, by its author. One page, structured as rules with a clear subject and verb, is read by everyone and remembered by most. Everything that is context, rationale or examples belongs somewhere else, linked.
Vagueness is subtler because it looks like flexibility. “Use the appropriate tier” sounds accommodating and is in practice an invitation to argue in every review. A rule that names the tier for each kind of change removes the argument without removing judgement — the judgement moved to the moment the policy was written, where it could be made once and carefully.
Reproducible Setup
Write the policy where the code is, so it is versioned, reviewable and discoverable from the pull request template.
mkdir -p docs && touch docs/TESTING.md
<!-- .github/pull_request_template.md -->
- [ ] Tests follow `docs/TESTING.md`, or an exception is recorded below
Implementation
Step 1 — Write rules with a trigger and an obligation. Every rule should have the form “when X, do Y”, so applying it is a lookup rather than an interpretation.
## Rules
1. A new HTTP route ships with an integration test covering its success path
and its primary failure path.
2. A bug fix ships with a test that fails on the commit before the fix.
3. Pure business logic — pricing, permissions, validation — is tested at the unit
tier, not through the interface.
4. A new end-to-end test requires an existing one to be removed, or a note in the
description explaining why the suite must grow.
5. A test that has been skipped for more than 30 days is deleted.
Rule four is the one teams find surprising and the one that keeps the most expensive tier from growing without a decision.
Step 2 — State who owns what, by path. Ownership belongs in a machine-readable file so reviews route automatically; the policy names the principle and points at the file.
# .github/CODEOWNERS
/src/domain/pricing/ @acme/payments
/src/features/checkout/ @acme/checkout
/e2e/specs/checkout.spec.ts @acme/checkout
/test/builders/ @acme/platform
Step 3 — Give exceptions a mechanism, not a debate. Every real policy is wrong sometimes; what matters is that the exception is recorded and visible rather than negotiated afresh each time.
## Exceptions
An exception is a line in the pull request description:
> Testing exception: no integration test for `GET /internal/metrics`.
> Reason: exercised by the deployment smoke check.
> Review by: 2026-12-01
Exceptions are listed in the monthly test health review. An exception with no
review date is not an exception.
Step 4 — Automate the rules that can be automated, and only those. A rule a machine can check should never be a human’s job; a rule it cannot check should not be phrased as if it could.
# .github/workflows/policy.yml
- name: Every new route has a test file
run: |
for route in $(git diff --name-only origin/main...HEAD | grep -E 'src/routes/.*\.ts$'); do
spec="${route%.ts}.test.ts"
[ -f "$spec" ] || { echo "::error file=$route::No test file $spec (policy rule 1)"; exit 1; }
done
Step 5 — Keep it to one page and link everything else. The policy states the rules; the reasoning, the examples and the tooling live in linked pages such as unit vs integration vs E2E mapping.
Step 6 — Revise from evidence, on a schedule. A rule that is broken every week is wrong, not the team. Bring the exception list to the health review and change the rule rather than repeating the argument.
A small drafting note that makes a disproportionate difference: write the rules in the present tense and the active voice, naming what ships rather than what someone should do. “A bug fix ships with a failing-first test” describes a property of the change, which a reviewer can check by looking at it. “Developers should write a regression test” describes an intention, which nobody can check at all.
Verification
Verify the policy is decidable by testing it on real pull requests rather than on hypotheticals. Take five merged changes, apply each rule, and check that two people independently reach the same verdict.
gh pr list --state merged --limit 5 --json number,title,files \
--jq '.[] | "\(.number) \(.title) — \(.files | length) files"'
# 1841 Add /api/refunds route — 6 files → rule 1: was there an integration test?
# 1839 Fix rounding in discounts — 3 files → rule 2: does the test fail without the fix?
Then verify adoption rather than assuming it. Count how often each rule is satisfied over a month; a rule satisfied ten per cent of the time is not a policy, it is a wish, and the useful response is to change it or to invest in making it easy.
Finally, verify the exception mechanism is being used. Zero recorded exceptions across a quarter is not a sign of perfect compliance; it is a sign that people are silently ignoring rules rather than recording when they do not apply.
gh pr list --state merged --limit 100 --json number,body \
--jq '[.[] | select(.body | test("Testing exception"))] | length'
# 7 ← a healthy number; zero would be the warning sign
Troubleshooting
Symptom: reviewers disagree about whether a rule was met. Diagnosis: the rule is not decidable. Fix: rewrite it with a concrete trigger and a concrete obligation, and check the new wording against the five real pull requests from the verification step before adopting it.
Symptom: the policy is quoted only when someone wants to block a change. Diagnosis: it is being used as a weapon rather than as a shared standard, usually because it was imposed rather than agreed. Fix: bring the disputed rules to the team, keep the ones people endorse, and delete the rest — a shorter policy that is genuinely shared outperforms a complete one that is resented.
Symptom: new engineers do not know the policy exists. Diagnosis: it lives in a wiki nobody opens. Fix: put it in the repository, link it from the pull request template, and reference it in the onboarding path described in onboarding engineers into an existing test suite.
Symptom: rules multiply after every incident. Diagnosis: the policy is being used as an incident log. Fix: cap the number of rules — around six is workable — and require that adding one removes another. Most post-incident rules are better expressed as an automated check than as a paragraph nobody will remember.
FAQ
How long should the policy be?
One page, and preferably less. If a rule cannot be stated in a sentence, it is probably two rules or a piece of guidance rather than a rule. The constraint is not arbitrary: length is what determines whether people read it, and an unread policy has no effect whatever its contents.
Should the policy mandate coverage numbers?
Put thresholds in configuration rather than in prose, because the configuration is enforced and the prose is not. The policy can say that new code is expected to be covered and point at where the numbers live — which keeps the document stable while the numbers ratchet, as described in per-directory coverage thresholds in Vitest.
Who should own the policy?
Whoever owns the pipeline, with changes reviewed by the teams it binds. An ownerless policy decays; one owned by a single team that does not write most of the tests becomes an imposition. Naming an owner and requiring review by the affected teams is the arrangement that survives.
What if the team simply disagrees with a rule?
Then it will not be followed, and pretending otherwise wastes everyone’s time. Bring it to the review, hear the objection, and either change the rule or make it cheap to follow. A rule that survives an honest argument is worth far more than one that was never discussed.
Related
- Back to Test Ownership Models
- Running a test health review cadence — the forum that revises the policy.
- CODEOWNERS-driven test ownership in CI — routing the reviews the policy depends on.
- Unit vs Integration vs E2E Mapping — the reasoning behind the tier rules.