Test organisation: tags, hooks, steps, annotations
With 20 tests you need no organisation; with 500, the lack of it makes the suite unmanageable.
Grouping: test.describe('Checkout') blocks split a file logically and create hierarchy in the report. Nesting is possible, but going deeper than two levels hurts readability. test.describe.configure({ mode: 'serial' }) makes tests sequential and dependent (if one fails, the rest are skipped) — a last resort, because it kills parallelism and independence. mode: 'parallel' runs even same-file tests in parallel.
Tags and selective execution: the modern syntax is test('...', { tag: ['@smoke', '@critical'] }, async ({ page }) => ...); putting @smoke in the title also works. Running: --grep @smoke, --grep-invert @slow, logical expressions: --grep "@smoke|@critical". A practical tag set: @smoke (the critical minimum that runs on PRs), @regression, @slow, @flaky (quarantine), @wip.
The main value of selective execution: a 5-minute smoke on PRs and full regression nightly — developers get fast feedback while the team keeps broad coverage.
Hooks vs fixtures — a frequently asked comparison:
beforeEach/afterEach— simple, imperative, familiar to everyone. The problems: they apply to EVERY test in the describe block (needed or not), they aren't typed and they don't compose.- Fixtures are declarative: a test lists the fixtures it wants as arguments, and only then do they run. Typed, composable (a fixture can depend on a fixture), with guaranteed teardown.
A practical rule: resources and data → fixtures; simple universal setup → hooks. A beforeEach doing page.goto('/') is fine; creating a user must be a fixture.
Annotations document a test's status IN CODE:
test.skip()— temporary disabling; always with a reason and a tickettest.fixme()— the test or the feature is broken and a fix is pending (unlike skip, the intent is explicit)test.fail()— the test is EXPECTED to fail; if it passes, it reports as a failure — ideal for a known bugtest.slow()— triples the timeout (instead of deleting the test)test.only()— local debugging only; a lint rule must block it from reaching maintest.describe.configure({ retries: 2 })— retries scoped to one group
test.step(), meanwhile, structures the INSIDE of a test: named stages appear in the report and the trace.
| Annotation | Meaning | When to use |
|---|---|---|
| test.skip(reason) | The test is not run | A precondition isn't met (e.g. the feature is absent in this environment) — a reason is required |
| test.fixme(reason) | The test is broken and a fix is pending | Quarantine — together with a ticket link |
| test.fail() | Failure is EXPECTED; passing raises an alarm | A known unfixed product bug — you learn the moment it's fixed |
| test.slow() | Timeout tripled | A genuinely long flow (report generation, a batch job) |
| test.only() | Only this test runs | Local debugging; must be lint-blocked from reaching main |
A strong interview signal: knowing the difference between test.skip() and test.fail(). skip hides the problem — the test doesn't run and nobody remembers it; fail tracks it — the moment the bug is fixed, the test reports "unexpectedly passed". Add this: in our repo a skip always carries a ticket link, otherwise quarantine turns into a permanent graveyard.
📚 Sources and documentation
- Annotationsofficialplaywright.dev
- Projectsofficialplaywright.dev