top of page

Test Case Template That Stays Maintainable

  • Shawn West
  • Mar 19
  • 10 min read

Updated: Aug 10

A payments team once shipped a broken "resend receipt" button for the better part of two years, and a green test case sat on top of it the entire time. The case read, in full: "Verify the receipt email works." The step was "click Resend." The expected result was "email is sent." Nobody had defined sent to whom, containing what, arriving when — so the automated version asserted only that the click returned HTTP 200. The endpoint kept returning 200 after a template migration silently dropped the receipt body. The test never went red. The bug surfaced when a customer forwarded a blank email to support.

Down the hall, the same team had the opposite problem. Their checkout test spelled out every click, every CSS selector, the exact pixel-labeled button text, the order of the two address fields. It went red roughly every sprint — not because checkout broke, but because someone renamed a class or swapped two fields. After the third false alarm in a month, a developer added it to the quarantine list. Six weeks later it was deleted. The one test that exercised the money path was gone, and no one trusted it enough to miss it.

Two failures, one root cause. A test case is a small contract: under specific preconditions, specific actions should produce specific outcomes. Written too loosely, it passes while the feature is broken. Written too rigidly, it breaks while the feature is fine — and a test that cries wolf gets deleted, which is worse than never writing it. The template you use is what holds the contract in the survivable middle. This piece gives you that template, shows you one real case written all three ways so you can see the middle win, and shows you the one field — Purpose — that lets a future maintainer decide keep-versus-delete without guessing.

If you want the layer beneath this — why we test each behavior at the lowest practical level — read Testing Fundamentals: Why We Test first; this article assumes you already know that you test and focuses on how you write it down so it lasts.

What a test case actually has to capture

Strip a test case to its load-bearing parts and there are five questions, not four. What is being tested? Under what conditions? What actions trigger it? What outcome do you expect? And the one most templates omit: why does this test exist at all?

The first four make the test runnable. The fifth makes it maintainable. Without it, every test case is equally precious and equally disposable — you have no basis to decide whether the failing test on your screen is protecting something real or protecting a behavior the product retired a year ago. Hold onto that fifth question; the whole keep-or-delete decision later in this article turns on it.

Do this now: open your three most-quarantined test cases and check whether any of them answer question five in one sentence. If they don't, you've found why nobody can decide what to do with them.

The working template

Here is the template. It is deliberately small — seven fields, one of which is optional.

## Test Case: [ID] [Short, active-voice title]
**Purpose:**       [One line: why this test exists — what regression it guards against]
**Preconditions:** [The exact state that must hold before step 1]
**Test Data:**     [Specific values, not categories]
**Steps:**
  1. [Single observable action]
  2. [Single observable action]
  3. [Single observable action]
**Expected Result:** [Observable, specific outcome — what you can see or measure]
**Notes:**         [Optional: edge cases, known flake, related cases, links]

Filled in for a real case, it looks like this:

## Test Case: PWD-014 Password reset email arrives within 60 seconds
**Purpose:**       Guards the reset path's delivery SLA — a slow or missing
                   email is our #2 support driver and a login dead-end.
**Preconditions:** An active account, email verified, not currently locked out.
**Test Data:**     Account reset-user+pwd014@sqmail.test
**Steps:**
  1. Request a password reset for the account's email.
  2. Poll the test inbox for a message from no-reply@ourapp.com.
**Expected Result:** A reset email whose subject contains "Reset your password"
                   and a working reset link arrives within 60 seconds of the request.
**Notes:**         Delivery SLA is 60s (product decision, not a hard system limit).
                   Sibling: PWD-015 covers an expired reset link.

Notice what the Purpose line does that a title can't: it names the regression this test is the guard for. When PWD-014 goes red at 2 a.m., the on-call engineer reads one line and knows whether they're looking at a real login dead-end or a slow mail relay in the test environment. Do this now: take one existing test case and write its Purpose line as "Guards ___ against ___." If you can't fill both blanks, you've found a test whose value nobody can currently defend.

One case, three ways — watch the middle survive

(Developed example — composite scenario, drawn from patterns we've seen repeatedly on reset and profile flows.)

Take a single behavior — a password reset email must arrive within 60 seconds — and write it three ways.

Too loose (catches nothing). This is the payments-team failure from the opening, generalized:

Purpose:         Make sure password reset works.
Preconditions:   A user account.
Steps:           1. Reset the password.
Expected Result: It works.

"A user account" — verified or not? Locked or not? "It works" — arrives, or merely sent? Automated, this decays into assert response.status_code == 200. The endpoint returns 200 whether the email contains a reset link, contains nothing, or goes to the wrong address. It passed for two years over a broken feature because it never described the feature — only that a request was accepted.

Too rigid (breaks on cosmetic change). Overcorrect and you get this:

Preconditions:   Logged out, on /login, viewport 1280x800, cookie banner dismissed.
Steps:           1. Click the link with CSS selector a.link-forgot.text-sm.mt-2
                 2. Type into input#email-field-v2
                 3. Click button.btn-primary.rounded-lg labeled exactly "Send Reset Link"
                 4. Wait 3 seconds. Assert <div class="toast toast-success"> is visible.
Expected Result: Toast text reads exactly "Check your email!"

Every one of those selectors is a tripwire on the presentation, not the behavior. Rename btn-primary to btn-brand in a design refresh and it goes red. Change the toast copy from "Check your email!" to "We sent you a link" — a copy edit that improved nothing about correctness — and it goes red. Add A/B test padding and the wait 3 seconds races. This test fails on changes that don't touch whether the email arrives, so its red means nothing, so it gets quarantined, so it gets deleted. The rigidity destroyed the very coverage it was trying to lock down.

Maintainable (the middle). Describe the behavior at the level the user cares about, and pin only what actually defines correctness:

Purpose:         Guards the reset delivery SLA (60s) — a login dead-end otherwise.
Preconditions:   Active account, email verified, not locked out.
Test Data:       reset-user+pwd014@sqmail.test
Steps:           1. Request a password reset for the account.
                 2. Poll the test inbox for a message from no-reply@ourapp.com.
Expected Result: Email with subject containing "Reset your password" and a working
                 link arrives within 60 seconds.

"Request a password reset" survives a button rename, a copy change, a layout reflow, and a move from a modal to a full page — because none of those change what the test is about. It still fails, correctly, if the email doesn't arrive, arrives blank, or arrives in five minutes. It pins the subject substring and the 60-second window because those are the contract, and pins nothing else.

The automated version captures the same contract, with the template's fields living implicitly in the code:

def test_password_reset_email_arrives_within_60s(active_account, test_inbox):
    # Purpose lives in the test name + this comment: guards the 60s reset SLA.
    # Preconditions + Test Data live in the fixtures (active_account, test_inbox).
    request_password_reset(active_account.email)          # Step 1 — behavior, not selectors

    message = test_inbox.wait_for_message(                 # Step 2 — wait for a condition
        from_address="no-reply@ourapp.com",
        timeout=60,                                        # Expected Result: the 60s SLA
    )
    assert "Reset your password" in message.subject        # Expected Result: it's the right email
    assert message.reset_link().is_valid()                 # Expected Result: the link works

The docstring/name is the Purpose. The fixtures are the Preconditions and Test Data. The wait_for_message(timeout=60) is the Expected Result with the SLA baked in — not a blind sleep(60), but a wait on the actual condition, the same discipline that keeps end-to-end tests from becoming the team's biggest pain point. Nothing in this test references a CSS class, so a front-end refactor leaves it untouched; everything in it references the behavior, so a broken email turns it red. Do this now: find your most-quarantined UI test and ask whether it's red because a behavior broke or because a selector moved. If it's the selector, you've found a too-rigid test to rewrite toward this middle.

The Purpose line is the keep-or-delete decision

Here is where that fifth question earns its place. Test suites don't die from tests that fail — they die from tests nobody can decide about. A red test with no stated purpose forces a choice between two bad options: keep it and keep debugging a failure that might mean nothing, or delete it and maybe drop real coverage. Teams resolve that tension by quarantining, and quarantine is where coverage goes to quietly disappear.

Walk a real decision. A profile test, DPL-032, starts failing after a release:

## Test Case: DPL-032 Changing display name updates the header greeting
**Purpose:** Guards the header greeting — it read the raw DB field before,
             and showed "null" for users who cleared their name (SUP-1180).

The header was redesigned this release and no longer shows a greeting at all. Without the Purpose line, DPL-032 is a mystery: is the greeting supposed to be gone, or did we break it? Someone spends an hour in git blame. With the Purpose line, the decision takes thirty seconds: this test exists to guard a specific greeting behavior that was itself a fix for SUP-1180; the greeting was removed by product decision; the regression it guarded can no longer occur; delete it, with a note pointing at the ticket that removed the feature. No quarantine, no mystery, no slow erosion.

Now the mirror case. PWD-014 fails after the same release. Its Purpose says it guards the 60-second reset SLA. The reset feature still exists and still matters. So this red is not disposable — it's the test doing its job, and someone needs to find out why the email is slow. Same symptom (a red test), opposite decision, and the Purpose line is the only field that tells them apart. Do this now: write a one-line rule for your team — "a test whose Purpose describes a behavior the product no longer has gets deleted with a ticket link, not quarantined" — and apply it to your current quarantine list this week.

When to use the full template vs a quick note

Not every check earns seven fields. Matching depth to need is itself part of keeping the suite maintainable — over-documenting a smoke check wastes the same time that under-documenting a compliance test costs you later.

Situation

Full template

Quick note

Critical path (checkout, auth, reset)

Yes — Purpose + SLA pinned, handed to on-call

Never — this is where vagueness ships broken features

Smoke test (does the app boot / core page load)

Overkill

Yes — a one-line title and expected result is enough

Exploratory checklist

No — see exploratory testing as a discipline

Yes — charter + notes, formalize only what you find

Compliance / audit-relevant test

Yes — Purpose cites the control, kept as evidence

Never — the paper trail is the deliverable

Dev-time verification while coding

No

Yes — or skip writing it; the automated test is the record

The pattern under the table: the full template pays for itself exactly when a test outlives the person who wrote it or has to answer to someone outside the team. A smoke test you rerun hourly and a scratch check you throw away in an afternoon don't. Do this now: audit one test directory and flag any critical-path or compliance case running on a quick note — those are the mismatches that bite.

Writing steps and results that don't rot

Two field-level habits separate cases that age well from cases that rot.

Steps describe observable actions, not assertions or internals. Each step is one thing a user could watch you do — "request a password reset," not "call POST /api/v2/reset with a JSON body." Assertions belong in Expected Result, not scattered through the steps, so that a failure points at one place. And each step should be small enough to fail on its own; when the test goes red, the failing step number tells you where. The moment a step names an internal endpoint version or a CSS class, it has bound itself to something that will change for reasons unrelated to correctness.

Expected Results are observable and specific, and their completeness is your coverage. "It works" catches nothing. "A reset email whose subject contains 'Reset your password' and a working link arrives within 60 seconds" catches a missing email, a blank email, a broken link, and a slow relay. The rule is blunt: the test catches exactly what the Expected Result describes and nothing more. If you didn't assert the link works, a broken link passes. This is the same thinking as writing sharp acceptance criteria — Given/When/Then done right is the requirements-side version of the same skill, and a good Expected Result is often a Then clause you can execute.

Do this now: take one test's Expected Result and list what it would let slip through. Every gap you find is a real defect class currently invisible to that test.

Naming and granularity, briefly

Names describe what the test verifies, in active voice: "Sign-in fails after five wrong attempts," not "test_login_2." A good name is a Purpose line's shorter cousin — someone scanning a failed CI run should know what broke without opening the case.

On granularity, the unit-testing maxim of "one assertion per test" doesn't survive contact with integration and E2E work, where a single scenario legitimately needs several assertions — the unit testing best practices guide covers where the one-assertion rule genuinely applies. The portable rule for higher-level cases: one scenario per test case, as many assertions as that scenario needs, and never chain cases so that case B assumes case A already ran. Chained cases fail in cascades and can't be run in isolation, which is the opposite of maintainable. Do this now: find any test case that only passes when another runs first, and give it its own preconditions so it stands alone.

What "maintainable" actually bought you

Maintainable is not a style preference. It's the difference between a suite that gets trusted and one that gets quarantined into uselessness. The loose test cost two years of a broken feature because it described a request instead of a behavior. The rigid test cost the team its only checkout coverage because it pinned presentation instead of contract. The middle template — behavior-level steps, contract-level assertions, and a Purpose line that makes every future keep-or-delete decision a thirty-second read instead of an hour of git blame — is the one that's still running, still trusted, and still going red only when something that matters actually breaks. Write the Purpose line first; everything else is downstream of knowing why the test exists.

Sources

  • The PWD-014 and DPL-032 cases, and the two-year and quarantine timelines, are composite scenarios built from recurring patterns on reset and profile flows, not a single documented incident. They are illustrative, not measured.

  • Selector-stability guidance (prefer behavior/role over CSS/XPath) and condition-based waiting reflect the documented practices of mainstream E2E frameworks such as Playwright and Cypress; see their official testing-best-practices documentation.

Keep learning. This article is part of the Software Testing Foundations path in the ShiftQuality Learning Center.

bottom of page