Given/When/Then, revisited for machine readers
The format was designed to get a business analyst and a developer talking. It is now being parsed by software. That changes what a good scenario looks like.
Somewhere in your backlog is a scenario that looks like this:
Given a user is logged in
When they go to the settings page
Then they should see their settings
It has the shape of rigour. Three clauses, the right keywords, indentation. It would pass a review in most organisations because it looks like the thing you are supposed to write. And it contains no information whatsoever. Strip the keywords and you get “logged-in users can see settings”, which is a restatement of the ticket title with extra ceremony.
I want to be careful here, because Given/When/Then deserves better than a lazy dismissal. It is one of the genuinely good ideas in software process. But it was designed for a particular purpose in a particular era, and the era has changed underneath it in a way that is worth being explicit about.
What GWT was actually for
The format came out of behaviour-driven development, and its real job was social, not technical. It existed to get a business person and a developer to describe the same behaviour in the same sentence, in language the business person could argue with.
That is why the structure is what it is. Given is context, the state of the world before anything happens. When is the single event under test. Then is the observable outcome. The discipline of separating those three is what makes a conversation productive: it forces you to say what the starting state is, which is where most disagreements actually live.
The secondary job was automation, via step definitions that mapped each clause to code. This worked, in the sense that it ran, and it produced a well-documented set of problems: a growing library of step definitions that only one person understood, scenarios that were really unit tests wearing a costume, and the eventual realisation that the business stakeholder who was supposed to read all this had not opened it since the kickoff.
So GWT arrived at the agentic era with a split reputation. Teams who used it for conversation found it valuable. Teams who used it for automation mostly quietly stopped.
Used for conversation
Its real job: getting a business person and a developer to describe the same behaviour in the same words. Found valuable, still is.
Used for automation
Step definitions mapping clauses to code. Ran, produced a brittle glue layer, and most teams quietly stopped.
The new problem
Prose criteria cannot be mechanically resolved to done. A checkbox has a state; a paragraph does not.
The new value
A well-formed Given is a complete description of the world the code must respect. That was always true and rarely exploited.
What changed
Here is the thing that is different now, and it is not subtle.
Your acceptance criteria have a new primary reader, and that reader is a machine that will implement the code rather than execute the scenario. That is a different relationship than the one BDD assumed. BDD’s machine reader was an executor: it ran the scenario against a finished system and reported pass or fail. The new machine reader is a producer: it reads the scenario and writes the system that satisfies it.
An executor punishes ambiguity by failing. A producer resolves ambiguity by choosing, and then builds on the choice.
Old reader: an executor
- Ran the scenario against built code
- Ambiguity produced a failing step
- Cost was a test that told you little
- The vagueness announced itself
New reader: a producer
- Builds the code from the scenario
- Ambiguity is resolved by choosing
- Cost is real code embodying a guess
- The guess is invisible and built upon
That inverts the risk. Under old BDD, a vague scenario meant a step definition you had to write and a test that did not tell you much. Under an agentic flow, a vague scenario means real code encoding a private interpretation. “Then they should see their settings” tells a producer almost nothing, so the producer decides what settings means, and now you have a settings page with the three fields the model considered most typical.
There is a second change. In a lot of tooling, prose criteria including GWT cannot be mechanically resolved to done or not done. A checkbox has a state something can read. A paragraph of Given/When/Then does not, which means the honest classification for it is unverifiable: it can be scored, it can be compared against a diff by a semantic layer with a confidence estimate, but it cannot be settled with certainty, and the right handling is to route it to a person rather than guess. If your entire definition of done is written in GWT, everything routes to a person, which is not automatically wrong but is worth knowing you have chosen.
The three clauses, reconsidered
Given is where the value is, and it is where everyone is lazy.
“Given a user is logged in” is the single most common Given in the industry and it is nearly always insufficient. Logged in as what role? With what data? In what plan tier? A producer reading that will construct the simplest world in which the sentence is true, which is a fresh user with an empty account, which is the least interesting case in your system and the one where most bugs do not live.
Compare:
Given a user on the Team plan with 3 seats, 2 of them occupied
And an outstanding invitation to dana@example.com sent 8 days ago
Now the world has shape. Every one of those facts is a lever the implementation has to respect, and each one is a place where a bug can be specified into existence or out of it. The eight days matters if invitations expire at seven. If your Given does not mention a number, you have probably specified the empty case.
When should be one event, and usually is not.
When they go to the settings page and change their email and save
Three events. Which one is under test? If the assertion fails, which step broke? Producers handle this by implementing all three, which is fine, but the scenario has stopped being a specification of a behaviour and become a script of a session. Scripts of sessions are useful things, they just are not acceptance criteria, and they are terrible at expressing what must be true rather than what happens next.
When they submit the email change form with `new@example.com`
One event, with a literal value in it, so the outcome can reference the literal.
Then must be observable, and “should” is a tell.
The word “should” in a Then clause is a reliable signal that the author has not decided what is observable. “Then the email should be updated” leaves open where: in the database, in the session, on the profile page, in the next email sent. Producers pick one. Frequently they pick the one you did not mean.
Then GET /api/me returns `"email": "new@example.com"`
And a verification email is sent to new@example.com and not to the old address
And until verification, `email_verified` is false and login still works with
the old address
That third clause is a whole feature decision that “the email should be updated” concealed entirely.
Rewriting one properly
Here is a scenario in the state most of them are in:
Scenario: Refund a payment
Given an order that has been paid
When the admin refunds it
Then the customer should get their money back
And the order should be updated
And here it is written for a reader that is going to build from it:
Scenario: Full refund of a captured payment
Given an order `ORD-1001` in state `paid` with a captured charge of 49.99 GBP
And the customer has no other outstanding orders
When an admin with role `support` submits a full refund for ORD-1001
Then a refund of 49.99 GBP is created against the original charge
And ORD-1001 moves to state `refunded`
And exactly one `order.refunded` event is emitted, with `amount_minor: 4999`
And the refund is recorded in `refunds` with the acting admin's user id
And the customer receives the `refund_confirmation` email once
Scenario: Refund attempt on an uncaptured authorisation
Given an order `ORD-1002` in state `authorised` with no capture
When an admin submits a full refund for ORD-1002
Then the authorisation is voided rather than refunded
And ORD-1002 moves to state `cancelled`
And no `order.refunded` event is emitted
Scenario: Provider rejects the refund
Given an order `ORD-1003` in state `paid`
And the payments provider returns 500 on the refund call
When an admin submits a full refund for ORD-1003
Then ORD-1003 remains in state `paid`
And a `refund_attempt` row is written with status `failed` and the
provider error code
And the admin sees the copy in `errors.refund_failed`
And no confirmation email is sent
Three scenarios instead of one. Notice what the rewrite surfaced: the distinction between a capture and an authorisation, which is a domain fact that “an order that has been paid” was hiding, and which is exactly the kind of thing that produces a support ticket six weeks later. Notice the idempotency assertions (“exactly one”, “once”), which are the most valuable clauses in the block and appear in almost no real-world GWT. Notice that the failure scenario asserts what did not happen, twice.
That is a lot of text. It is also, more or less, the test plan, the specification and the reviewer’s checklist in one artefact, and it took less time to write than the meeting where you would otherwise have discovered the capture distinction.
When to use GWT and when not to
GWT earns its keep when the behaviour is stateful and conditional. Refunds, permissions, workflow transitions, anything where the answer depends on what was true beforehand. The Given clause exists precisely to make prior state explicit, so use it where prior state is the interesting part.
It is overhead when the criterion is a property or a constraint. “The export contains no rows outside the selected date range” is a property. Forcing it into Given/When/Then produces “Given a date range, When I export, Then the rows are in the range”, which is three lines to say one thing and reads worse.
It is actively bad for non-functional criteria. “Given a database with 50,000 events, When I request the dashboard, Then it should be fast” is worse than a plain line with a number in it, because the format encourages the aspirational Then.
My rough rule: state-dependent behaviour gets scenarios, everything else gets a checkbox. Mixing formats in one ticket is completely fine and reads better than forcing consistency.
The hybrid that actually works
The practical compromise I have landed on is to write the scenario for thinking and the checklist for gating. The scenario goes in the description, where it does its original job of forcing agreement about the starting state. The acceptance criteria field gets the checkboxes derived from it:
- [ ] Full refund of a captured charge creates one refund for the full
amount and moves the order to `refunded`
- [ ] Refund on an authorised-but-uncaptured order voids instead, order
moves to `cancelled`, no `order.refunded` event
- [ ] Provider 500 leaves the order in `paid`, writes `refund_attempt`
with status `failed`, sends no email
- [ ] Duplicate refund submission produces exactly one refund
- [ ] Non-support roles receive 403 and no refund is created
Same information, in a form that has a readable state. An unticked box at merge time is a fact a machine can act on, which means the merge can be treated as needing review rather than as done. The scenarios above it explain why the boxes are what they are, which is the part that helps a human six months later.
This is not a compromise anyone should feel bad about. The scenario is a thinking tool and the checklist is an interface. They are different artefacts with different jobs, and the mistake is trying to make one do both.
Where this breaks down
The rewritten scenario is long enough that people will not write it. I have to be honest about this: the refund example is three scenarios and twenty-odd lines for a feature that a competent engineer would have understood from the title. Process advice that costs more than it saves gets abandoned within two sprints, and the abandonment is usually total rather than partial. If your team writes thin GWT today, telling them to write this instead will most likely produce no GWT at all. The realistic path is to spend this effort on the small number of tickets where state and money and permissions interact, and leave the rest thin on purpose.
Precision in the Given can over-constrain. Pinning the scenario to ORD-1001 and 49.99 GBP is great for verifiability and slightly bad for generality: it invites an implementation and a test that work for that shape and are quietly wrong for a partial refund, a multi-currency order, or an amount with a rounding edge. Concrete examples make criteria checkable and simultaneously narrow the implementer’s sense of the problem. You need at least one clause that gestures at the general rule, or you get software that passes your examples.
The format tempts you to specify implementation. Once you are writing “a refund_attempt row is written”, you have named a table. That is a specification of internals in an artefact that is supposed to describe behaviour, and if the design changes, your criteria are now wrong in a way that looks authoritative. I do it anyway, because internal state assertions are the strongest thing available, but it is a genuine trade and I would not pretend otherwise.
And BDD’s original failure mode has not gone away. It was always tempting to write scenarios that were really unit tests, and having a machine reader that rewards detail makes that temptation stronger, not weaker. If your scenarios start referencing function names, you have rebuilt the step-definition swamp with a new syntax. The test for whether a scenario is still doing its job is whether the person who wanted the feature could read it and disagree with it. When that stops being true, the format has become decoration.
The takeaway
Given/When/Then was built to make two humans agree about the starting state. That job still matters and nothing has replaced it.
What has changed is that the criteria are now read by something that builds rather than something that executes, so ambiguity produces code instead of a failure. Put numbers and literals in the Given, one event in the When, and only observable assertions in the Then, including at least one assertion about what must not happen. Then convert the whole thing into checkboxes for anything that needs to gate a merge, because prose cannot be settled mechanically and a checkbox can.
The next piece goes straight at that split: checkbox criteria versus semantic criteria, what each one can honestly promise, and why “unverifiable” is a better answer than a confident guess.