A long E2E scenario - sign in, fill the cart, place the order, pay, check the order - looks in the report like a flat wall of actions, and when it fails it is unclear at which stage. Playwright gives two tools to bring order here: steps that group actions into meaningful stages, and annotations that control how and when a test runs. Together they turn a suite from a dump of scenarios into an organized and diagnosable system.
A step is created via test.step with a name and a body. It does not change the test's logic but groups the actions inside it into one named stage, visible in the trace and the report as a separate block. A long scenario broken into steps "sign in", "add an item", "check out", "pay" reads as a story with a table of contents, and on failure the report immediately shows at which stage it happened - without digging through the whole test body.
The value of steps is especially visible in diagnostics. The Trace Viewer and the HTML report show steps as collapsible blocks with their durations, so from a failed test it is immediately clear where it stumbled and how long each stage took. This is a cheap investment in readability: a few steps around a scenario's logical stages save minutes of analysis at every future failure and cost one line of wrapping.
It helps to see steps and annotations in code side by side once. Below is a scenario broken into named steps and examples of annotations: a conditional skip, marking a slow test and a tag in the title. You return to this form when a scenario grows so large that its stages are no longer visible, or when a test needs to be temporarily excluded, marked or assigned to a set.
Annotations control a test's execution explicitly and honestly. test.skip with a condition skips a test where it is inapplicable - for example, on a certain browser or environment. test.fixme marks a knowingly broken scenario that is being fixed, not deleted. test.slow tells the runner the test is objectively long and increases its limit - this is the right way to give time to one heavy operation, unlike the raised global timeout from the config chapter.
Tags give flexible filtering of the suite without moving files around. A test is marked with a tag - via an option in its definition or right in the title - and then run selectively: --grep '@smoke' runs only the marked ones, --grep-invert excludes them. So the same suite serves both a fast smoke on every push and a full regression on a schedule, and the split runs along tags, not the directory structure.
There is also a group level - test.describe.configure. It sets the mode and parameters for the whole group: for example, put a describe into serial or assign it its own retries. This is a pointed tuning of behavior where it is really needed, instead of a global config change for one group of scenarios. It is important to apply it consciously - serial mode, as we saw, blocks parallelism and is justified only for an indivisible process.
The typical failures at this level are lost organization and misused annotations. A giant test with not a single step, from whose failure it is unclear where it broke. test.skip with no condition and no reason, quietly disabling a scenario forever instead of a temporary exclusion with an explanation. And tags placed unsystematically, from which no meaningful set can be assembled. Break a scenario into steps, mark skips with a condition and a reason, and keep tags as a deliberate map of sets.
test('order checkout', { tag: '@smoke' }, async ({ page }) => {
await test.step('sign in', async () => {
await page.goto('/login')
// ...
})
await test.step('check out and pay', async () => {
// ...
await expect(page.getByRole('status')).toContainText('Order paid')
})
})
test.skip(({ browserName }) => browserName === 'webkit', 'unsupported in WebKit')
test.slow() // an objectively long scenario - raise the limit for it alone