In a Playwright test, what does testInfo.retry hold, and why would a hook read it?
answer
- A zero-based attempt counter
- Second argument to the test function
- Also reachable through test.info()
- Guard fixture setup on it
- It never explains the previous failure
basics
~20 stestInfo.retry is the zero-based attempt number of the running Playwright test: 0 on the first run, 1 on the first retry, and so on. Hooks read it to reset state or raise logging only when a test is being re-run.
solid answer
~40 s`testInfo.retry` is the index of the current attempt, exposed on the `TestInfo` object that arrives as the second argument of a test or hook (`async ({ page }, testInfo)`) and is also reachable anywhere through `test.info()`. It is zero on the original run and increments by one per retry, so `testInfo.retry > 0` means "this is a re-run". Hooks branch on it to make retries meaningful without slowing the happy path: reseeding data the failed attempt left behind, namespacing records so the second attempt does not collide with the first, turning on verbose logging, or attaching extra diagnostics. It does not say why the previous attempt failed — for that, read `testInfo.status` and `testInfo.error` in an `afterEach`, which describe the attempt that has just finished.
code
typescript · 12 linesimport { test, expect } from '@playwright/test';
test.beforeEach(async ({ request }, testInfo) => {
if (testInfo.retry > 0) {
await request.post('/api/payroll/test-data/reset');
}
});
test('payslip shows net pay', async ({ page }) => {
await page.goto('/payroll/runs/latest');
await expect(page.getByRole('cell', { name: 'Net pay' })).toBeVisible();
});go deeper
Recall that it is a number on the testInfo argument, that zero means the first run, and that you read it rather than set it.
Explain the zero-based numbering and where testInfo comes from: the second argument of a test or hook, or test.info() from a helper with no argument in scope.
Demonstrate a real use: making the retry start from a clean payroll state, or namespacing data per attempt so the retry does not fail on a record the first attempt created.
Frame the boundary. Branching on the attempt number to repair state is sound; branching on it to weaken an assertion converts a real defect into a green run and should not survive review.
## Where the value comes from Every Playwright test, hook and fixture can receive a `TestInfo` object describing the currently running test. It arrives as the **second argument** of the test function, after the fixtures object: ```typescript test('payslip totals', async ({ page }, testInfo) => { console.log(testInfo.title, testInfo.retry); }); ``` The same object is available anywhere inside a running test through `test.info()`, which matters for helper modules and page objects that never receive the argument. `testInfo.retry` is one field on it, alongside `testInfo.title`, `testInfo.project`, `testInfo.status`, `testInfo.outputDir` and `testInfo.attach()`. ## The numbering `testInfo.retry` is **zero-based**: it is the index of the current attempt, not a count of attempts made. | Attempt | `testInfo.retry` | Meaning | |---|---|---| | First run | `0` | The test has not been retried | | First retry | `1` | The previous attempt failed | | Second retry | `2` | Two previous attempts failed | So `testInfo.retry > 0` is the idiomatic test for "this is a re-run", and `testInfo.retry === 0` for "this is the original attempt". A test running in a configuration with no retries always sees `0`. ## What it is good for - **Repairing state before a re-run.** In a payroll regression suite the failed attempt may have left a partially approved pay run behind; a `beforeEach` that reseeds only when `testInfo.retry > 0` keeps the happy path fast while making the retry meaningful. - **Raising the diagnostic level on a second attempt.** Turning on verbose application logging, extra `testInfo.attach()` payloads, or a slower step-by-step path only when retrying keeps the normal run cheap. - **Namespacing data per attempt.** Deriving an employee id from `testInfo.retry` avoids unique-constraint collisions between the row attempt one created and the row attempt two wants to create. - **Widening a budget for the re-run.** `test.setTimeout()` can be given a larger value when `testInfo.retry > 0`, on the theory that the retry is competing with a busier machine. ## What it cannot tell you `testInfo.retry` is a counter and nothing more: 1. It does not say **why** the previous attempt failed. The error from attempt one is not handed to attempt two; if you want it, capture it in an `afterEach`, where `testInfo.status` and `testInfo.error` describe the attempt that has just finished. 2. It is not a licence to change the assertion. Skipping the test, softening a matcher, or short-circuiting the body when `testInfo.retry > 0` produces a green run that proves nothing — the reported flaky verdict then describes the framework's behaviour, not the application's. 3. It carries no history across runs. It resets to `0` for every test in every fresh invocation of the runner. ## Attempt-scoped output paths `testInfo.outputDir` and `testInfo.outputPath()` are unique per attempt, so a retry writes its trace, video and any file you produce into a different directory from the attempt before it. That is why the HTML report can show attempt one and attempt two side by side with their own attachments: nothing overwrites anything. If a test writes a downloaded payslip PDF with `testInfo.outputPath('payslip.pdf')`, each attempt gets its own copy, and the one belonging to the failed attempt is still there when someone opens the report. ## A worked shape The common pattern is a hook that is a no-op on the first attempt: ```typescript test.beforeEach(async ({ request }, testInfo) => { if (testInfo.retry > 0) { await request.post('/api/payroll/test-data/reset'); } }); ``` This costs nothing on the thousands of tests that pass first time and makes the handful that retry start from a known state. The alternative — resetting unconditionally — is correct too, but on a large suite it pays the reset cost on every test to benefit the few that need it, which is exactly the trade `testInfo.retry` exists to let you make.
- How do you read the attempt number from a page object that never receives testInfo?Call `test.info()`. It returns the `TestInfo` for the currently running test from anywhere inside it, so helpers and page objects can read `test.info().retry` without threading the argument through every call. It throws if called outside a running test.
- Can testInfo.retry tell you why the previous attempt failed?No. It is a counter and carries no history. To capture a failure, use an `afterEach` hook where `testInfo.status` and `testInfo.error` describe the attempt that has just finished, and attach whatever diagnostics you need there.
saying these in an interview costs you the question
- Thinks testInfo.retry starts at one on the first run
- Believes it is only available to reporters after the run
- Uses it to skip or soften a test on the last attempt
- Expects it to expose the previous attempt's error
- Assumes module-level state survives into the retry