UI & End-to-End Testing: Journeys, Locators & Flakiness
UI automation should verify a small set of user-visible journeys through stable semantic locators, isolated state and condition-based waits. Functional browser tests, end-to-end scope and visual screenshot comparison are separate dimensions with different oracles and failure evidence.
UI automation controls an application through an interface a user can perceive. End-to-end testing describes scope: a journey crosses the assembled system from one edge to another. Visual regression describes an oracle: rendered pixels are compared with an approved baseline. A browser test can be UI-level without being end-to-end, end-to-end without using a browser, or functional without comparing pixels.
Decide which claim needs the UI #
Use UI automation for behavior that smaller interfaces cannot prove:
- navigation, focus and form submission compose correctly;
- browser storage, cookies and redirects preserve a journey;
- user-visible controls expose the right state and accessible semantics;
- frontend and backend agree on serialized data and error presentation;
- the deployed application can complete a critical path.
Do not enumerate every pricing rule or validation partition through the browser when a unit or API test sees the same defect more directly. Broad tests are slower, less specific and include more timing/state dependencies. Keep one or a few journeys per critical user outcome and push combinatorial cases down to the smallest credible boundary.
flowchart LR U["User-visible intent"] --> L["Semantic locator"] --> A["Actionability wait"] --> X["Action"] --> O["Observable outcome"] X --> E["Trace, DOM, network, logs"] O --> E
Locate by meaning, not DOM shape #
A locator is part of the test's contract with the UI. Prefer the same semantics a user or assistive technology relies on:
- accessible role and name;
- associated label or visible text;
- an explicit stable test identifier when no suitable user-facing identity exists;
- CSS/XPath structure only when the structure itself is the contract.
getByRole("button", { name: "Place order" }) survives wrapper elements and CSS refactoring and also pressures the interface to expose a meaningful accessible name. div:nth-child(3) > button.primary binds the test to layout and styling that users do not depend on.
Text selectors can also be unstable when copy changes frequently or localization is in scope. Use a test id as an explicit automation contract, not as a substitute for accessible semantics. A stable locator should identify one element; silently taking the first match hides ambiguity.
Wait for actionability and outcomes #
Recorded scripts often insert fixed sleeps because they cannot explain when the application is ready. Modern browser harnesses can wait until a locator resolves uniquely and the element is visible, stable, enabled and able to receive input. Assertions can retry until a user-visible condition becomes true within a deadline.
Wait for the condition the user needs, such as a confirmation heading or enabled control. “Network idle” is not a universal readiness definition: applications keep telemetry, streams and polling connections open, while a quiet network does not guarantee that rendering or state propagation finished.
Isolate browser and backend state #
Each test needs a clean browser context: cookies, local/session storage, permissions and cache must not leak from another case. Backend state needs the same ownership. Allocate unique users/orders or reset a disposable environment through a supported setup API. Serial tests that depend on a prior journey create cascading failures and cannot be sharded safely.
Setup through the UI only when setup behavior is part of the claim. Authenticating every test through a five-screen flow wastes time and couples unrelated tests to login presentation; reusing a freshly generated authenticated state can preserve isolation while bypassing repetition. Do not share one mutable browser page across parallel tests.
Use production-like services only for dependencies whose integration the journey is meant to prove. Third-party providers can make a suite slow, costly and nondeterministic; a sandbox or controllable stub may be correct at that boundary, with separate contract and smoke evidence against the provider.
Retries classify a test; they do not repair it #
If a test fails and passes on retry, report it as flaky and retain the first failure. A retry can contain infrastructure noise and collect more evidence, but treating the final pass as ordinary green trains the suite to hide races and shared-state defects.
On failure, keep a trace with:
- action timeline and locator resolution;
- DOM snapshots before and after actions;
- screenshots or screencast around failure;
- console errors and network requests/responses;
- backend correlation id and relevant service logs;
- fixture identifiers, browser/runtime versions and retry number.
These artifacts turn “checkout timed out” into a boundary diagnosis: button never enabled, request never sent, response was 500, response succeeded but state did not render, or another test consumed the fixture.
Functional UI checks versus visual comparison #
A functional assertion asks whether a user-visible state exists or an action is possible. A visual comparison asks whether rendered output matches a baseline within declared tolerances. The second catches spacing, clipping, font and color regressions that DOM assertions miss; it also reacts to operating system, browser version, fonts, GPU, animations and dynamic content.
Pin the rendering environment that created the baseline, disable or mask legitimate nondeterminism, wait for fonts and stable layout, and review baseline changes as code changes. Never bulk-approve screenshots merely to make CI green. A baseline copied from an already broken rendering is a perfectly consistent wrong oracle.
Recorders are drafts #
Action recording can discover locators and produce a first script, but the recording reflects incidental navigation, timing and selectors from one run. Refactor it into intent-revealing actions, remove redundant waits, replace structural locators, isolate data and add explicit outcomes. Generated code is input to test design, not the design itself.
Worked journey: checkout decline #
A durable browser test for a declined payment might:
- create a unique user and basket through a setup API;
- configure a payment sandbox/stub to decline a known token;
- open checkout in a fresh browser context;
- locate fields and the submit button by role/label;
- submit and await the visible decline state;
- assert the basket remains recoverable and no order-confirmation identifier appears;
- use a backend correlation id to verify that no paid order was committed;
- retain trace and service evidence on failure.
The UI portion proves presentation and journey composition. The payment mapping, database transaction and pricing partitions still belong in focused contract, integration and unit tests. That division makes the broad test small enough to trust.