From Requirements to Test Cases
A requirement is usually one fuzzy sentence: "Users must fill in their details before placing an order." Your job is to turn that into a set of concrete checks a machine can run. This is where most of the thinking in QA happens — the automation is the easy part.
> Setup: every example below is a runnable Playwright test against the ShopKart Test Range. The fastest way to run them is to download the [starter kit](https://resources.criodo.com/shopkart/shopkart-playwright-starter.zip) (config + page objects already wired up). Or roll your own: npm init playwright@latest, set baseURL to https://resources.criodo.com, and drop the specs into tests/. The exact config and page objects come in Lesson 3 — for now, focus on the thinking.
Start with the happy path — but don't stop there
Take ShopKart's checkout. The happy path is obvious:
> Log in, add an item, go to checkout, fill in first name / last name / postal code, and the order is placed.
Here's that happy path as a real test. This passes against the live app — the ₹199.80 total is ₹185.00 plus 8% tax, verified:
import { test, expect } from '@playwright/test';
test('checkout: single item shows correct total and confirms', async ({ page }) => {
// log in
await page.goto('/shopkart/login');
await page.locator('[data-test="username"]').fill('student');
await page.locator('[data-test="password"]').fill('crio123');
await page.locator('[data-test="login-button"]').click();
// add the USB-C cable (₹185.00) and open the cart
await page.locator('[data-test="add-to-cart-mi-usb-c-cable"]').click();
await expect(page.locator('[data-test="cart-badge"]')).toHaveText('1');
await page.locator('[data-test="cart-link"]').click();
// checkout
await page.locator('[data-test="checkout"]').click();
await page.locator('[data-test="firstName"]').fill('Ada');
await page.locator('[data-test="lastName"]').fill('Lovelace');
await page.locator('[data-test="postalCode"]').fill('560001');
await page.locator('[data-test="continue"]').click();
// ₹185.00 + 8% tax = ₹199.80
await expect(page.locator('[data-test="summary-total"]')).toHaveText('₹199.80');
await page.locator('[data-test="finish"]').click();
await expect(page.locator('[data-test="complete-header"]')).toHaveText('Thank you for your order!');
});
The happy path proves the feature can work. But bugs live in the paths users take when things go slightly wrong. That's what the rest of this lesson is about.
Equivalence partitioning: don't test the same thing twice
You can't test every possible input. Equivalence partitioning says: group inputs that the system should treat the same way, then test one example from each group.
Take the postal code field. Instead of testing "560001", "110001", "400001" (all the same to the system — valid non-empty strings), split into classes that behave differently:
| Partition | Example | Expected |
|---|---|---|
| Valid, present | 560001 | Accepted |
| Empty | ` (blank) | "Postal Code is required" |
One test per partition. Testing ten more valid postal codes adds cost and catches nothing new.
Boundary-value analysis: bugs hide at the edges
Off-by-one errors, >= vs >, and empty-vs-one-character mistakes cluster at the boundaries of a partition. So test right at the edge.
For a quantity field that must be 1–10, don't test "5". Test the boundaries: 0 (just below min), 1 (min), 10 (max), 11 (just above max). Four tests, each targeting a likely off-by-one bug. (ShopKart's cart doesn't expose a quantity field, but you'll meet this on almost every real cart — remember the edges, not the middle.)
Negative paths: what should fail, and fail gracefully
For every field a user must fill, there's a requirement hiding in the negative: what happens when they don't? ShopKart's checkout enforces three. These are the exact error strings the live app returns:
- Missing first name → Error: First Name is required
- Missing last name → Error: Last Name is required
- Missing postal code → Error: Postal Code is required
You could write three near-identical tests. Don't. That's where data-driven testing comes in.
One test, many cases: data-driven testing
When several cases differ only in their data, express the data as a table and generate one test per row. This is the single highest-leverage pattern for negative-path coverage.
This spec passes against the live app — it produces three separate, independently-reported tests:
import { test, expect } from '@playwright/test';
// Each row is a test case. Adding a case = adding a row, not copying a test.
const missingFieldCases = [
{ name: 'no first name', first: '', last: 'Lovelace', zip: '560001', error: 'Error: First Name is required' },
{ name: 'no last name', first: 'Ada', last: '', zip: '560001', error: 'Error: Last Name is required' },
{ name: 'no postal code', first: 'Ada', last: 'Lovelace', zip: '', error: 'Error: Postal Code is required' },
];
for (const c of missingFieldCases) {
test(`checkout rejects ${c.name}`, async ({ page }) => {
// arrange: get to the checkout form with one item in the cart
await page.goto('/shopkart/login');
await page.locator('[data-test="username"]').fill('student');
await page.locator('[data-test="password"]').fill('crio123');
await page.locator('[data-test="login-button"]').click();
await page.locator('[data-test="add-to-cart-mi-usb-c-cable"]').click();
await page.locator('[data-test="cart-link"]').click();
await page.locator('[data-test="checkout"]').click();
// act: fill only the fields this case provides, then continue
if (c.first) await page.locator('[data-test="firstName"]').fill(c.first);
if (c.last) await page.locator('[data-test="lastName"]').fill(c.last);
if (c.zip) await page.locator('[data-test="postalCode"]').fill(c.zip);
await page.locator('[data-test="continue"]').click();
// assert: the specific error for this case
await expect(page.locator('[data-test="error"]')).toHaveText(c.error);
});
}
Run it and you get three named results:
✓ checkout rejects no first name
✓ checkout rejects no last name
✓ checkout rejects no postal code
Why this beats three copy-pasted tests:
- Each case reports separately. When "no last name" breaks, the runner tells you exactly that — not "one of three checkout tests failed."
- Adding a case is one line. Tomorrow's requirement ("reject a postal code with letters") is a new row, not a new 20-line test.
- The intent is visible. The table is the specification. A reviewer reads the rows and immediately sees what's covered.
The Arrange-Act-Assert shape
Notice every test above has the same three-part shape, and it's worth making explicit:
- Arrange — get the system into the starting state (log in, add item, reach the form).
- Act — do the one thing under test (click continue with this data).
- Assert — check the one outcome (this specific error appears).
One test, one behavior, one reason to fail. If a test needs three "Assert" sections checking unrelated things, it's probably three tests wearing a trench coat.
Turning a requirement into cases — the checklist
For any requirement, walk this list:
- Happy path — the intended success. (1 test)
- Equivalence classes — one input per group that behaves differently.
- Boundaries — the edges of every numeric or length range.
- Negative paths — every "required", every invalid input, every "what if they don't?".
- Prioritize — mark which are E2E (browser) vs which belong in unit/integration (from Lesson 1).
- Data-drive — collapse cases that differ only by data into one parameterized test.
Key takeaways
- The thinking is the work: turning one fuzzy sentence into concrete, prioritized cases is the core QA skill.
- Equivalence partitioning stops you testing the same behavior ten times; boundary-value analysis aims your tests where off-by-one bugs actually live.
- Always cover the negative paths — the graceful failures are as much a requirement as the happy path.
- Data-driven testing (one test, a table of cases) gives per-case reporting, one-line extensibility, and a spec a reviewer can read.
- Keep every test in Arrange-Act-Assert shape: one behavior, one reason to fail.
Next lesson
Our tests work, but they're full of duplicated locators and copy-pasted login steps. When ShopKart changes a data-test` id, we'd fix it in a dozen places. Next we'll build a suite that survives change — proper Page Objects and a real test-data strategy using the ShopKart dataset.