Sparround

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 ticket
  • test.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 bug
  • test.slow() — triples the timeout (instead of deleting the test)
  • test.only() — local debugging only; a lint rule must block it from reaching main
  • test.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.

AnnotationMeaningWhen to use
test.skip(reason)The test is not runA 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 pendingQuarantine — together with a ticket link
test.fail()Failure is EXPECTED; passing raises an alarmA known unfixed product bug — you learn the moment it's fixed
test.slow()Timeout tripledA genuinely long flow (report generation, a batch job)
test.only()Only this test runsLocal 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