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.

Vague rules versus decidable rules A rule saying to write meaningful tests cannot settle a review, while a rule naming the tier and the trigger produces the same answer from any reviewer. Written as Can a reviewer apply it? "write meaningful tests" no — it means nothing specific "use the appropriate tier" no — two reviewers differ "a new API route gets an integration test" yes — present or absent "a bug fix arrives with a failing-first test" yes — check the commit order
A rule is useful exactly to the degree that two reviewers reach the same verdict from it.

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
Where each rule should be enforced Structural rules such as a test file existing are enforced by CI, judgement rules such as choosing a tier are enforced in review, and cultural rules such as investigating retries are kept alive by the health review. CI enforces a test file exists coverage of new lines no long-skipped tests structural, decidable review enforces the right tier was chosen the assertion is meaningful the new e2e test earns its place judgement, but rule-guided the review meeting exceptions coming due flake and duration trends rules that keep failing cultural, over months
Putting a rule in the wrong column is why policies feel either toothless or bureaucratic.

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
The life cycle of a rule A rule is proposed, applied in reviews, measured for how often it is followed, and then either automated, kept as it is, or rewritten — a rule broken constantly is evidence about the rule. proposed one sentence applied in reviews measured how often followed automated, if checkable rewritten, if always broken
A rule nobody follows is data about the rule, and the response is to change it rather than to repeat it.

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.