Acceptance criteria are the conditions a change must meet before anyone calls it done. When they're good, QA, product and engineering all agree on what "done" means before the first commit. When they're bad, that argument happens in code review.
This post is a set of acceptance criteria examples in the two formats most teams use, a few ambiguous tickets rewritten into testable ones and a sketch of how criteria turn straight into automated tests.
Two formats, two jobs
Scenario style uses Given, When and Then. It fits behavior with a clear trigger and outcome, like a checkout flow or a permission rule.
Rule style is a plain checklist. It fits constraints that don't have a natural sequence, like field limits, layout rules or performance budgets.
Scenario style examples
Here's a password reset feature written in Gherkin.
Feature: Password reset
Scenario: Reset link sent for a known email
Given an account exists for "ana@example.com"
When I request a password reset for "ana@example.com"
Then a reset email is sent to "ana@example.com"
And the link in the email expires after 30 minutes
Scenario: Same response for an unknown email
Given no account exists for "ghost@example.com"
When I request a password reset for "ghost@example.com"
Then I see the message "If that address has an account, we've sent a link"
And no email is sent
Scenario: Expired link
Given I have a reset link that is 31 minutes old
When I open the link
Then I see "This link has expired"
And I am offered a button to request a new one
A few things make these testable. Every Then is observable. Numbers are explicit. The second scenario pins down a security rule, so nobody has to guess whether to reveal which emails exist.
Rule style examples
A profile form, written as a checklist.
- [ ] Display name is required, 2 to 40 characters
- [ ] Leading and trailing spaces are trimmed before saving
- [ ] Bio is optional, max 280 characters, with a live counter
- [ ] Avatar accepts PNG or JPEG up to 5 MB
- [ ] Saving shows a "Profile updated" toast and keeps the page scroll position
- [ ] All fields are reachable and operable by keyboard alone
Each line can be checked as true or false.
Ambiguous tickets, rewritten
These are the kinds of lines that cause rework. Each rewrite says the same thing in a way a test can check.
Before: Search should be fast.
After: Search results render within 300 ms at p95 for a catalog of 50k items.
Before: Handle errors gracefully.
After: If the payment API returns 5xx, show "Payment didn't go through, you
weren't charged" and keep the cart intact.
Before: The table should work on mobile.
After: At widths under 640px, the table collapses into stacked cards with
the order number as each card's heading.
Before: Admins can manage members.
After: Admins can invite, remove and change the role of members.
Removing the last admin is blocked with an inline message.
Before: Support bulk upload.
After: CSV upload accepts up to 10,000 rows. Rows with errors are skipped
and listed in a downloadable report with row number and reason.
The pattern is always the same. Replace adjectives with numbers, replace verbs like "manage" with a list of actions and name what the person sees when things fail.
Turning criteria into tests
Good criteria map almost line for line onto tests. Here's the expired link scenario as a Playwright sketch.
import { test, expect } from "@playwright/test";
import { createResetToken } from "./helpers/auth";
test("expired reset link offers a new one", async ({ page }) => {
const token = await createResetToken("ana@example.com", { ageMinutes: 31 });
await page.goto(`/reset?token=${token}`);
await expect(page.getByText("This link has expired")).toBeVisible();
await expect(
page.getByRole("button", { name: "Send a new link" })
).toBeVisible();
});
And one profile rule as a unit test.
import { describe, it, expect } from "vitest";
import { validateProfile } from "../src/profile";
describe("display name", () => {
it("trims spaces before checking length", () => {
expect(validateProfile({ displayName: " Al " }).ok).toBe(true);
expect(validateProfile({ displayName: " A " }).ok).toBe(false);
});
it("rejects names over 40 characters", () => {
const long = "x".repeat(41);
expect(validateProfile({ displayName: long }).errors).toContain("displayName.max");
});
});
If you can't sketch a test for a criterion in a minute or two, the criterion probably isn't specific enough yet.
Don't forget the non-functional lines
Teams often write criteria for the happy path and skip everything else. Add a line for each of these when it matters.
- Accessibility. Focus order, labels and color contrast.
- Performance. A budget with a percentile, like p95 under 300 ms.
- Security. Who can see or do this, and what happens if they can't.
- Analytics. The event names and properties that should fire.
- Empty and error states. What shows when there's no data or the call fails.
Common mistakes
- Writing the implementation. "Use a Redis cache" is a design choice. State the outcome and let engineers choose.
- Untestable words. Fast, intuitive, user-friendly and robust can't fail a test.
- Criteria written after the code. They turn into a description of what was built, not what was needed.
- Too many scenarios. If a ticket has fifteen, it's probably two or three tickets.
Before the ticket moves to ready
Read each criterion and ask whether a new teammate could write a failing test for it today. If not, rewrite it.
Long criteria often hide a simple rule. A short guide on the habit you need here, simplify text without changing meaning, shows how to pull it out and keep every condition.
When a whole block of criteria reads dense, Rephrase AI can reword it in plainer sentences, and then a side by side read confirms every number and edge case survived.
Clear criteria won't write the code for you. They do make the review a lot shorter.
Top comments (0)