0 / 91
Week 11 · Day 74 of 91

Playwright end-to-end tests

Testing, Security, Docker, and Deployment

Objective

Automate a few critical user journeys through the browser.

Production readiness resembles validation before field deployment: repeatable setup, protective limits, test evidence, monitoring, and rollback plans.

  • locators by user-visible meaning
  • stable test data
  • screenshots and traces on failure

Why this matters

Yesterday's integration tests proved your API behaves. They say nothing about whether a human can actually log in, because they never opened a browser. Today you automate the one journey that must never break — log in, create equipment, add a maintenance record, log out — in a real browser, and you set it up so that when it fails at 7am you get a screenshot and a replayable trace instead of a shrug. Two of these tests are worth having. Twenty would be a second job.

What an end-to-end test does

Playwright starts a real browser, navigates to your running app, and drives it the way a person does: click, type, read. Nothing is faked — real HTTP, real cookies, real React rendering, real database. That is the value: an E2E test is the only test that can prove the parts you assembled in Week 10 still work together.

It is also the reason E2E tests are slow (seconds each) and fragile (anything on the path can break them). So you write few of them, and only for critical paths: journeys where a failure means the product is unusable. Login is critical. The colour of a tooltip is not.

The field trial

Bench measurements and subassembly tests tell you the parts meet spec. Before you ship, someone still powers the finished unit on, in its enclosure, and runs it through the operating sequence. That trial is slow and you only run a few, but it is the only test that includes the wiring harness, the enclosure fit, and the thermal reality. E2E tests are that trial.

Locators by user-visible meaning

A locator is how the test finds an element on the page. This choice decides whether your test survives next month.

Resilient locators use what a user can perceive: the element's role, its accessible name, its label, its visible text.

page.getByRole("button", { name: "Save equipment" });
page.getByLabel("Serial number");
page.getByText("Pump 3");

Brittle locators use what a build tool produced: generated class hashes, deep CSS paths, or positions:

page.locator(".css-1a2b3c4 > div:nth-child(3) > button"); // breaks on any restyle

The class hash changes when the styling changes, and the nth-child index changes when someone adds a wrapper div. Neither change affects a single user, yet both turn your suite red. That is the definition of a bad test.

There is a second, quieter benefit: if you cannot find a control by its role and name, a screen reader user cannot find it either. A test that is hard to write with getByRole is usually telling you the markup has an accessibility problem — a button with no text, an input with no label.

When there is genuinely nothing user-visible to grab, add an explicit test hook in your markup — data-testid="equipment-row" — and use page.getByTestId("equipment-row"). It is a deliberate, stable contract rather than an accident of styling.

Directions that survive the redecoration

"The door marked Fire Exit" still works after the walls are repainted. "The third door past the blue poster" does not. Both get you there today; only one is worth writing down.

Waiting, without sleeping

The most common beginner instinct is to add a delay: "wait two seconds for the page to load." Two seconds is simultaneously too long on a fast machine and too short on a slow one, so the suite is both slow and flaky.

Playwright's assertions retry automatically until they pass or the timeout expires:

await expect(page.getByRole("heading", { name: "Equipment" })).toBeVisible();

That line polls until the heading appears, then continues immediately. It replaces every sleep you were about to write. Use page.waitForTimeout() only while debugging by hand, never in a committed test.

Stable test data

An E2E test that depends on data already sitting in your database is a test that passes on your machine and fails on everyone else's. Two rules:

  • Create what you need inside the test, with a unique value so parallel runs cannot collide: const serial = `SN-${Date.now()}`.
  • Point the tests at the test database, never a database with real records — the test writes rows, and it will write them wherever you aim it.

Failure artifacts

When an E2E test fails you were not watching, so Playwright can save evidence:

  • A screenshot of the page at the moment of failure.
  • A trace: a recorded timeline you can replay afterwards, with the DOM at every step, the network requests, and the console output. It is the single most useful debugging artifact in this entire course.

Walkthrough: install, write one journey, read one failure

From your web project folder:

npm init playwright@latest

It asks where to put tests (accept tests or use e2e) and whether to add a GitHub Actions workflow, then downloads the browsers. That download is a few hundred megabytes and happens once.

Open playwright.config.js and set the base URL, the artifacts, and a webServer block so Playwright starts your app itself:

import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  use: {
    baseURL: "http://localhost:5173",
    screenshot: "only-on-failure",
    trace: "on-first-retry",
  },
  webServer: {
    command: "npm run dev",
    url: "http://localhost:5173",
    reuseExistingServer: true,
  },
});

Now e2e/critical-path.spec.js:

import { test, expect } from "@playwright/test";

test("technician can log in, add equipment, and log out", async ({ page }) => {
  const serial = `SN-${Date.now()}`;

  await page.goto("/login");
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Password").fill("correct-horse-battery");
  await page.getByRole("button", { name: "Log in" }).click();

  await expect(page.getByRole("heading", { name: "Equipment" })).toBeVisible();

  await page.getByRole("link", { name: "Add equipment" }).click();
  await page.getByLabel("Name").fill("Pump 3");
  await page.getByLabel("Serial number").fill(serial);
  await page.getByRole("button", { name: "Save" }).click();

  await expect(page.getByText(serial)).toBeVisible();

  await page.getByRole("button", { name: "Log out" }).click();
  await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
});

Run it:

npx playwright test
Running 1 test using 1 worker

  ✓  1 [chromium] › e2e/critical-path.spec.js:3:1 › technician can log in, add equipment, and log out (4.2s)

  1 passed (5.1s)

Now break it on purpose: change the button label in your React app from "Log in" to "Sign in" and run again.

  1) [chromium] › e2e/critical-path.spec.js:3:1 › technician can log in, add equipment, and log out

    Error: locator.click: Timeout 30000ms exceeded.
    Call log:
      - waiting for getByRole('button', { name: 'Log in' })

       9 |   await page.getByRole("button", { name: "Log in" }).click();

Read it: the action that failed, what it was waiting for, and the line. Then open the report:

npx playwright show-report

The HTML report opens in your browser with the failure screenshot attached. Restore the label and confirm green again.

Checkpoint

You can point at the failure output and say three things: which step failed, what locator it was waiting for, and where the screenshot is. That is the whole skill.

Your turn

  1. Install Playwright and configure baseURL, screenshot: "only-on-failure", and trace.
  2. Write the full journey: log in → create equipment → add a maintenance record → log out, in one test. Use getByRole and getByLabel throughout; if a control cannot be found that way, fix the markup — add the missing <label> or button text — rather than reaching for a CSS path.
  3. Generate a unique serial per run so repeated runs cannot collide.
  4. Add one assertion after each step, so a failure names the step rather than the journey.
  5. Run npx playwright test. Then break one label deliberately, re-run, and open npx playwright show-report. Save the screenshot path in docs/test-plan.md.
  6. Set retries: 1 in the config and re-run the broken version so a trace is recorded, then replay it: npx playwright show-trace test-results/*/trace.zip. Step through the timeline once.
  7. Add test-results/ and playwright-report/ to .gitignore. Artifacts are evidence, not source.

Never point E2E tests at production

These tests create records, and a Date.now() serial makes them hard to spot and clean up. Aimed at a live system they write junk into real data — and a login test will lock a real account if you add a failed-password case. Set baseURL to your local app and DATABASE_URL to the test database, and confirm both before the first run.

Reviewer mode — after your test passes

"Review this Playwright test for brittle selectors, unnecessary waits, and overlong scope."

Ask for specific findings with evidence — the exact locator, the exact line — not praise and not a rewrite. Expect it to flag any waitForTimeout and any CSS-path locator. Verify each finding by making the change and re-running before you accept it.

You are done when

One critical-path test passes reliably twice in a row, and a deliberate break produces a screenshot and a trace you have actually opened.

Common pitfalls

  • Forgetting await. Almost every Playwright call returns a promise. A missing await makes the test pass instantly while doing nothing.
  • Sleeping instead of asserting. waitForTimeout(2000) is the leading cause of flaky suites. Use expect(...).toBeVisible() and let it retry.
  • One giant test covering everything. When it fails you learn only "the app is broken". Keep a journey to one coherent path, and assert after each step.
  • Relying on data that already exists. "Pump 3 is in the list" is true on your machine only. Create the row in the test.

Verify it yourself

Open today's reference, Playwright's Installation and first test guide.

  1. Find its section on locators and confirm the recommended priority order. Where does getByTestId sit relative to getByRole — and does that match how this lesson told you to use it?
  2. Find what trace: "on-first-retry" means precisely, and what you would set instead to record a trace on every run. Note why you would not want that on by default.

The hour

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Automate login, create equipment, add maintenance, and logout.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

One passing critical-path test and useful failure artifacts.

Working with AI today

AI as skeptical reviewer

Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.

Review this Playwright test for brittle selectors, unnecessary waits, and overlong scope.

References

End-of-day quiz

Q1 What selector style is usually most resilient?
Q2 Which result best proves today’s work is complete?
Q3 What should an AI code review primarily produce?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.