Acceptance Criteria: Given-When-Then Done Right
- Shawn West
- Mar 11
- 8 min read
Updated: Aug 10
A discount-code feature passed its acceptance criteria, passed review, passed QA — and still shipped a bug that briefly paid customers to place orders. The criteria weren't wrong. They were silent on the one condition that mattered. Given-When-Then's real job isn't to format that silence more neatly; it's to make the silence impossible. Here's the mechanism, and one ticket walked from a vague line to the scenarios that would have caught it.
The acceptance criterion read, in full: "When a customer enters a valid discount code, the discount is applied to their order." It was reviewed. Everyone agreed it described the feature. A developer built it, a tester confirmed that entering SAVE20 took twenty percent off, and the story was accepted and closed. By every process the team had, the work was done.
Three weeks later, a $20-off code landed on a $15 cart. The discount applied — exactly as written — and produced an order total of negative five dollars. Checkout accepted it. For a few hours, a handful of customers were effectively being credited to shop, until someone watched the payments dashboard go the wrong direction and pulled the code.
Nobody wrote a bug. The code did what the criterion said. The defect wasn't born in the implementation — it was born in the acceptance criterion, in a condition no one had written down: what happens when the discount is larger than the thing it's discounting? That question never got asked, because the format the criterion lived in never forced anyone to ask it. This is the discovery-first pattern in miniature: the failure traces not to bad code but to an acceptance condition the team never surfaced. And it's the reason to care whether your Given-When-Then is doing real work or just looking tidy.
What the format is actually for
The mechanics are the easy part. A scenario has three moves:
Given [the state that is true before anything happens]
When [the single action that triggers the behavior]
Then [the observable result you can check]
Most explanations stop there, at grammar, and the grammar is genuinely useful — it separates precondition from action from outcome, which is more than "sign-in works" ever manages. But grammar is not why Given-When-Then earns its place, and a team can follow every rule of it and still write the discount criterion that shipped the bug.
The reason the format matters is what it forces you to decide. A weak criterion is one that can't fail: "the discount is applied" has no state that makes it false, so there is nothing to disagree about, nothing to discover, nothing a test could catch you on. A real Given-When-Then scenario is an assertion with a truth value — it names a precondition specific enough to reproduce, an action precise enough to perform, and an outcome concrete enough to be wrong. The instant you try to write the Then for a $20 code on a $15 cart, you have to type a number. Is it Then the order total is $0.00? Or Then the order total is -$5.00? Or Then the code is rejected with "order minimum not met"? You cannot finish the sentence without making the decision that the vague version let everyone skip.
That is the mechanism. Given-When-Then works when it drags the edge cases into the open before the code exists, and becomes the testable definition of correct once it does. The format is not documentation of a decision already made — it's the forcing function that makes the decision get made at all, at the cheapest possible moment to make it: on a ticket, in a refinement session, before a line is written. A criterion and a test are the same artifact caught at two different times; this is the seam where acceptance criteria and the definition of done meet the tests that verify them. The bad AC and the good AC differ in exactly one respect — what they force you to decide out loud.
One ticket, walked all the way
(Developed example — composite scenario.)
Take the discount feature from the top, the way it actually arrives. The ticket is one line:
As a customer, I can apply a discount code at checkout.
That sentence is a wish, not a criterion. It can't fail — there is no state of the world in which "I can apply a discount code" is observably false, which is the tell that it isn't a criterion at all yet. The work of acceptance criteria is turning that wish into a set of claims each of which can be false, and the discipline of Given-When-Then is that it won't let you write a claim without naming the state it depends on.
Start with the happy path, because you have to, and because writing it exposes the first buried question:
Scenario: Valid code reduces the order total
Given a cart with a subtotal of $80.00
And an active code "SAVE20" for 20% off
When the customer applies "SAVE20"
Then the discount shown is $16.00
And the order total is $64.00
Writing that forces the first decision the one-liner hid: twenty percent of what? Subtotal, or subtotal-plus-shipping, or post-tax? You cannot write Then the discount shown is $16.00 without answering it. The vague criterion let three people picture three different answers and all nod. The scenario makes them pick one.
Now the branch that actually shipped the bug — the one no one drew:
Scenario: Discount is larger than the order subtotal
Given a cart with a subtotal of $15.00
And an active code "SAVE20FLAT" for $20 off
When the customer applies "SAVE20FLAT"
Then the discount applied is capped at $15.00
And the order total is $0.00, never negative
That Then — never negative — is the entire defect, written as one line, weeks before it would have cost anything. It didn't require a genius. It required the format to ask "what's the outcome?" about a precondition (subtotal below discount) that the happy path never visits. The rest of the ugly cases come the same way, each one a precondition the wish glossed:
Scenario: Expired code is rejected
Given an active cart
And a code "SPRING" that expired yesterday
When the customer applies "SPRING"
Then the code is rejected with "This code has expired"
And the order total is unchanged
Scenario: A single-use code cannot be reused
Given a customer who has already redeemed "WELCOME10" on a past order
When the customer applies "WELCOME10" again
Then the code is rejected with "This code has already been used"
And the order total is unchanged
Scenario: Removing items drops the cart below the code's minimum spend
Given a $60.00 cart with "SAVE20" applied, requiring a $50.00 minimum
When the customer removes an item, leaving a $40.00 subtotal
Then the discount is removed
And the customer is shown "Add $10.00 to keep your discount"
Six scenarios, and only the first is the one the original ticket described. The other five are the feature's real surface area — expiry, reuse, the floor at zero, the minimum-spend interaction that changes after the code is applied. Every one of them is a precondition the one-line criterion contained but never named, and every one is a defect the team would otherwise have discovered in production, on the payments dashboard, the expensive way. This is the same instinct that separates a happy-path demo from real user acceptance testing: the value is in the paths you deliberately went looking for.
The payoff is that these scenarios are now the tests — not a description of tests, the tests themselves. Each maps to a case with almost no translation:
Scenario | Test asserts | Where it runs |
Valid code reduces total | $80 cart + SAVE20 → discount $16, total $64 | Unit / service |
Discount exceeds subtotal | $15 cart + $20 code → total $0.00, not negative | Unit — the boundary |
Expired code rejected | Expiry in the past → rejected, total unchanged | Unit / service |
Single-use reuse blocked | Second redemption → rejected | Integration (needs history) |
Below minimum after edit | Remove item → discount removed, prompt shown | Integration / UI |
The scenario that would have caught the shipped bug — row two — is a single unit test on a boundary. It costs seconds to run and would have failed the day the feature was built. That is the whole argument for writing the criterion properly: the defect and the test that catches it are the same sentence, and Given-When-Then is what makes you write the sentence early. (If your team runs these as executable specifications through a BDD tool, the mapping is literal rather than manual — worth doing well, and worth doing without the ceremony that BDD cargo cults mistake for the point.)
How to spot a criterion that can't catch anything
You don't need the whole backlog rewritten to find the risk. Weak acceptance criteria have a consistent, checkable shape, and you can run this pass on your own tickets this week. Take any acceptance criterion currently marked ready and hold it against three questions:
Can it fail? Try to write one scenario in which the criterion is observably violated. If you can't — if there is no state of the world that makes "the discount is applied" false — it isn't a criterion, it's a restatement of the title. A real criterion has a truth value; a wish doesn't.
Does it name a number or a specific state? Weak criteria are adjective-shaped: valid, correctly, appropriately, as expected. Strong ones carry a boundary — a $0.00 floor, a $50 minimum, an expiry date, a redemption count. No number and no named state means no edge, and the edges are where defects live.
Does it name a single non-happy path? If every scenario under a story is the golden path, the story's real risk is entirely unwritten. Count the branches: one happy path and zero others is not "simple," it's untested by design.
A criterion that fails all three isn't close to ready — it's the discount ticket before anyone thought about $15 carts. A criterion that passes all three has already surfaced its edges, which means the expensive discovery has happened on a ticket instead of in production. That relocation of discovery, from the dashboard to the refinement session, is the entire return on writing these well, and it's why testing starts long before code runs.
A few habits keep the format honest once you're writing it. Keep the When to one action — if it contains an "and then," it's two scenarios wearing one. Keep the Then observable, and out of the implementation: "the customer has an active session," not "a row is inserted into the sessions table" — how it's stored is engineering's call, and locking it into the criterion just makes the criterion brittle. And don't force the grammar where it fights the content: a non-functional target ("search returns in under 500ms at the 95th percentile") or a permission matrix is clearer as a plain, numbered statement than as a contorted Given. The format is a tool for forcing decisions into the open, not a tax to pay on every line.
Rewrite one ticket this week
Don't reformat your backlog into Given-When-Then. That produces tidier wishes, not stronger criteria, and it's how the format gets a reputation as ceremony. Do this instead: take the single riskiest story in your current sprint — the one touching money, permissions, data you can't easily un-corrupt, or an irreversible action — and run its acceptance criterion through the three questions above. Where it can't fail, write the scenario that would make it fail. Where it names no number, find the boundary and name it. Where it has only a happy path, write the two ugliest edge cases you can think of, and finish every Then.
If that exercise surfaces even one outcome nobody had decided — one $15-cart, one expired code, one discount with no floor — then you've moved a defect out of production and onto a ticket, which is the cheapest place it will ever be. That is the difference the format is actually for. Not neater criteria: criteria that force the decision the shipped bug was hiding inside.
Sources
The discount-code failure is a composite scenario drawn from common e-commerce defects, not a specific documented incident; figures are illustrative.
Given-When-Then and Gherkin conventions follow the structure defined in the Cucumber project's Gherkin reference (cucumber.io/docs/gherkin).
Related reading
Keep learning. This article is part of the Software Testing Foundations path in the ShiftQuality Learning Center.


