How to write test cases worth a second run

Most guides on how to write test cases explain the template. What makes a case worth running twice is an expected result a stranger can check without you.

Four clear glass test cards cascade toward a larger orange result panel

A test case template asks for an identifier, a title, preconditions, steps and an expected result, and a first-time writer fills the boxes in that order. The order is backwards. A case is worth writing only if it is worth running twice, and what decides that is the last box: an expected result a stranger can check without you.

The expected result is the case and the steps reach it

A test case is a prediction. Somebody who knows the requirement writes down what the software will do under a stated condition, and a run turns the prediction into a verdict. Everything else on the template exists to get a runner to the point where the prediction can be checked.

That is why writing the steps first produces the cases that get abandoned. The writer walks through the feature, records what they did, and then writes down what they saw as the expected result. A case built that way is a description of the current build, and it passes by definition, because the build has become its own test oracle. It passes next release for as long as the build still does what the writer saw, and when the build changes it cannot say whether the change was a defect, because nothing in it records what was required.

The alternative is to write the expected result from the requirement before opening the build, and then write the fewest steps that make the result reachable. “The order total shows the discounted price after a valid code is applied” is a prediction that can be wrong. “Click Apply and observe the total” is not a prediction at all.

Where the requirement does not say what should happen, the writer finds out before writing the case, because a guess written into the expected result becomes the specification the moment somebody else files against it. The gap between expected and actual result is the whole content of a failed run, and an expected result that was a guess produces a defect report about the wrong thing.

A case worth keeping gives a stranger the same verdict

The author running their own case on the day they wrote it is a rehearsal. The case earns its keep when a different person runs it weeks later, against a build the author has not seen, and gets a verdict that means the same thing. What breaks that is rarely the steps.

What the second runner needsWhat a first draft saysWhat survives a second run
The starting state“Log in”Which account, which role, and what data already exists, written as a precondition
The data“Enter a valid code”The exact value, or the rule that produces one, and where it lives
The verdict“It works”One observable outcome, stated so two people would agree whether they saw it
The scope“Check the checkout”One condition per case; a second condition is a second case
The environmentNothingThe browser, the build and the environment the verdict is claimed for

The second row produces the quiet failures. “A valid code” means the code the author had open, and months later that code has expired, so the case fails for a reason that has nothing to do with the feature. Test data belongs in the case, or in a named fixture the case points at, never in the author’s head.

The fourth row is the one first drafts get wrong most often. A case that checks five conditions has one verdict box, and when it fails nobody can say which of the five did. Our test case template asks for an objective that fits in one sentence for that reason, since a case that needs two sentences is two cases, and it keeps the preconditions in their own field so they are not mistaken for steps.

Which cases to write when you cannot write all of them

A feature with three inputs and a date has more combinations than a team will ever run. The happy path is the case everybody writes first and the one least likely to find anything, because it is the path the developer walked before handing the build over. The cases worth the second run cluster at the edges of what the requirement says.

TechniqueWhere it earns a caseAn example from a discount code
Happy pathOnce per flow, as the case the others are measured againstA valid code on an in-stock basket lowers the total
Boundary valuesWherever the requirement states a limitA code valid until midnight, applied at the minute before and the minute after
Decision tableWhere two or more conditions combine to decide the outcomeCode valid, basket over the minimum, customer already on a discount
State transitionsWhere the same action means something different depending on what came beforeApplying a code to an order that has already been submitted
Negative pathWherever the requirement says what must not happenAn expired code leaves the total unchanged and says why

Boundary value analysis is the cheapest row to derive cases from, because a limit is the one place a requirement is precise enough to be wrong about. A flow with no combining conditions gets no decision table.

An edge case nobody predicted cannot be written in advance; it is found by exploring around the scripted cases and added afterwards.

A failed run is the output a case exists to produce

A passing run produces a tick. A failing run produces the artefact the exercise exists for: the expected result beside what was observed, on a stated build, in a stated environment. Most templates end at an actual-result field, and the first-time writer fills it with “did not work”.

What separates a failed run that gets fixed from one that comes back irreproducible is what was captured at the moment of failure. The console error behind a blank screen, the request that returned an error status, the exact browser build: none of it is in the case, and a runner working from a checklist cannot be expected to open the developer tools mid-run and copy the right lines out. The page under test has to do that work. When the pages under test carry evidence capture, the runner files from the failing step and the report brings the console output, the requests that failed with their statuses and the browser of that run along with it, so the actual-result field holds what the page did instead of what the runner remembers of it.

One check is cheap: run the new case against the build as it was before the feature, or with the expected result deliberately wrong, and watch it fail. A case that passes against the old build was never testing the feature.

The case expires when the requirement moves

A test case is a copy of the requirement, and a copy does not move when the original does. When the product owner changes the discount rule, the case that predicted the old rule starts failing. A runner who trusts the case files a defect against correct behaviour. A runner who trusts the build edits the expected result to match what they saw, and the case stops predicting anything. Both runners did their job, and neither could have known which was right.

That decision cannot be delegated to whoever happens to run the case. It belongs to somebody who knows the requirement, and it has to be made every time the requirement moves, so a change to a requirement is a change to the case set, reviewed at the same time, by the same people. Every case carries the requirement it was written from so that the review can find it.

A suite nobody re-reads decays into a description of the build, and a description of the build passes by definition. That is the failure of writing the steps first, arrived at slowly.

Common questions

What should a test case contain?

An identifier, the requirement it was written from, the preconditions including the account and data the run starts with, the exact test data, the steps as actions, each paired with what should be observable after it, an expected final state stated so two people would agree whether they saw it, and the build and environment the result is claimed for. The expected result is the part that decides whether the case is worth running.

How many steps should a test case have?

As few as reach the condition being checked, each one an action paired with what should be observable after it, so that a failure has a location. A case that needs twenty steps usually has a precondition hiding in the first fifteen; move that setup into a fixture or a prerequisite case, and the steps that remain are the ones that matter.

Should the expected result be written before the steps?

Writing the expected result first, from the requirement, keeps the case a prediction rather than a description of the current build. Steps written first tend to record what the writer saw, and a case built that way passes by definition. The expected result is what a stranger checks, so it is the field to get right.

How do you check that a new test case is testing the feature?

Run it against the build from before the feature existed, or with the expected result deliberately wrong, and watch it fail. A case that passes against the old build is describing the build rather than the requirement, and it will pass next release for the same reason. The check takes a minute and it is the only proof that the case can fail at all.

How do you know which test cases are worth writing?

Write the happy path once, then a case at every limit the requirement states, then the combinations where conditions interact to change the outcome. A case is worth writing if a different runner, weeks later, gets a verdict that means the same thing; a case only its author can run is a note to self.

What happens when a test case fails?

The failed run becomes a defect report: the expected result beside what was observed, the build, the environment and whatever the page produced at the moment of failure. The case identifier goes on the ticket, and the case itself stays unchanged until somebody decides whether the build or the requirement was wrong.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">