Blog / Test design and QA docs
Test design and QA docs
How to Write Test Cases That a Person Can Run and a Test Can Prove
A test case template with a fully worked example, built so every expected result is something a person can see and a test can assert.

Knowing how to write test cases comes down to a simple standard: two people run the same case on the same build and reach the same verdict, and a script reaches it too. Most templates stop at a list of fields. This guide adds the rule that makes the fields work: every expected result must be something you can observe, and each one must map to exactly one automated assertion. You get a template, a complete guest checkout case, five rewrites of vague expected results, and a Playwright spec that proves the case line by line.
TL;DR: the template in one table
| Field | What to write | Example |
|---|---|---|
| ID | A stable key that never changes or gets reused | CHK-001 |
| Title | Who does what, and the outcome | Guest buys one item with standard shipping |
| Preconditions | State that must be true before step 1 | Cart is empty; shopper is signed out |
| Test data | Every value the tester types or picks | Email ana.guest@example.com |
| Steps | One action each, in order | Click "Add to cart" |
| Expected results | One observable claim per line, with an ID | ER4: The cart badge shows 1 |
| Priority | Impact if this breaks | High |
| Type | Functional, regression, end-to-end, negative... | End-to-end |
| Tags | How you select runs | smoke, checkout |
| Status | Where it is in its life | Draft, Needs review, Active, Deprecated |
Key takeaways:
- Write expected results a person can point at: exact text, a value, a count or a URL.
- One claim per line. If a line contains "and", it is probably two lines.
- Keep preconditions, test data and steps apart. They become setup, fixtures and actions in code.
How to write test cases: the fields that matter
ID. An area prefix and a zero-padded number: CHK-001, AUTH-014. Never renumber and never reuse a retired ID. Spec files, bug reports and release notes will point at it.
Title. Name the actor, the action and the result. "Checkout test 3" tells a reviewer nothing. "Guest buys one item with standard shipping" tells them what passing means.
Preconditions. The state of the world before step 1: signed in or not, which feature flags are on, what is in the cart. The tester does not perform them during the run.
Test data. Every value the tester enters or selects, in one named table. Steps refer to the data by name, so a change means editing one table, not six steps.
Steps. One user action per step, written as an instruction: "Click 'Place order'". Use the labels the user sees, never CSS selectors.
Expected results. What must be visible or true after the step. Vague wording does the most damage here, so it gets its own section below.
Priority, type, tags, status. Priority says what to run first when time is short. Type and tags select a smoke or regression run. Status says whether to trust the case: a Draft is not evidence.
Optional: postconditions (what is true after the run), automated by (the spec that proves the case) and references (the story or requirement ID it covers).
There is no actual result or pass/fail field on purpose. One case is run many times, so the actual result and the verdict belong to each test run, not to the case.
A worked example: guest checkout
Here is a full test case example for a small demo shop. The same shop runs locally in the code later in this post.
CHK-001: Guest buys one item with standard shipping
| ID | Priority | Type | Tags | Status | Automated by |
|---|---|---|---|---|---|
| CHK-001 | High | End-to-end | smoke, checkout | Active | tests/guest-checkout.spec.ts |
Preconditions
- The shopper is signed out.
- The cart is empty (the cart badge shows 0).
- "Blue Ceramic Mug" is in stock at €12.00.
- Standard shipping costs €4.95.
Test data
| Name | Value |
|---|---|
ana.guest@example.com |
|
| Full name | Ana Petrova |
| Address | 12 Harbour Street |
| City | Dublin |
| Postcode | D02 XY45 |
| Card number | 4242 4242 4242 4242 (Stripe's published Visa test card) |
Steps and expected results
| Step | Action | Expected result |
|---|---|---|
| 1 | Open the Blue Ceramic Mug product page | ER1: The heading "Blue Ceramic Mug" is shown. |
| ER2: The price shows €12.00. | ||
| 2 | Click "Add to cart" | ER3: The message "Added to cart" appears. |
| ER4: The cart badge shows 1. | ||
| 3 | Click "Cart" | ER5: The cart lists "1 × Blue Ceramic Mug". |
| ER6: The subtotal shows €12.00. | ||
| 4 | Click "Check out as guest" | ER7: The page heading is "Checkout". |
| 5 | Enter Email, Full name, Address, City and Postcode from the test data, then choose "Standard (€4.95)" | ER8: The total shows €16.95. |
| 6 | Enter the Card number from the test data and click "Place order" | ER9: The heading "Thank you for your order" is shown. |
| ER10: The order number is "ORD-" followed by 6 digits. | ||
| ER11: The page says "We sent a confirmation to ana.guest@example.com". |
Postcondition: the cart badge shows 0.
Its negative partner is short. CHK-002: Checkout rejects an email without a domain. Precondition: the cart holds one Blue Ceramic Mug. Test data: Email ana.guest@. Step: enter the email and click "Place order". ER1: the error "Enter a valid email address" is shown. ER2: the shopper stays on the checkout page.
A test case template to copy
**<ID>: <Actor> <does what> <with what result>**
| ID | Priority | Type | Tags | Status | Automated by |
|---|---|---|---|---|---|
| <ID> | <Low/Medium/High/Critical> | <type> | <tags> | Draft | <spec file or "manual"> |
**Preconditions**
- <state that is true before step 1>
**Test data**
| Name | Value |
|---|---|
| <name> | <value> |
**Steps and expected results**
| Step | Action | Expected result |
|---|---|---|
| 1 | <one user action> | ER1: <one observable claim> |
**Postcondition:** <what is true after the run, if anything>The same test case format in CSV
Spreadsheets and many test management tools import CSV. One row per expected result gives each ER its own cell and ID, so you can filter and sort them in a spreadsheet. In this sample every row repeats the case fields, so a filtered row still makes sense on its own.
ID,Title,Preconditions,Test data,Step,Action,Expected ID,Expected result,Priority,Type,Tags,Status,Automated by
CHK-001,Guest buys one item with standard shipping,Cart is empty; shopper is signed out,Email: ana.guest@example.com,1,Open the Blue Ceramic Mug product page,ER1,"The heading ""Blue Ceramic Mug"" is shown",High,End-to-end,smoke;checkout,Active,tests/guest-checkout.spec.ts
CHK-001,Guest buys one item with standard shipping,Cart is empty; shopper is signed out,Email: ana.guest@example.com,1,Open the Blue Ceramic Mug product page,ER2,The price shows €12.00,High,End-to-end,smoke;checkout,Active,tests/guest-checkout.spec.ts
CHK-001,Guest buys one item with standard shipping,Cart is empty; shopper is signed out,Email: ana.guest@example.com,2,"Click ""Add to cart""",ER3,"The message ""Added to cart"" appears",High,End-to-end,smoke;checkout,Active,tests/guest-checkout.spec.tsQuotes inside a cell are doubled (""), and any cell with a comma or a quote is wrapped in quotes.
Importers differ on cases that span several rows. Some, such as TestRail's, ask you to pick a column, usually the ID or the title, that marks where a new case starts. A repeated value in that column can then mean "new case" rather than "same case". Read your tool's import docs and import a two-row sample before the whole suite.
Expected results you can check
An expected result passes three tests.
- Observable. A person can point at it: text on the screen, a field value, a row count, the URL, a response status code. "The system processes the order" fails: nobody can see processing.
- Specific. It names the exact text, value or count. When the value changes on every run, such as an order number, it states the pattern: "ORD- followed by 6 digits".
- One claim per line. A step can have several expected results, each holding one fact. If a step needs five lines, split the step.
Also say where. "The error appears" is weaker than "The error 'Enter a valid email address' is shown under Email." The step already gives you the when.
Five rewrites from vague to checkable
| Vague | Checkable | The assertion it becomes |
|---|---|---|
| Login works | The URL ends with /dashboard. | await expect(page).toHaveURL(/\/dashboard$/) |
| Item is added to the cart | The cart badge shows 1. | await expect(badge).toHaveText('1') |
| An error message appears | The error "Enter a valid email address" is shown. | await expect(page.getByRole('alert')).toHaveText('Enter a valid email address') |
| The order is created successfully | The order number is "ORD-" followed by 6 digits. | await expect(orderNumber).toHaveText(/^ORD-\d{6}$/) |
| The page loads correctly | The results table shows 20 rows. | await expect(page.getByTestId('result-row')).toHaveCount(20) |
The assertions are fragments: badge and orderNumber stand for locators you define, as in the full spec further down.
The last one is the most common trap. "The page loads" passes on an empty table or an error banner inside a valid layout. Pick a fact that only a working page would show.
The fourth is the second trap. "Successfully" is not observable; an order number is. If the shop can return an order number while the payment failed, add a line that checks the payment state too.
Preconditions vs test data vs steps
These three fields get mixed more than any others. A quick rule:
- If it must be true before the tester starts, it is a precondition.
- If it is a value the tester types or picks, it is test data.
- If the tester does it during the run, it is a step.
A muddled step looks like this: "Log in as admin@shop.test / Passw0rd!, make sure the cart is empty, then add the mug." That line hides a precondition (signed in, empty cart), two pieces of test data, and the one real action.
The split also maps cleanly to code. Preconditions become setup: a fixture, an API call that seeds data, or a saved signed-in state (see Playwright authentication). Test data becomes a constant or a fixture (see Playwright test data). Steps become the actions inside the test.
Keep real secrets and real customer details out of test data. Use accounts that exist only in the test environment and your payment provider's published test cards.
Positive, negative and edge cases: how many is enough
Start with one positive case per user goal: the shortest path that gets a real user what they came for. Then add cases by risk, not by habit.
- Negative cases: one per rule the system enforces, such as a required field, a format check or a permission. Pick one value per class of bad input (missing domain, missing @), not twenty spellings of the same mistake.
- Edge cases: boundaries where code tends to break. Zero and one item, the maximum quantity, rounding in totals.
- Data variations: when only the data and expected values change, write one case with a data table, not five copies.
Priority drives the order: a broken checkout costs money in minutes, a wrong tooltip does not. Tag cases that must pass on every deploy as smoke and the rest as regression. The trade-offs are covered in smoke vs regression testing.
Link each expected result to an automated assertion
When a case is automated, make the link explicit. Each expected result maps to one assertion, and the assertion carries the ID.

Here is the spec for CHK-001 and CHK-002. It targets a demo shop: four static HTML pages and a small script in a site folder, served by Python's built-in web server, so the test never touches a real store. Download the demo shop project (the site folder plus the two files below) to run it as shown. To run it against your own app instead, change baseURL and swap the test IDs and labels for yours. The spec uses test.step so the report shows the same numbered steps as the case, tags for smoke and regression, and the optional message argument of expect for the ER ID.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: { baseURL: 'http://127.0.0.1:4173' },
webServer: {
command: 'python3 -m http.server 4173 --bind 127.0.0.1 --directory site',
url: 'http://127.0.0.1:4173/product.html',
reuseExistingServer: !process.env.CI,
},
});On Windows, python3 may not be on the path. Use python or py in webServer.command instead.
// tests/guest-checkout.spec.ts
import { test, expect } from '@playwright/test';
// Test data for CHK-001. Fake values only; the card is Stripe's published Visa test card.
const guest = {
email: 'ana.guest@example.com',
name: 'Ana Petrova',
address: '12 Harbour Street',
city: 'Dublin',
postcode: 'D02 XY45',
card: '4242 4242 4242 4242',
};
test('CHK-001 Guest buys one item with standard shipping', { tag: ['@smoke', '@checkout'] }, async ({ page }) => {
await test.step('1. Open the Blue Ceramic Mug product page', async () => {
await page.goto('/product.html');
// ER1: The heading "Blue Ceramic Mug" is shown.
await expect(page.getByRole('heading', { name: 'Blue Ceramic Mug' }), 'CHK-001 ER1').toBeVisible();
// ER2: The price shows €12.00.
await expect(page.getByTestId('price'), 'CHK-001 ER2').toHaveText('€12.00');
});
await test.step('2. Click "Add to cart"', async () => {
await page.getByRole('button', { name: 'Add to cart' }).click();
// ER3: The message "Added to cart" appears.
await expect(page.getByRole('status'), 'CHK-001 ER3').toHaveText('Added to cart');
// ER4: The cart badge shows 1.
await expect(page.getByTestId('cart-count'), 'CHK-001 ER4').toHaveText('1');
});
await test.step('3. Click "Cart"', async () => {
await page.getByRole('link', { name: /Cart/ }).click();
// ER5: The cart lists "1 × Blue Ceramic Mug".
await expect(page.getByRole('listitem'), 'CHK-001 ER5').toHaveText('1 × Blue Ceramic Mug');
// ER6: The subtotal shows €12.00.
await expect(page.getByTestId('subtotal'), 'CHK-001 ER6').toHaveText('€12.00');
});
await test.step('4. Click "Check out as guest"', async () => {
await page.getByRole('link', { name: 'Check out as guest' }).click();
// ER7: The page heading is "Checkout".
await expect(page.getByRole('heading', { name: 'Checkout' }), 'CHK-001 ER7').toBeVisible();
});
await test.step('5. Enter the shipping details and choose Standard shipping', async () => {
await page.getByLabel('Email').fill(guest.email);
await page.getByLabel('Full name').fill(guest.name);
await page.getByLabel('Address').fill(guest.address);
await page.getByLabel('City').fill(guest.city);
await page.getByLabel('Postcode').fill(guest.postcode);
await page.getByLabel('Standard (€4.95)').check();
// ER8: The total shows €16.95.
await expect(page.getByTestId('order-total'), 'CHK-001 ER8').toHaveText('€16.95');
});
await test.step('6. Enter the card number and click "Place order"', async () => {
await page.getByLabel('Card number').fill(guest.card);
await page.getByRole('button', { name: 'Place order' }).click();
// ER9: The heading "Thank you for your order" is shown.
await expect(page.getByRole('heading', { name: 'Thank you for your order' }), 'CHK-001 ER9').toBeVisible();
// ER10: The order number is "ORD-" followed by 6 digits.
await expect(page.getByTestId('order-number'), 'CHK-001 ER10').toHaveText(/^ORD-\d{6}$/);
// ER11: The page says "We sent a confirmation to ana.guest@example.com".
await expect(page.getByText(`We sent a confirmation to ${guest.email}`), 'CHK-001 ER11').toBeVisible();
});
// Postcondition: the cart badge shows 0.
await expect(page.getByTestId('cart-count'), 'CHK-001 postcondition').toHaveText('0');
});
test('CHK-002 Checkout rejects an email without a domain', { tag: ['@regression', '@checkout'] }, async ({ page }) => {
// Precondition: the cart holds one Blue Ceramic Mug.
await page.goto('/product.html');
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
await page.goto('/checkout.html');
await page.getByLabel('Email').fill('ana.guest@');
await page.getByRole('button', { name: 'Place order' }).click();
// ER1: The error "Enter a valid email address" is shown.
await expect(page.getByRole('alert'), 'CHK-002 ER1').toHaveText('Enter a valid email address');
// ER2: The shopper stays on the checkout page.
await expect(page, 'CHK-002 ER2').toHaveURL(/\/checkout\.html$/);
});Run it with npx playwright test, or only the smoke case with npx playwright test --grep @smoke. Both cases pass against the demo shop on Playwright 1.61.
Now break ER8 on purpose: open checkout with an empty cart and no shipping choice, then assert the €16.95 total. The report leads with the ID you gave the assertion (first lines of the report):
Error: CHK-001 ER8
expect(locator).toHaveText(expected) failed
Locator: getByTestId('order-total')
Expected: "€16.95"
Received: "€0.00"
Timeout: 5000msWhoever triages that failure opens CHK-001, reads ER8, and knows what the product was supposed to do, without reverse-engineering intent from a locator.
These are web-first assertions: toHaveText and toBeVisible retry until the condition holds or the timeout ends (5 seconds by default, per the Playwright assertions docs). The demo shop waits a moment before "Added to cart" appears, and the test passes without a manual wait. More in Playwright assertions.
Keep test cases true when the product changes
A case that no longer matches the product is worse than no case: it makes people distrust the rest.
- The ID is the link. A text search for
CHK-001 ER8finds both the case line and the assertion. - Change both in one pull request. When a copy change breaks a text assertion, update the expected result and the assertion together, and let review see both.
- Use status on purpose. New cases start as Draft or Needs review; only a reviewed case is Active. Mark cases for removed features Deprecated instead of deleting them, so old bug reports still resolve.
- Mark what is not automated. Some results can't be checked in the browser, such as "the confirmation email arrives". Mark them manual, or check them another way (a test inbox, an API call). Otherwise people assume the green run covered them.
A checklist before you mark a case Active
- The title names the actor, the action and the outcome.
- Preconditions describe state only; all typed values live in the test data table.
- Each step is one action, using labels the user sees.
- Each expected result is observable, specific, one claim and has an ID; changing values are patterns.
- Every expected result maps to one assertion, or is marked manual.
- Someone other than the author has run it once from the written text alone.
How Blendra writes test cases from a recording
Blendra records a flow in Chrome and can draft the test case from that recording (test cases are called test docs in the app). Each Generate uses one AI request from your workspace's monthly allowance; editing a case, changing its status and exporting do not.
The draft uses the fields above, plus a description and an overall expected outcome. Every line is labelled Observed, Inferred or Not specified, so a reviewer knows which lines to check hardest. When the generated test runs a check after a step, each check's sentence becomes an expected result of the step it follows, so the case and the code say the same thing. Passwords, card numbers and similar values are replaced with [REDACTED] before anything is sent to the AI.
A new draft starts as Needs review, not Active. If the recorded actions, checks or start page change later, the case is marked Out of date and says what changed. You can export a project's test cases as CSV, Markdown or PDF. The details are in the AI QA documentation docs.
FAQ
What is the difference between a test case and a test scenario?
A scenario is a one-line statement of what to test: "A guest can check out." A test case is the runnable version: preconditions, data, steps and expected results. One scenario usually becomes several cases, such as the happy path, a rejected email and a sold-out item.
How detailed should a test case be?
Detailed enough that a new team member can run it without asking anyone, and no more. Name every value and expected result exactly, and skip what any user knows, such as how to open a browser. If a reader would have to guess, add detail.
Should I write test cases in Gherkin?
Use Gherkin (Given/When/Then) if your team runs it with a BDD tool such as Cucumber, or if stakeholders review cases in that form. The rules here still apply: one observable claim per Then line. Otherwise, a plain step table is easier to read and keep current.
Do I still need written test cases if everything is automated?
Often, yes. People outside engineering (product, support, auditors) read cases, not specs. Some checks stay manual. And a written case states intent: when a test fails, the case tells you what the product was meant to do.