A good E2E test reads as a story of what the user does and what they get in return. This is not a formality: a test written as a story documents the system's behavior and stays clear a year later, when the author has forgotten the details. The very first scenario sets the tone for the whole suite - so it is written not as a technical sequence of clicks but as an assertion about a concrete user promise.
The test's skeleton is minimal. From @playwright/test you import test and expect, and the test itself is a function with a meaningful name and an async body, receiving page via argument destructuring. page is a page in an isolated context, separate for each test. Asynchrony here is not a formality: every action in the browser is an await, and that is exactly why the scenario reads top to bottom as a sequence of steps.
The test name carries meaning on par with the code. "The user signs in and sees the account page" states both the condition and the expected outcome at once - from the name alone in the report you see what exactly broke, even before reading the body. A bad name like "login test" does not give this: on failure you have to dig into the code to understand which behavior no longer holds. The name is the first line of diagnostics.
The steps themselves describe the user's actions in the language of the interface. page.goto('/login') opens the page, getByLabel('Email').fill(...) types into a field by its label, getByRole('button', { name: 'Sign in' }).click() presses the button by its role and visible name. The test does not know and does not want to know which React component or handler is inside - it does exactly what a person in front of the screen would do.
The assertions at the end observe the observable. await expect(page).toHaveURL('/dashboard') confirms the user ended up where promised, and expect(heading).toBeVisible() confirms the account page is actually shown. These are web-first assertions: they retry the check until the needed state appears, so they are reliable on an async interface where data and transitions do not arrive instantly.
It helps to see such a scenario in full once to feel its shape: a short meaningful name, a few actions in the user's language, a few assertions at public boundaries. Below is that very first login test; you return to this shape as a model when writing a new scenario and wanting to keep it readable.
What the test lacks is fundamental. It does not peek into React's internal state, does not check which functions were called, does not read component variables. It observes only what the user sees: the page address and the interface. This is what makes it E2E - and at the same time resilient to refactoring: while the promise "signed in and saw the account page" holds, the test is green no matter how the code inside changes.
The typical first-test failures set a bad tone for the whole suite. A placeholder name like "test1" or "form check", from which on failure it is unclear what broke. Checking internal implementation instead of the URL and interface, because of which the test goes red on a harmless refactor. And locators by random CSS instead of role and label, tying the scenario to the markup. Write the test as a user story - and it will be both clear and durable.
import { test, expect } from '@playwright/test'
test('user signs in and sees the account page', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('anna@example.com')
await page.getByLabel('Password').fill('correct-password')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page).toHaveURL('/dashboard')
await expect(
page.getByRole('heading', { name: 'Account' }),
).toBeVisible()
})