Test what the user can observe

An end-to-end test follows a workflow through the running interface: open a page, perform an action, and check the result. It can fail unpredictably when the test assumes a control has appeared, a request has finished, or a transition has ended. Reliability begins with a simple rule: locate the intended element, then wait for the state that proves the action worked.

The examples below assume a JavaScript project using Playwright Test and a local tasks application at http://localhost:3000/tasks. The page has an “Add task” button, a field labeled “Task name,” a “Save” button, and a list that displays saved tasks. These are example application requirements, not Playwright defaults. Adapt the names and URL to your application, and arrange for the server to be running before the test.

Choose selectors that express intent

A selector should identify an element by its purpose rather than its position or styling. Playwright locators such as page.getByRole('button', { name: 'Add task' }) and page.getByLabel('Task name') describe what a user encounters. They are usually easier to maintain than a long CSS path or a selector based on a generated class name. They also encourage controls to have useful accessible names.

Roles and labels are not always enough. Repeated controls, such as a “Delete” button on every task, need a narrower scope. First locate the task row by its visible content, then find its button within that row. If a control has no stable user-facing name, a deliberate test identifier such as data-testid="save-task" can be appropriate. Treat that identifier as part of the interface’s testing contract; avoid adding one to every element by default.

Be cautious with text that changes for legitimate reasons. A selector based on the entire contents of a task row might break when a due date is added. Prefer the smallest meaningful locator that uniquely identifies the target, and make a failure from an ambiguous match visible rather than silently choosing the first result.

Wait for a result, not a number of milliseconds

A fixed pause, such as waiting two seconds after clicking “Save,” does not establish that saving succeeded. On a fast run it wastes time; on a slower run it may still end too early. Instead, assert the outcome the workflow promises. Playwright’s expect assertions retry until their condition is met or the configured timeout expires, so an assertion about the new task can accommodate ordinary variation in rendering and response time.

For the example application, the core test can be written as follows in a Playwright test file: import { test, expect } from '@playwright/test'; test('adds a task', async ({ page }) => { await page.goto('http://localhost:3000/tasks'); await page.getByRole('button', { name: 'Add task' }).click(); await page.getByLabel('Task name').fill('Write release notes'); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByRole('listitem').filter({ hasText: 'Write release notes' })).toBeVisible(); }); This assumes saved tasks are rendered as list items and the chosen task name is unique in the test data.

Choose the assertion that matches the behavior under test. If the interface promises a confirmation message, assert that message. If saving should make a task available after a page reload, reload and check the persisted task instead. A visible item immediately after clicking may prove an optimistic update, but it does not by itself prove that storage succeeded.

Keep data and asynchronous behavior under control

Even good selectors cannot rescue a test whose starting state changes unexpectedly. Create or reset test data before each test, or use an isolated account or environment. Avoid sharing a task name across parallel runs when the application permits duplicates. The setup should make the expected starting state explicit, and cleanup should not depend on the assertion passing.

Wait on network activity only when it represents a meaningful part of the behavior you need to verify. For example, if a save request must succeed, arrange to observe its response before clicking, then check the response and the resulting interface. Do not use a broad “network idle” condition as a substitute for understanding the page: background polling or analytics can make it unrelated to readiness. A visible, enabled control or a specific completed request is often a clearer condition.

Use failures to improve the test contract

When a test fails intermittently, record which condition failed: finding the control, completing the action, receiving a response, or displaying the result. Run the test repeatedly in the same environment, and inspect the runner’s trace or screenshots if available. These artifacts can show whether an overlay blocked a click, the wrong row matched, or the expected state never appeared.

Resist the urge to fix every failure by increasing timeouts. A longer timeout is reasonable when a legitimately slow operation has a known completion condition. Otherwise, refine the locator, make the starting data deterministic, or assert the right result. A reliable end-to-end test is a clear account of what the interface should do, with enough observable checkpoints to reveal where it stopped.

Back to category ↗