UI Test Automation
UI test automation drives a real browser the way a user would — finding elements, clicking, typing, reading what is on the screen — and checks that the outcome matches the requirement. It is the only kind of automated test that exercises the whole stack at once: markup, styles, client code, API, database and the glue between them. That is both its value and its cost: UI tests are the slowest, most expensive and most failure-prone tests a team owns.
This guide is the entry point to the subject. It covers what UI automation is good and bad at, how a maintainable framework is layered, how Selenium/WebDriver, Playwright and Cypress differ, synchronization and assertions, page and component objects, test data, cross-browser and parallel runs, CI, and how to decide what belongs at the UI level. Two narrower guides go deeper: one on locator strategies, and one on diagnosing and fixing flaky tests.
What UI automation is for — and what it is bad at
A UI test answers one question no other test can: can a user, in a real browser, complete this journey from start to finish? It catches defects that live between layers — a button wired to the wrong handler, a form that sends a field the API rejects, a script error that only appears after a redirect, a layout change that hides the submit button behind a cookie banner.
It is poor at almost everything else. Checking every validation rule, every calculation branch or every error code through the browser is slow, and when a UI test fails it tells you that the journey broke, not where. Those checks belong in unit and API tests, which run in milliseconds and point at the faulty code.
- Good fit — critical user journeys (sign-up, login, search, checkout, payment confirmation), the integration of front end and back end, cross-browser behaviour of a small set of flows, and regression of flows that have broken before.
- Poor fit — business rules with many input combinations, validation matrices, visual design judgement, usability, exploratory questions (“is this confusing?”), and screens whose design changes every sprint.
- Warning signs — a suite that takes longer than the team is willing to wait, tests that fail for reasons unrelated to the product, and failures nobody investigates because “it is probably flaky”.
How a maintainable automation framework is layered
Most UI suites that become unmaintainable fail for the same reason: test intent, page structure and tool calls are mixed in the same function, so one markup change breaks fifty tests. The cure is separation into layers, each with a single reason to change. The ISTQB Advanced Level Test Automation Engineering syllabus describes a generic test automation architecture built on the same idea — separating what is tested from how the system under test is driven. The layer names below are the ones most teams use day to day.
- Test layer — the specs. One scenario per test, written in business terms (“customer pays with a saved card”). No selectors, no waits, no driver calls.
- Page and component objects (or an equivalent abstraction such as Screenplay tasks) — the only place that knows how a screen is structured and which locators it uses.
- Driver / framework layer — the WebDriver bindings, Playwright or Cypress, plus thin wrappers for your own conventions (fixtures, custom commands). Keep wrappers thin: re-implementing the framework hides its features and its documentation.
- Test data layer — factories and API clients that create the users, orders and products a test needs, and remove them afterwards.
- Configuration — base URLs, browsers, timeouts, credentials and feature flags per environment, read from environment variables or config files, never hard-coded in tests.
- Reporting — pass/fail per test with the evidence needed to debug a failure: screenshots, videos, traces, console and network logs, published by the CI pipeline.
e2e/
tests/ checkout.spec.ts, login.spec.ts <- test layer (intent only)
pages/ login-page.ts, checkout-page.ts <- page objects
components/ header.ts, date-picker.ts <- component objects
api/ orders-client.ts, users-client.ts <- set up state via API
fixtures/ auth.ts, test-data.ts <- data + setup/teardown
config/ playwright.config.ts, env/staging.env <- configuration
reports/ (generated) html report, traces, junit.xml <- reportingCalls go down the stack
- 1Test layerOne scenario per test in business terms; the test decides what is correct
Changes when the scenario changes
- 2Page and component objectsOr an equivalent such as Screenplay tasks: the only place that knows screen structure and locators
Changes when the markup changes: one class, not every test
- 3Driver / framework layerWebDriver bindings, Playwright or Cypress, plus thin wrappers (fixtures, custom commands)
Changes when the automation tool changes
- 4Browser and the system under testDriven only through the layer above
From Test layer to Driver / framework layer:not allowed: selectors, waits or driver calls in a test
Beside the call stack: the test data layer in api/ and fixtures/ (creates what a test needs, removes it afterwards), configuration per environment in config/ (never hard-coded in tests) and reporting in reports/ (published by CI).
Browser automation: WebDriver, Playwright and Cypress
WebDriver is a W3C specification for remote-controlling a browser: a client library sends commands over HTTP to a browser-specific driver (chromedriver, geckodriver, safaridriver and others), and the driver controls the browser. Selenium is the best-known client, with official bindings for Java, Python, C#, JavaScript and Ruby, and Selenium Grid distributes sessions across machines. Because browser vendors ship their own drivers, WebDriver is the most portable option, and the protocol also underlies other tools such as Appium for mobile apps.
Playwright is an open-source library from Microsoft that drives Chromium, Firefox and WebKit through its own browser connection rather than WebDriver. Its test runner provides auto-waiting before actions, retrying (“web-first”) assertions, isolated browser contexts, parallel workers, network interception and a trace viewer. It supports TypeScript/JavaScript, Python, Java and .NET.
Cypress runs test code inside the browser next to the application, which gives it direct access to the page and the network, automatic retrying of queries and assertions, and an interactive runner that shows every command against a snapshot of the DOM. Tests are written in JavaScript or TypeScript. The in-browser architecture brings trade-offs that its documentation lists, for example around multiple tabs and cross-origin navigation.
None of the three is “the best”. Choose by the language your team writes, the browsers your users actually run, whether you need grid or mobile infrastructure, and how clearly the tool reports failures. The concepts in the rest of this guide apply to all of them.
Locators: how tests find elements
A locator is the query a test uses to find an element, and its quality decides most of a suite’s maintenance cost. Playwright’s and Testing Library’s documentation both recommend user-facing attributes first — the element’s role and accessible name, its label, its visible text — and a dedicated test id after them; extending that order with CSS, and XPath as the last resort, gives a ranking that works with any tool.
User-facing locators survive refactors because they change only when the interface the user sees changes, and they fail when an element loses its accessible name — a useful side check for accessibility. The guide on locator strategies covers the full order, CSS versus XPath, dynamic ids, iframes, shadow DOM and anti-patterns such as absolute XPath and index-based selectors.
// Playwright — in order of preference
page.getByRole("button", { name: "Place order" })
page.getByLabel("Email")
page.getByText("Order confirmed")
page.getByTestId("order-total")
page.locator("form#checkout button[type=submit]") // CSS: only when nothing above fits
// Selenium (Java)
driver.findElement(By.cssSelector("[data-testid=order-total]"));
// Cypress
cy.get("[data-cy=order-total]");Waits and synchronization: never a fixed sleep
Most UI flakiness is a race: the test acts or asserts before the application is ready. A fixed sleep (Thread.sleep, time.sleep, cy.wait(5000)) “fixes” it on your laptop by guessing, then fails on a slower CI runner — and wastes its full duration on every fast run. Wait for a condition instead, with a timeout.
- Selenium implicit wait — a driver-wide setting that makes every element lookup poll until the element is present or the timeout expires. It covers presence only, not visibility or clickability.
- Selenium explicit wait — WebDriverWait with a condition (visible, clickable, text present, or your own function), applied exactly where it is needed. The Selenium documentation warns against mixing implicit and explicit waits, because the combined timeouts become unpredictable.
- Playwright auto-waiting — before an action such as click or fill, Playwright checks that the element is actionable (visible, stable, able to receive events, enabled and, for inputs, editable) and waits until it is or the timeout expires.
- Cypress retry-ability — queries such as cy.get and cy.contains are retried together with the assertion chained after them until the assertion passes or the command times out. Actions such as .click() are not re-run, so assert the state you need before acting on it.
- Wait for the application’s own signals — a specific network response, a spinner disappearing, a “Saved” status — rather than for time or a generic “network idle” state.
// Selenium (Java) — explicit wait for a condition, not a sleep
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement status = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.cssSelector("[data-testid=order-status]")));
// Playwright (TypeScript) — click auto-waits; the assertion retries
await page.getByRole("button", { name: "Pay now" }).click();
await expect(page.getByTestId("order-status")).toHaveText("Paid");
// Cypress — the query retries until the assertion passes or times out
cy.get("[data-cy=order-status]").should("have.text", "Paid");
// Never: Thread.sleep(5000) / time.sleep(5) / cy.wait(5000)Assertions: check what the user can see, and let them retry
An assertion should describe an outcome the user or the business cares about: the confirmation heading is visible, the order total reads 199.20 SAR, the error message names the invalid field, the URL is the order page. Asserting on internal details — a CSS class, the number of divs, a component’s state — couples the test to the implementation and breaks on harmless refactors.
Use retrying assertions where the framework offers them. In Playwright, expect(locator).toBeVisible(), toHaveText() and expect(page).toHaveURL() retry until they pass or time out, whereas expect(await locator.textContent()).toBe(…) reads once and races with the page. In Cypress, .should() retries the query before it. In Selenium, wait for the condition explicitly, then assert.
- Assert one behaviour per test, with as many assertions as it takes to prove that behaviour.
- Use exact values for money, counts and statuses; use patterns only for genuinely variable parts, such as an order number like ORD-2026-000184 or a timestamp.
- When the UI renders data from an API, assert the rendered result in the UI test and the full response in an API test — do not re-verify every JSON field through the browser.
- Make failure messages read like bug titles: expected “Paid”, received “Pending” for order ORD-2026-000184.
Page objects and component objects
A page object is a class that represents one page or screen. It exposes what a user can do there as methods (signIn, addToCart) and hides the locators and interaction details. When the markup changes, you fix one class instead of every test. The Selenium documentation describes the pattern and recommends that page object methods return other page objects or data, and that page objects do not make assertions — the test decides what is correct.
Component objects apply the same idea to parts that appear on many pages: a header, a date picker, a data table, a modal dialog. A checkout page object then composes a header component and an address-form component instead of duplicating their locators.
Keep page objects small and behaviour-focused. A 900-line “BasePage” full of generic helpers is a sign that the abstraction has turned into a second framework. Some teams prefer alternatives such as the Screenplay pattern (actors performing tasks) or plain domain-level helper functions; they are fine as long as they achieve the same separation. The example below uses Playwright with TypeScript.
// pages/orders-page.ts
import type { Page, Locator } from "@playwright/test";
export class OrdersPage {
readonly heading: Locator;
constructor(page: Page) {
this.heading = page.getByRole("heading", { name: "My orders" });
}
}
// pages/login-page.ts
import type { Page, Locator } from "@playwright/test";
import { OrdersPage } from "./orders-page";
export class LoginPage {
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
constructor(private readonly page: Page) {
this.email = page.getByLabel("Email");
this.password = page.getByLabel("Password");
this.submit = page.getByRole("button", { name: "Sign in" });
}
async goto() {
await this.page.goto("/login");
}
async signIn(email: string, password: string): Promise<OrdersPage> {
await this.email.fill(email);
await this.password.fill(password);
await this.submit.click();
return new OrdersPage(this.page);
}
}
// tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/login-page";
test("customer sees their orders after signing in", async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
const orders = await login.signIn("layla.h@example.com", process.env.TEST_USER_PASSWORD!);
await expect(orders.heading).toBeVisible();
await expect(page).toHaveURL(/\/orders$/);
});Test data and isolation
Every UI test should create what it needs and depend on nothing another test created. Shared accounts and shared orders are the most common cause of tests that pass alone and fail in the suite: one test cancels the order another test is checking, or two parallel tests edit the same profile.
Create state through the API or a database seed, not through the UI. Logging in through the form in every test, or building a cart by clicking through ten product pages, makes each test slow and exposes it to failures in screens it is not testing. Test the login form once, in its own test; elsewhere, obtain a session through the API and reuse it — Playwright can save authenticated browser state to a file, and Cypress offers cy.session for the same purpose.
- Unique data per test — add a run id or UUID to emails and names (for example layla.h+run8412@example.com) so parallel runs cannot collide.
- Fictional data only — never copy production customers into test environments; generate realistic records instead.
- Stub what you do not own — payment gateways, SMS providers and map services can be replaced with mocked responses in most UI tests (page.route in Playwright, cy.intercept in Cypress), keeping one separate check against the provider’s real sandbox.
- Clean up, or make data disposable — delete created records after the test, or use an environment that is reset for each run.
Try it: API Request Builder Try it: Fake User Generator Try it: Test Data Factory Try it: Mock API Response Generator
Cross-browser testing and parallel execution
Cross-browser testing checks that the same journeys work in the browser engines your users actually have: Chromium-based browsers (Chrome, Edge), Firefox and Safari (WebKit), at desktop and mobile widths. Let your analytics decide the matrix; running every test on every browser multiplies cost for little gain. A common split is the full suite on one browser and a smoke set of critical journeys on the others.
Playwright’s WebKit build is not the same product as Safari on macOS or iOS. If Safari users matter to you, include real Safari runs — through safaridriver or a device cloud — for the most important flows.
Parallel execution keeps a growing suite fast, and it only works when tests are isolated. Runners split tests across workers: Playwright workers and sharding, Selenium Grid nodes combined with TestNG, JUnit or pytest-xdist parallelism, and Cypress spec files spread across CI machines. Each worker needs its own browser context and its own data.
- Design for any order — occasionally run the suite in random order to prove there are no hidden dependencies between tests.
- Shard long suites across CI jobs and merge the reports into one.
- Watch shared resources — one test tenant, one rate-limited API or one mailbox can serialise a “parallel” suite or make it flaky.
Running UI tests in CI
In CI, browsers run headless (without a visible window) on clean runners, usually in containers with the browsers preinstalled. Pin browser and framework versions so that a runner image update cannot silently change results, and record the version of the application under test in the report.
A failed CI run is only useful if someone can debug it without re-running it locally. Configure the suite to keep evidence on failure — screenshots, videos, Playwright traces, browser console and network logs — and publish the HTML report and JUnit XML as build artifacts so the pipeline can show results per test. The example is a GitHub Actions job for a Playwright smoke set; GitLab CI and Jenkins pipelines follow the same shape.
- Pull request — a fast smoke set of UI journeys (minutes, not an hour) alongside unit and API tests.
- Main branch or nightly — the full UI regression across the browser matrix.
- After deployment — a handful of read-only journeys against the live environment.
- Failures block the merge. Flaky tests are fixed or quarantined with an owner, not re-run until green; the guide on flaky tests covers diagnosis, retries and quarantine.
name: ui-smoke
on: [pull_request]
jobs:
ui-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --grep @smoke
env:
BASE_URL: ${{ vars.STAGING_URL }}
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/A practical strategy: what to automate at the UI level
The test pyramid, explained in the guide on manual versus automated testing, puts many fast unit tests at the base, fewer API and service tests in the middle and a small number of UI tests at the top. For UI automation the practical rule is: automate a journey through the UI only when the thing at risk is the journey itself — the wiring between screens, front end and back end — and push every other check down to the lowest level that can detect it.
For example, a discount calculation with twelve rule combinations belongs in unit and API tests; “the discounted total is shown at checkout and charged at payment” belongs in one UI test.
- Start from risk: list the journeys whose failure costs money, data or trust (payment, sign-up, login, key search) and automate those first.
- Write each journey as Given/When/Then acceptance criteria first; the criteria become the test names and the assertions.
- Budget the suite: agree a maximum duration for the pull-request smoke set and for the full run, and delete or push down tests when you exceed it.
- Treat tests as production code: code review, naming conventions, no copy-paste, and a named owner for each area.
- Maintain continuously: update page objects in the same pull request as the UI change, agree test ids with developers, delete tests for features that no longer exist, and upgrade framework and browser versions in their own change.
- Measure what matters: time to feedback, flaky rate, and defects the suite caught before release — not the raw number of tests.
Try it: Acceptance Criteria Generator Try it: Test Case Generator
Frequently asked questions
What is UI test automation?
It is the use of a tool such as Selenium, Playwright or Cypress to drive a real browser through user journeys and check the visible results automatically. It verifies that the front end, back end and integrations work together, and complements faster unit and API tests.
Should we choose Selenium, Playwright or Cypress?
Choose by your team’s language, the browsers you must support, your infrastructure (grid, device cloud, mobile) and how well the tool reports failures. All three can support a maintainable suite; the framework design and test data discipline matter more than the tool.
How many UI tests should we have?
There is no correct number. Cover the critical journeys and the integration points that only a browser can check, and keep the rest at the API and unit level. If the suite is too slow to run on every pull request, it is too big or badly split.
Why do our UI tests keep failing randomly?
The usual causes are timing (acting before the page is ready), shared data between tests, unstable environments, third-party services and animations. Replace sleeps with condition-based waits and retrying assertions, isolate data, and use traces and screenshots to find the actual cause.
Should page objects contain assertions?
Generally no. The Selenium documentation recommends keeping assertions in tests, so page objects stay reusable. A page object may check that it has reached the expected page, but the verification of behaviour belongs in the test.
Can recorded tests replace a framework?
Recorders such as Playwright codegen or Selenium IDE are useful for learning a page and drafting locators. Recorded scripts usually need refactoring into page objects, better locators and proper waits before they are maintainable in a long-lived suite.
Do UI tests have to run headless in CI?
Headless is the norm in CI because it is faster and needs no display, and modern browsers behave the same way in headless mode for most purposes. Keep screenshots, videos or traces so failures can still be seen.
References
- Certified Tester Advanced Level Test Automation Engineering (CTAL-TAE) v2.0ISTQB's advanced syllabus on designing, implementing and maintaining test automation solutions and their architecture.
- WebDriver (W3C)The W3C protocol browsers implement for remote control by automation tools such as Selenium (the Level 2 edition is a Working Draft).
- Selenium DocumentationOfficial documentation for browser automation with WebDriver, including waits, locators and Grid.
- Playwright DocumentationOfficial documentation for Playwright, an end-to-end test framework that drives Chromium, WebKit and Firefox.
- Cypress DocumentationOfficial documentation for end-to-end and component testing in the browser with Cypress.
- Best Practices — PlaywrightTest user-visible behaviour, keep tests isolated, prefer resilient locators and web-first assertions, and debug with traces.
- Page object models — SeleniumThe page object pattern: one class per page or component that hides locators and exposes the actions a test needs, reducing duplication.
- The Practical Test Pyramid (Ham Vocke)A widely cited explanation of how to balance unit, integration, contract and UI tests in an automated test portfolio.
Practice exercise
Review a recorded checkout test and plan the UI suite
You are the QA engineer of an online furniture shop (fictional). Release 3.4 adds "Pay with a saved card" at checkout and goes live in three weeks. Nadia, an automation engineer who joined two months ago, recorded the first Playwright test of the checkout with codegen, edited it and opened a pull request; she also wrote a plan for the rest of the release. Last week 3 of 11 pull-request runs failed in CI and passed on her laptop. The material gives the team, the browser analytics, the staging API the suite could use, the test with line numbers, and the plan.
Work on paper with the guide’s sections on layers, locators, waits, assertions, test data, cross-browser runs, CI and strategy. Refer to line numbers, and write your findings as review comments Nadia can act on. Allow 45–60 minutes.
Online furniture shop (fictional) - web shop, staging at https://staging.furnish.example
Release 3.4 adds "Pay with a saved card" at checkout. Go-live in three weeks.
The team: Nadia (automation engineer, joined 2 months ago), Omar (front end),
Sara (back end), Yousef (release manager), QA: you.
Analytics, last 90 days (fictional): Chromium browsers (Chrome, Edge) 74%,
Safari 21% (iOS 17%, macOS 4%), Firefox 4%, other 1%.
Staging API
POST /api/auth/session sign in; sets the session cookie
POST /api/cart/items { sku, qty } adds an item to the signed-in cart
POST /api/test-support/customers test environments only: creates a disposable
customer, optionally with a saved card; returns
id, email, password
DELETE /api/test-support/customers/{id}
The checkout page calls the payment gateway sandbox directly from the browser:
POST https://sandbox.paygate.example/v1/charges (2-9 s; declines about 1 in 20)
Checkout address form: 7 fields, 23 validation rules (required, format, length),
checked in the browser and again by POST /api/addresses.
Nadia's first test, recorded with codegen and edited:
tests/checkout.spec.ts
1 import { test, expect } from '@playwright/test';
2
3 test('test 1', async ({ page }) => {
4 await page.goto('https://staging.furnish.example/login');
5 await page.locator('#email').fill('qa.shared@example.com');
6 await page.locator('#password').fill('Furnish2026!');
7 await page.locator('.MuiButton-root.css-1x2y3z').click();
8 await page.waitForTimeout(3000);
9 await page.goto('https://staging.furnish.example/p/oak-desk-140');
10 await page.locator('xpath=//div[3]/div[2]/button').click();
11 await page.goto('https://staging.furnish.example/p/desk-lamp-led');
12 await page.locator('xpath=//div[3]/div[2]/button').click();
13 await page.goto('https://staging.furnish.example/cart');
14 expect(await page.locator('.total').textContent()).toBe('1,249.00 SAR');
15 await page.locator('text=Checkout').click();
16 await page.locator('input').nth(3).fill('');
17 await page.locator('text=Continue').click();
18 expect(await page.locator('.error').count()).toBe(1);
19 await page.locator('input').nth(3).fill('Riyadh');
20 await page.locator('text=Continue').click();
21 await page.locator('text=Pay now').click();
22 await page.waitForTimeout(8000); // the gateway sandbox is slow
23 await expect(page.locator('.alert')).toHaveClass(/success/);
24 });
Nadia's plan for 3.4
1. One test like this one for each of the 23 validation rules of the address form.
2. Run the whole suite on Chromium, Firefox, WebKit and Edge on every pull request.
3. Screenshots, videos and traces off in CI to keep the job fast; re-run locally
to debug a failure.
Last week: 3 of 11 pull-request runs failed in CI and passed on Nadia's laptop.- Review the test against the guide, line by line. For every problem write the line numbers, the rule it breaks (layers, locators, waits, assertions, test data and isolation, configuration) and what the three failed CI runs most likely have to do with it.
- Decide what belongs at the UI level. Where are the 23 validation rules tested (unit, API, UI)? Then write the UI journeys the suite needs for release 3.4 as Given/When/Then, each with its assertions.
- Design the layers: the folders and files of the suite for this journey (tests, page and component objects, API clients and fixtures, configuration, reports), what each one contains and what it must never contain.
- Rewrite the checkout journey as a test in the test layer, in TypeScript with Playwright or as a numbered list of what it does: state set up through the API, the gateway stubbed, user-facing locators, retrying assertions, nothing left behind.
- Plan the runs: the browser matrix for the analytics given; what runs on a pull request, on main or nightly and after deployment; the evidence CI keeps on failure; and your reply to the three points of Nadia’s plan.
Show the model answer
Review, part one: structure and data. Line 3: the name "test 1" says nothing, and the test checks four behaviours — sign-in, the cart total, address validation and payment — so when it fails nobody knows which journey broke; one behaviour per test, named in business terms. Lines 4, 9, 11 and 13: the base URL is hard-coded in the test. It belongs in the configuration layer (baseURL in playwright.config.ts, read from an environment variable such as BASE_URL), so the same suite runs against any environment. Lines 5–6: a shared account with its password in the source. The password belongs in a secret (process.env.TEST_USER_PASSWORD in the guide’s example), and a shared account breaks isolation: two runs at once — two pull-request pipelines today, two parallel workers once the suite grows — add items to the same server-side cart, and the total on line 14 is wrong for one of them. Line 18 expects exactly one error although the test touches only one of the seven fields, so it also relies on the shared account’s saved address filling the other six — data the test did not create. Lines 4–8 sign in through the form, and lines 9–12 build the cart by clicking through product pages; both make the test slow and expose it to failures in screens it is not testing. Sign in through the API and create the cart with POST /api/cart/items. Nothing is removed afterwards, so every run leaves an order behind, and a run that stops before payment leaves items in the cart that make the next run’s total wrong.
Review, part two: locators, waits and assertions. Line 7: a generated CSS class (css-1x2y3z) is a hash that can change with any front-end build — the most fragile kind of CSS selector; use getByRole("button", { name: "Sign in" }). Lines 10 and 12: an XPath built from positions (div[3]/div[2]) breaks as soon as a wrapper div is added or the layout changes; use the accessible name of the "Add to cart" button, or a test id agreed with Omar. Lines 16 and 19: input.nth(3) depends on the order of the inputs on the page; use getByLabel("City"). Lines 15, 17, 20 and 21 use visible text, which is user-facing, but the text= selector matches any element whose text contains the word, ignoring case: text=Continue also matches a "Continue shopping" link if the page has one, and the click then fails because two elements match. getByRole("button", { name: "Continue", exact: true }) is precise. Line 8: a fixed sleep that guesses how long sign-in takes; on a slower runner line 9 navigates away before the session exists. Wait for a condition — the page the user lands on — or, better, sign in through the API. Lines 10–13: the test navigates straight after each "Add to cart" click without waiting for the cart to update, so the navigation can cut off the add-to-cart request; wait for the cart count or for that request’s response. Line 22: a sleep that guesses the gateway’s 2–9 seconds. The toHaveClass assertion on line 23 already retries (5 seconds by default), so the sleep protects nothing and adds 8 seconds to every run; stub the gateway instead. Lines 14 and 18: expect(await …textContent()) and count() read once and race with the page, and they find elements by styling classes (.total, .error); use await expect(locator).toHaveText(…) on a test id such as cart-total, and assert the error message that names the field, not the number of .error elements. Line 23: a CSS class is an implementation detail; assert what the user sees — the confirmation heading, the order number and the URL. The three CI failures that passed locally fit this picture: a CI runner is slower than a laptop, so the 3-second sleep and the cut-off cart requests show up there first; two pipelines running at once share one account and one cart; and the sandbox’s own declines (about 1 in 20) fail the test for a reason that has nothing to do with release 3.4. Without traces, nobody can tell which of these it was.
What belongs at the UI level. The 23 validation rules are business rules with many inputs — the guide’s poor fit. The browser-side checks belong in the front end’s unit tests (one case per rule, milliseconds each) and the server-side checks in API tests of POST /api/addresses (each rule’s invalid value, the status code and the error body); the server is tested on its own because a client can bypass the browser. The UI keeps one test that proves the wiring: an invalid field shows a message naming the field and "Continue" does not move on — one rule, for example an empty city, is enough. Nadia’s 23 UI tests become 1. The UI journeys for 3.4: (1) a customer signs in and sees the account page — the only test that uses the sign-in form; (2) a customer with a saved card completes checkout — Given a signed-in customer with a saved card and a cart of two items, When they confirm the address and pay with the saved card, Then the confirmation page shows the order number and the total 1,249.00 SAR; (3) a declined payment — Given the same, When the gateway declines, Then the checkout shows the decline message and "Pay now" can be used again; (4) the address validation wiring, as above. Four tests, one behaviour each.
The layers. tests/checkout.spec.ts and tests/login.spec.ts — the specs: intent only; no selectors, no waits, no URLs. pages/login-page.ts, pages/cart-page.ts, pages/checkout-page.ts, pages/confirmation-page.ts — page objects: locators and actions (checkout(), continueWithAddress(), payWithSavedCard()) that return the next page object or data; no assertions. components/address-form.ts and components/header.ts — component objects for parts that appear on several pages. api/customers-client.ts and api/cart-client.ts — thin clients over /api/test-support/customers, /api/auth/session and /api/cart/items. fixtures/customer.ts — a fixture (test.extend) that creates a disposable customer with a saved card before the test and deletes it afterwards; fixtures/gateway.ts — the page.route stub for sandbox.paygate.example with an approved and a declined response. playwright.config.ts — baseURL, the projects (browsers and a mobile width), timeouts, trace, screenshot, video and the reporters; env/staging.env for the environment, secrets from the CI secret store. playwright-report/ and junit.xml — generated, never committed. Keep the wrappers thin: no "BasePage" that re-implements Playwright.
The test. test("customer pays with a saved card", async ({ page, customer, cart, gateway }) => { … }). The customer fixture calls POST /api/test-support/customers and asks for a saved card; the response gives a new customer’s id, e-mail and password, so every test has its own account (when a test chooses an e-mail itself, it adds a run id or UUID, as in layla.h+run8412@example.com). The fixture then signs in with page.request.post("/api/auth/session", { data: { email, password } }) — page.request shares the cookie jar with the page, so no form is filled — and deletes the customer in its teardown. cart adds the two products through POST /api/cart/items. gateway registers page.route("**/sandbox.paygate.example/v1/charges", route => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(approved) })), where approved copies a real approved response from the sandbox. route.fulfill answers the intercepted request with that status and body, so the request never reaches the gateway (Cypress does the same with cy.intercept). The body: const cartPage = new CartPage(page); await cartPage.goto(); const checkout = await cartPage.checkout(); await checkout.continueWithAddress(testAddress); const confirmation = await checkout.payWithSavedCard(); await expect(confirmation.heading).toBeVisible(); await expect(confirmation.total).toHaveText("1,249.00 SAR"); await expect(confirmation.orderNumber).toHaveText(/^ORD-\d{4}-\d{6}$/); await expect(page).toHaveURL(/\/orders\/ORD-/); — testAddress is a fictional address from the test data layer. No waitForTimeout: click() auto-waits for an actionable element, and every expect retries until it passes or times out. The declined-payment test uses the same fixtures with the stub returning a declined response (for example insufficient funds) and asserts the decline message and that "Pay now" is enabled again. When tests only read data, a setup project is the cheaper option: a project in playwright.config.ts that runs before the others, signs in once and saves the cookies and storage to a storageState file that the tests then load (Cypress’s equivalent is cy.session). One separate, slower test runs nightly against the real sandbox to prove the integration still works; it accepts an approval or a decline as long as the page shows the matching outcome, so the sandbox’s random declines do not make it flaky.
The runs. The matrix follows the analytics: Chromium covers 74%, and Edge is built on the same engine, so a separate Edge project adds little; WebKit stands in for Safari’s 21%, at a mobile viewport because most of it is iOS; Firefox’s 4% gets the smoke set. Playwright’s WebKit is not Safari, so the checkout journey also runs on real Safari — through safaridriver or a device cloud — once per release candidate. Pull request: the smoke set (the four journeys) on Chromium, a few minutes, next to unit and API tests; a failure blocks the merge. Main and nightly: the full UI regression on Chromium and on WebKit at mobile width, with the smoke set on Firefox, sharded across CI jobs with the reports merged; the real-sandbox payment test runs nightly. After deployment: read-only journeys on production — open the home page, search, open a product page — never an order or a payment. Evidence: trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure", the HTML report and junit.xml uploaded as build artifacts even when the job fails, Playwright and browser versions pinned, and the build of 3.4 under test written into the report.
Reply to Nadia’s plan. Point 1: 23 rule tests would make the suite minutes slower and tell the team nothing a unit or API test does not tell faster and more precisely — one UI wiring test, and the 23 rules tested at unit and API level instead; offer to pair on the API tests. Point 2: four browsers on every pull request multiplies the duration by four, and Edge adds little over Chromium while Firefox adds the 4% the nightly run already covers — smoke on Chromium per pull request, the matrix nightly. Point 3: without traces or screenshots the three failed CI runs could only be guessed at, which is why they were re-run locally, passed, and their cause is still unknown — keep evidence on failure only, which costs little on a green run. And the test goes through code review like production code: the findings of steps 1 and 2 are the review comments, page objects are updated in the same pull request as the UI change, and Omar agrees test ids for the elements that have no accessible name.
If your answer differs, check it against the guide’s rules: a UI test only where the risk is the journey itself; state created through the API, unique per test and removed afterwards; user-facing locators; waits for conditions, never for time; assertions on what the user sees, retrying; configuration and secrets outside the tests. A different split with a reason is fine — for example testing the decline message in a front-end component test instead of a UI test, because the browser talks to the gateway directly and handling a decline is front-end logic only — but a fixed sleep, a shared account or a hard-coded URL is not.
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.
Part of the QA Roadmap
Before this topic
Next topics
Related topics in this module
Tools mentioned in this guide
Compose and send HTTP requests (method, URL, headers, auth, body) and inspect the response — a lightweight Postman in your browser.
Generate fake user records (name, username, email, password, avatar seed, role) for test accounts.
One-stop factory for synthetic test data: users, customers, companies, addresses, numbers, dates and strings in JSON, CSV, SQL or XML.
Generate Given/When/Then acceptance criteria and a definition-of-done checklist from a user story.
Generate structured test cases (id, title, preconditions, steps, expected result, priority) from a feature description and field list.
Test regular expressions live with match highlighting, capture groups and replace preview.
Generate complete mock API responses (status, headers, JSON body) for success, list, detail, error and empty cases.