Playwright Test Runner
Playwright Test is the test runner that ships with Playwright: it groups tests, runs setup and teardown hooks, runs files in parallel across worker processes, retries failures and reports the results. This guide covers each feature with one example spec that was actually run — the output shown below is real.
The Example Spec
import { test, expect } from '@playwright/test';
test.describe('Cart', () => {
let items: string[];
test.beforeEach(async () => { items = ['backpack']; });
test.afterEach(async ({}, testInfo) => { console.log(`[afterEach] ${testInfo.title}: ${testInfo.status}`); });
test('adds an item', async () => {
items.push('bike light');
expect(items).toHaveLength(2);
});
test('starts with one item', { tag: '@smoke' }, async () => {
expect(items).toEqual(['backpack']); // fresh array from beforeEach
});
test('flaky on first attempt', async ({}, testInfo) => {
expect(testInfo.retry).toBeGreaterThan(0); // fails on attempt 0, passes on the retry
});
test.skip('coupon feature not deployed yet', async () => {});
test.fixme('known bug BUG-512: tax rounding', async () => {});
});
test.describe('Checkout flow', () => {
test.describe.configure({ mode: 'serial' });
let orderId = '';
test('create order', async () => { orderId = 'ORD-1001'; });
test('pay for order', async () => { expect(orderId).toBe('ORD-1001'); });
});
Run with retries: 1 in the config, the result was:
Running 7 tests using 1 worker
[afterEach] adds an item: passed
✓ Cart › adds an item
[afterEach] starts with one item: passed
✓ Cart › starts with one item @smoke
[afterEach] flaky on first attempt: failed
✘ Cart › flaky on first attempt
[afterEach] flaky on first attempt: passed
✓ Cart › flaky on first attempt (retry #1)
- Cart › coupon feature not deployed yet
…
1 flaky
2 skipped
4 passed
test.describe — Grouping Tests
test.describe() groups related tests into a suite (like a TestNG class): Login, Cart, Checkout. Groups can be nested, share hooks, and appear as sections in the report ("Cart › adds an item"). Teams typically organise specs module by module, one describe per feature.
Hooks
| Hook | Runs | Typical use |
|---|---|---|
test.beforeEach |
Before every test in scope | Open the page, reset state |
test.afterEach |
After every test (pass or fail) | Clean up data, log results |
test.beforeAll |
Once per worker, before the tests in scope | Create shared test data via API |
test.afterAll |
Once per worker, after the tests | Delete shared data |
In the run above, beforeEach gave every test a fresh items array — so "starts with one item" passed even though the previous test had added to its own copy. testInfo.status in afterEach tells you whether the test passed or failed.
For page setup and logged-in sessions, Playwright's fixtures are usually cleaner than hooks: a fixture sets something up only for the tests that ask for it. See Storage State & Session Reuse.
only, skip, fixme and Tags
| Annotation | Effect | When |
|---|---|---|
test.only() |
Run only the focused test(s) — every other test in the run is ignored | Local debugging — never commit it (forbidOnly: !!process.env.CI fails CI if you do) |
test.skip() |
Don't run; reported as skipped | Feature not deployed, not relevant for a browser |
test.fixme() |
Don't run; marked as needing a fix | A known bug or broken test — reference the ticket |
test.fail() |
Runs and expects to fail | Documenting a known defect; alerts you when it's fixed |
test.slow() |
Triples the timeout | Genuinely slow flows |
{ tag: '@smoke' } |
Label for filtering | Smoke, regression, module groups |
skip vs fixme: both stop the test running; skip means "not applicable right now", fixme means "broken, needs work". Skips can also be conditional: test.skip(browserName === 'webkit', 'Not supported in Safari').
Running Specific Tests
npx playwright test # everything
npx playwright test tests/cart.spec.ts # one file
npx playwright test cart.spec.ts:13 # the test at line 13
npx playwright test --grep "adds an item" # by title
npx playwright test --grep @smoke # by tag — ran only "starts with one item @smoke"
npx playwright test --grep-invert @slow # everything except a tag
npx playwright test --project=firefox # one browser project
npx playwright test --list # list tests without running them (7 tests in our file)
npx playwright test --last-failed # rerun only the failures from the last run
Parallel Execution and Workers ⭐
Playwright runs tests in separate worker processes, each with its own browser. By default, different files run in parallel, but tests inside one file run one after another in the same worker. That's why our single-file run said "using 1 worker" even with workers: 2 configured.
export default defineConfig({
workers: process.env.CI ? 2 : undefined, // undefined = half the CPU cores
fullyParallel: true, // also parallelise tests inside each file
});
With --fully-parallel, the same file ran "using 2 workers". Command-line equivalents: --workers=4, --fully-parallel, and --workers=1 to force everything serial when debugging.
Analogy: workers are bank counters — more counters serve more customers at once, but only if customers don't need to be served in a particular order. Parallel runs are safe only when tests are independent: each creates its own data and doesn't rely on another test's result.
Serial Mode for Dependent Tests
test.describe('Checkout flow', () => {
test.describe.configure({ mode: 'serial' });
// tests run in order, in one worker; if one fails, the rest are skipped
});
The "pay for order" test used the orderId set by "create order" and passed because both ran in order. Serial mode is the modern replacement for test.describe.serial(). Use it sparingly: independent tests are faster and easier to debug — create the order via an API in beforeEach instead where you can.
Retries and Flaky Tests
export default defineConfig({
retries: process.env.CI ? 2 : 0, // retry in CI, fail fast locally
use: { trace: 'on-first-retry' }, // record a trace when a retry happens
});
In the run above, "flaky on first attempt" failed, was retried, passed — and Playwright reported it as 1 flaky, not as a pass. That distinction is the point: retries keep a temporary glitch from failing the pipeline, while the flaky label and the trace tell you which tests need fixing. A real defect fails on every attempt. Command line: --retries=2. testInfo.retry tells a test which attempt it's on (for example, to clear state before retrying). Debugging failures: Trace Viewer & Reports.
Coming From TestNG
| TestNG | Playwright Test |
|---|---|
| Test class | test.describe() |
@BeforeMethod / @AfterMethod |
test.beforeEach / test.afterEach |
@BeforeClass |
test.beforeAll |
enabled = false |
test.skip() |
groups = "smoke" |
{ tag: '@smoke' } + --grep |
parallel="methods" thread-count="4" |
fullyParallel: true, workers: 4 |
dependsOnMethods |
Serial mode |
IRetryAnalyzer |
retries |
Convert more Selenium and TestNG code in the Selenium ⇄ Playwright ⇄ Cypress Translator, and compare with the TestNG Cheat Sheet.
From Real Projects
My own automation experience is with Selenium WebDriver, Java and TestNG in a POM-based hybrid framework, running in batch, parallel and cross-browser mode. The concepts on this page carry straight over to Playwright — page objects, independent tests, parallel execution and reliable synchronisation — which makes it a natural next tool for Selenium testers. The biggest difference you'll notice is how much waiting Playwright handles for you. Testing Testsigma — a platform where users write automated tests in plain English — meant thinking like its users, who are testers themselves. Treat retries as a signal to investigate, not a fix.
📚 Official documentation: Selenium Grid documentation · Selenium documentation
FAQs
What is test.describe()?
A way to group related tests into a suite that shares hooks and configuration and appears as a section in reports.
What's the difference between beforeEach and beforeAll?
beforeEach runs before every test; beforeAll runs once per worker before the tests in its scope.
What is the difference between test.skip() and test.fixme()?
Both prevent the test from running. skip signals "not applicable now"; fixme signals "known broken — needs fixing".
Do tests in the same file run in parallel?
Not by default — files run in parallel, tests within a file run in order. Set fullyParallel: true to parallelise inside files.
How do you run tests one after another?
test.describe.configure({ mode: 'serial' }) for a group, or --workers=1 for the whole run.
How do retries work?
A failed test is re-run up to retries times. If a retry passes, the test is reported as flaky rather than passed, so it can still be investigated.