End-to-end tests with Playwright
End-to-end tests are the most expensive kind to write, by far the most expensive to keep, and the only kind that can tell you the shop works.
So there are seven of them, and each covers a path where a failure means the shop cannot take money.
The one test that matters
test("cart, sign-in and checkout", async ({ page }) => {
await page.goto("/products/toor-dal");
await page.getByRole("button", { name: "Add to cart" }).click();
await expect(page.getByRole("button", { name: /Added/ })).toBeVisible();
await page.getByRole("link", { name: /^Cart/ }).click();
await expect(page.getByRole("heading", { name: "Your cart" })).toBeVisible();
await page.getByRole("button", { name: "Increase quantity" }).click();
await expect(page.locator("[aria-live=polite]")).toHaveText("2");
await page.getByRole("link", { name: "Sign in to check out" }).click();
await page.getByLabel("Email").fill(CUSTOMER.email);
await page.getByLabel("Password").fill(CUSTOMER.password);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Checkout" })).toBeVisible();
await expect(page.getByText(/Toor Dal/)).toBeVisible();
await page.getByLabel("Flat, building, street").fill("Flat 402, Sai Residency, Lane 5");
await page.getByRole("button", { name: "Place order" }).click();
await expect(page).toHaveURL(/\/orders\/KS-/);
await expect(page.getByRole("heading", { name: /^KS-/ })).toBeVisible();
});
Every piece of this course is in those twenty lines: the catalogue, the cart cookie, a server action, the login merge, a transaction that claims stock and a delivery slot, and an order.
If that test is green, the shop works. If it is red, nothing else matters.
Note what it asserts along the way — Toor Dal is still visible after the
sign-in. That is the login merge from module 12, and losing it is a lost sale.
Asserting it inside the purchase flow is cheaper than a test of its own and
catches it in the situation where it matters.
Playwright waits, so never sleep
await expect(page.getByRole("heading", { name: "Checkout" })).toBeVisible();
That retries until it passes or times out. Every Playwright assertion does.
So waitForTimeout is almost always wrong: too short and it is flaky, too long
and the suite crawls, and it is always both on somebody else's machine. If you
are reaching for it, you have not found the thing to wait for.
The race that reads as a bug
This one is from writing these very tests, and it cost half an hour:
// Wrong
await page.getByRole("button", { name: "Sign in" }).click();
await page.goto("/admin");
expect(response?.status()).toBe(404);
The click starts a server action that sets a session cookie and redirects.
page.goto on the next line does not wait for it. The session cookie is not
set, so /admin redirects to the login page — and page.goto follows
redirects and reports the final response, which is 200.
The test failed claiming the admin area was reachable by anyone. It was not. The guard was fine; the test never signed in.
async function signIn(page, who) {
await page.goto("/account/login");
await page.getByLabel("Email").fill(who.email);
await page.getByLabel("Password").fill(who.password);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible();
}
Wait for something only a signed-in visitor sees. Now the helper cannot return until the sign-in has actually happened.
Two lessons, and the second is the bigger one. Never navigate on the line after an action that redirects. And: an end-to-end failure blames the application by default, and is very often the test.
Selectors that are not accidentally ambiguous
Another one from writing these:
await expect(page.getByText("2")).toBeVisible();
strict mode violation: getByText('2') resolved to 2 elements:
1) <span aria-live="polite">2</span>
2) <dd>₹230.00</dd>
"2" appears in the total as well as the quantity. Playwright's strict mode refuses rather than picking one, which is exactly right — a locator that silently matched the first would pass today and assert the wrong thing next week.
await expect(page.locator("[aria-live=polite]")).toHaveText("2");
The live region is the element that announces the quantity to a screen reader, so it is both the precise locator and the accessible one. Those tend to coincide, which is not a coincidence.
The same happened at the end of the purchase: getByText(/Order placed|Delivery/)
matched five elements — a badge, a progress step, a history entry and two more.
Replaced with the order-number heading.
When a locator matches more than one thing, the fix is a better locator, never
.first().
Status codes, not just pages
test("answers a missing product with a real 404", async ({ page }) => {
const response = await page.goto("/products/not-a-real-product");
expect(response?.status()).toBe(404);
});
This is the regression test for decision 0012 — the soft 404, where a
loading.tsx above a route flushed the response before notFound() could run,
so a missing product rendered the right page with a 200 status and Google
indexed it.
A test asserting the page would have passed throughout. Only the status line shows it.
The admin test does the same:
const response = await page.goto("/admin");
expect(response?.status()).toBe(404);
await expect(page.getByText(/could not find that/i)).toBeVisible();
Both: the status and the content. Either alone can be right while the other is wrong.
Two projects, because the customer is on a phone
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", use: { ...devices["Pixel 5"] } },
],
The same seven tests, run twice. A Pixel 5 at 393px is closer to what a kirana shop's customers actually hold than any desktop.
It doubles the runtime, from about eight seconds to fifteen, and it catches the class of bug where a control is off-screen or behind something at narrow widths — which is invisible on a laptop and is most of your traffic.
One worker, and no shared state
workers: 1,
fullyParallel: false,
These tests buy real stock. Two workers compete for the last bag of atta and one loses, which looks exactly like flakiness and is not.
if (await pack.count()) {
await pack.click();
…
}
And the admin test does not assume an order is waiting to be packed. Depending on what an earlier test left behind is how a suite becomes order-dependent — passing locally and failing in CI, where the order can differ.
What not to test here
Everything already covered more cheaply.
The totals are unit tested. The state machine is unit tested. Stock release is API tested against a real database. None of that is repeated in a browser, where each assertion costs a thousand times more and the failure message is worse.
An end-to-end test that fails should mean "the shop is broken", not "a number is wrong".
When it fails
trace: "retain-on-failure",
screenshot: "only-on-failure",
A trace is a full recording — every DOM snapshot, every network request, the console — that you can step through afterwards:
npx playwright show-trace test-results/…/trace.zip
It is the only thing that explains a failure in CI that does not reproduce locally. Kept only for failures, because keeping them for passes fills a disk with recordings nobody watches.
In CI
- run: npx playwright install --with-deps chromium
working-directory: apps/web
- name: Start the application
run: |
npm run start --workspace=apps/api &
npm run start --workspace=apps/web &
npx wait-on http://localhost:3001/api/categories http://localhost:3000/products
- run: npm run test:e2e --workspace=apps/web
wait-on, not sleep 10. The same rule as inside the tests: wait for the thing,
not for a duration.
And the whole workflow runs cheapest-first — typecheck, unit tests, API tests, builds, and only then a browser download and Playwright. A type error should not wait behind a 94 MB download.
Check your work
Why so few: they are the most expensive to write, keep and diagnose, so each one covers a path where failure means the shop cannot take money.
Why never waitForTimeout: Playwright assertions retry, and a fixed sleep is
both too short and too long on somebody else's machine.
The redirect race: page.goto after a click that redirects does not wait for
it, and it follows redirects and reports the final response — so a failed
sign-in reads as a missing guard.
Why strict mode is right: a locator matching two things would pass today and assert the wrong one next week.
Why the live region was the right locator: it is what announces the quantity, so it is precise and accessible at once.
Why assert status codes: the soft 404 renders a perfect page with a 200, and only the status line shows it.
Why a mobile project: a control off-screen at 393px is invisible on a laptop and affects most of the traffic.
Why one worker: the tests buy real stock and would compete.
Why the admin test guards with count(): depending on what an earlier test
left behind makes the suite order-dependent.
Why traces only on failure: they are the only thing that explains a CI-only failure, and keeping them for passes fills a disk.
Practice
- Run the Playwright suite. Then run
npx playwright test --uiand step through the purchase. - Break the login merge — make it discard the anonymous cart — and find which test goes red.
- Replace the
signInhelper's final assertion withwaitForTimeout(500)and run the suite ten times. - Reintroduce the root
loading.tsxfrom module 11 and confirm the 404 test catches it. - Change a locator to
.first()where strict mode complains. Then add a second matching element and watch it assert the wrong thing. - Run only the mobile project. Note anything harder to reach at 393px.
- Force a failure and open the trace. Find the network request that failed.
- Set
workers: 2and run the suite repeatedly until something fails. - Add an end-to-end test for the online payment path, using the simulator.
- Delete the six supporting tests and keep only the purchase. Decide what you lost, and whether you would make that trade on a real project.
Next: shipping it — environments, deployment, and knowing when it breaks.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship