Resource library

QA How-To

Playwright test retry annotation: Examples and Best Practices

Copy playwright test retry annotation examples for scoped retries, unique data, fixtures, artifacts, polling, quarantine, reporters, and stable CI tests.

16 min read | 2,610 words

TL;DR

The strongest examples pair visible retry configuration with structured metadata and isolated test data. Use full retries for residual run-level instability, expect.poll() for one converging value, and a tagged job for temporary quarantine.

Key Takeaways

  • Place a one-test retry exception in a describe block and annotate its owner and issue.
  • Include the retry index in external test-data identifiers when failed attempts can leave records.
  • Centralize controlled retry recovery in an idempotent fixture when many tests need it.
  • Use expect.poll() for one eventually consistent read instead of replaying a complete test.
  • Run quarantined tests separately with unchanged assertions and visible outcomes.
  • Keep reporters observational and distinguish tests from individual attempts.

Playwright test retry annotation examples need one clarification before the recipes: annotations describe a test, while retry settings control whether Playwright reruns it. There is no built-in annotation that grants a single test another attempt. Use retries or test.describe.configure({ retries }), then add annotations and testInfo.retry logic when they improve accountability or diagnostics.

The examples below treat a retry as an engineering signal. Each pattern keeps assertions unchanged, protects test isolation, and makes the first failure visible. You can copy the TypeScript structures into a current Playwright Test project and adapt only the application routes and test-data endpoints.

TL;DR

  • Set a small retry count in configuration, commonly enabled only in CI.
  • Put a one-test exception inside a dedicated describe with test.describe.configure().
  • Add owner, issue, and retry-policy annotations for traceability.
  • Use testInfo.retry to select fresh data or attach evidence, never to weaken checks.
  • Prefer locator assertions or expect.poll() when only one asynchronous condition needs polling.
Failure pattern First choice Full test retry?
Element appears after an API response Web-first assertion Usually no
Backend job reaches a terminal state expect.poll() Usually no
Remote grid disconnects intermittently Small configured retry plus artifacts Possibly
Parallel tests reuse one customer Fix data isolation No
Known sandbox instability with an owner Scoped retry plus annotation Temporarily

1. Playwright Test Retry Annotation Examples: The Base Configuration

Start with one predictable policy. Local runs get immediate failure feedback, while CI allows two retries and records the first retry trace. The total maximum is three attempts because the initial execution is not counted as a retry.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  reporter: [
    ['line'],
    ['html', { outputFolder: 'playwright-report', open: 'never' }],
  ],
  use: {
    baseURL: process.env.BASE_URL ?? 'https://example.test',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
});

This configuration says nothing about why an individual test is eligible. That policy belongs in team documentation or structured annotations on exceptions. Avoid setting retries: 5 during an incident and forgetting it. Every extra attempt multiplies setup, data creation, browser time, and artifact volume.

Run-time overrides are useful for diagnosis:

npx playwright test tests/orders.spec.ts --project=chromium --retries=2

The CLI flag changes the command, not the repository policy. Record the exact command when sharing a reproduction. If a test only passes under repeated execution, use --repeat-each separately to expose frequency. repeat-each creates independent planned runs, while retries reacts to failures.

For a conceptual explanation of all retry controls, read how to use Playwright test retry annotation. The remainder here concentrates on implementation recipes and code-review standards.

2. Example: A Scoped Retry With Owner and Issue Annotations

Suppose a payment sandbox occasionally delays a callback beyond the environment service-level target. The team has a tracked issue and wants two temporary retries only for that behavior. Wrap the test in a group of one.

import { test, expect } from '@playwright/test';

test.describe('payment sandbox callback', {
  tag: ['@payments', '@sandbox'],
  annotation: [
    { type: 'owner', description: 'checkout-sdet' },
    { type: 'issue', description: 'QA-2719' },
    {
      type: 'retry-policy',
      description: 'Two retries until sandbox callback fix is verified',
    },
  ],
}, () => {
  test.describe.configure({ retries: 2 });

  test('shows the authorized payment', async ({ page }) => {
    await page.goto('/checkout/result/qa-order-102');
    await expect(page.getByTestId('payment-state')).toHaveText('Authorized', {
      timeout: 15_000,
    });
  });
});

The annotation is not the switch. The configure call is. Keeping the two adjacent makes the exception auditable. When QA-2719 is closed, delete the scoped configuration and the retry-policy annotation together.

Do not add a special retry because the status takes 15 seconds. The locator assertion already waits up to the stated bound. The retry addresses a distinct sandbox failure that sometimes exceeds that contract. If the product requirement itself permits longer processing, adjust the bounded assertion based on the requirement instead of rerunning checkout.

Use tags for selection, annotations for context, and the title for expected behavior. This separation produces reports that are readable by both engineers and release stakeholders.

3. Example: Generate Unique Data for Every Attempt

A retry executes in a new worker, but external records from the failed attempt can remain. Generate an identifier that includes the retry index so a second attempt never collides with the first. testInfo.outputPath() also provides a safe per-test output location for temporary artifacts.

import { test, expect } from '@playwright/test';

test('creates a uniquely named workspace', async ({ page }, testInfo) => {
  const workspaceName = [
    'pw',
    testInfo.workerIndex,
    testInfo.retry,
    Date.now(),
  ].join('-');

  testInfo.annotations.push({
    type: 'test-data',
    description: `workspace=${workspaceName}`,
  });

  await page.goto('/workspaces/new');
  await page.getByLabel('Workspace name').fill(workspaceName);
  await page.getByRole('button', { name: 'Create workspace' }).click();
  await expect(page.getByRole('heading', { name: workspaceName })).toBeVisible();
});

The timestamp is not used to prove behavior, only to make test data unique. If reproducibility requires a stable seed, derive a value from testInfo.testId, workerIndex, and retry through a local helper. Never depend on the order in which parallel workers happen to run.

The test-data annotation helps an engineer find the exact record after a failure. Confirm that identifiers contain no secret or personal data before putting them in a report. A better enterprise pattern also adds a run ID supplied by CI so environment cleanup can delete all records from one run.

Unique data is usually safer than retry-only cleanup. It preserves the failed attempt for inspection while allowing the next attempt to start cleanly.

4. Example: Retry-Aware Cleanup in an Automatic Fixture

Centralize recovery when many tests use the same controlled sandbox. An automatic fixture can reset state before later attempts and attach the reset response. The test itself remains focused on behavior.

// retry-fixtures.ts
import { test as base, expect } from '@playwright/test';

type Fixtures = {
  retryRecovery: void;
};

export const test = base.extend<Fixtures>({
  retryRecovery: [async ({ request }, use, testInfo) => {
    if (testInfo.retry > 0) {
      const response = await request.post('/test-api/recover-sandbox', {
        data: { testId: testInfo.testId },
      });

      const body = await response.text();
      await testInfo.attach('retry-recovery-response', {
        body,
        contentType: 'application/json',
      });
      expect(response.ok()).toBeTruthy();
    }

    await use();
  }, { auto: true, title: 'recover sandbox before retry' }],
});

export { expect };
// preferences.spec.ts
import { test, expect } from './retry-fixtures';

test('saves digest preference', async ({ page }) => {
  await page.goto('/preferences');
  await page.getByLabel('Weekly digest').check();
  await page.getByRole('button', { name: 'Save changes' }).click();
  await expect(page.getByRole('status')).toHaveText('Changes saved');
});

Only adopt this pattern if the reset endpoint is idempotent and test-scoped. A global database cleanup in parallel execution can erase another worker's records. The fixture title shows the recovery in reports, and the response attachment creates evidence when recovery itself fails.

An automatic fixture runs even when its name is not listed in the test signature. Keep it small and publish it through an obvious fixture entry point. Hidden, expensive recovery can confuse local performance and should be documented.

5. Example: Attach More Evidence on Later Attempts

It is reasonable to collect lightweight evidence on every failure and add more detail when a retry starts. Do not wait until after a passing retry to investigate, because the passing state may no longer contain the failure cause.

import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }, testInfo) => {
  if (testInfo.retry > 0) {
    testInfo.annotations.push({
      type: 'diagnostics',
      description: `enhanced logging enabled for retry ${testInfo.retry}`,
    });

    page.on('console', message => {
      if (message.type() === 'error') {
        console.log(`[browser] ${message.text()}`);
      }
    });
  }
});

test('loads the account summary', async ({ page }, testInfo) => {
  await page.goto('/account');

  if (testInfo.retry > 0) {
    const screenshot = await page.screenshot({ fullPage: true });
    await testInfo.attach('retry-start', {
      body: screenshot,
      contentType: 'image/png',
    });
  }

  await expect(page.getByRole('heading', { name: 'Account summary' }))
    .toBeVisible();
});

Playwright configuration can already capture screenshots on failure and traces on retry. Add manual artifacts only when they answer a specific question, such as the state before a workflow begins. Duplicate full-page screenshots add storage without adding evidence.

Console logs can contain tokens or user data. Filter and redact according to the application security model. For network diagnosis, Trace Viewer is often safer and more complete than printing response bodies to CI output.

6. Example: Use expect.poll Instead of Retrying the Test

When only a backend value is eventually consistent, poll that value inside the test. This preserves the browser session and avoids replaying side effects.

import { test, expect } from '@playwright/test';

test('export becomes downloadable', async ({ request, page }) => {
  const createResponse = await request.post('/api/exports', {
    data: { report: 'weekly-quality' },
  });
  expect(createResponse.ok()).toBeTruthy();

  const { id } = await createResponse.json() as { id: string };

  await expect.poll(async () => {
    const statusResponse = await request.get(`/api/exports/${id}`);
    if (!statusResponse.ok()) return `http-${statusResponse.status()}`;
    const body = await statusResponse.json() as { status: string };
    return body.status;
  }, {
    message: 'export should finish processing',
    timeout: 30_000,
    intervals: [500, 1_000, 2_000],
  }).toBe('ready');

  await page.goto(`/exports/${id}`);
  await expect(page.getByRole('link', { name: 'Download export' })).toBeVisible();
});

expect.poll() retries the callback within its own timeout. It does not restart the worker or recreate data. A non-success response is returned as a temporary status value, so the final matcher remains responsible for the bounded retry behavior.

Keep polling callbacks read-only when possible. Repeatedly calling a create or submit endpoint can cause duplicate side effects. Use a bounded interval list and an overall timeout tied to the system contract. This pattern demonstrates why not every intermittent observation deserves a full test retry.

7. Example: Separate Infrastructure Retries From Product Assertions

Teams sometimes wrap every action in try/catch and rerun it. That hides the source of instability and bypasses Playwright's result classification. Keep product assertions direct. If a setup call to a controlled service has a documented transient behavior, retry that call with a small, explicit helper outside browser assertions.

import { expect, type APIRequestContext } from '@playwright/test';

export async function provisionTenant(
  request: APIRequestContext,
  name: string,
): Promise<void> {
  const attempts = 2;

  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    const response = await request.post('/test-api/tenants', {
      data: { name },
    });

    if (response.ok()) return;

    if (attempt === attempts) {
      expect(response.ok(), `tenant provisioning failed: ${response.status()}`)
        .toBeTruthy();
    }
  }
}

This helper retries only a known provisioning boundary and makes the final failure explicit. It does not sleep, weaken assertions, or swallow a non-success response. In a real suite, limit retries to documented retryable statuses rather than treating every response as transient.

Use full test retries for process-level or environment-level intermittency that cannot be handled more narrowly. Use locator waiting for UI readiness, expect.poll() for observable backend convergence, and service-client logic for documented API retry semantics. Each layer has a different owner and failure signal.

8. Example: Quarantine Selection Without Hiding Results

A quarantine is a routing decision, not an expected-pass trick. Tag known flaky tests, keep an issue annotation, and run them in a separate CI job with the same assertions. Do not mark them test.fail() merely because they are inconsistent, because test.fail() expects failure and complains if the test unexpectedly passes.

import { test, expect } from '@playwright/test';

test('third-party tax quote is displayed', {
  tag: ['@checkout', '@quarantine'],
  annotation: [
    { type: 'owner', description: 'checkout-quality' },
    { type: 'issue', description: 'QA-3004' },
  ],
}, async ({ page }) => {
  await page.goto('/cart/qa-tax-sandbox');
  await expect(page.getByTestId('tax-total')).toHaveText(/\$\d+\.\d{2}/);
});

Run the primary set and quarantine set explicitly:

npx playwright test --grep-invert @quarantine
npx playwright test --grep @quarantine --retries=2

The quarantine job should still report failures and flaky passes. Give it an owner, a time limit, and visible status. A test that remains in quarantine indefinitely stops protecting the release.

Do not use title substrings as the only quarantine registry. Structured tags are filterable, and annotations preserve the reason. When the issue is resolved, remove both in the same change and run the test repeatedly under representative concurrency.

9. Example: Create a Retry-Focused Reporter

Playwright's built-in reporters already classify outcomes. A small custom reporter can emit structured retry events to an approved metrics collector or a local JSON-line stream. The example below only logs and uses the documented reporter interfaces.

// retry-events-reporter.ts
import type {
  Reporter,
  TestCase,
  TestResult,
} from '@playwright/test/reporter';

export default class RetryEventsReporter implements Reporter {
  onTestEnd(test: TestCase, result: TestResult) {
    if (result.retry === 0) return;

    const event = {
      testId: test.id,
      title: test.titlePath().join(' > '),
      retry: result.retry,
      status: result.status,
      project: test.parent.project()?.name ?? 'unknown',
    };

    console.log(JSON.stringify(event));
  }
}
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [
    ['line'],
    ['./retry-events-reporter.ts'],
  ],
});

If an installed Playwright version or custom typing setup does not expose a convenience used by your reporter, rely on the current reporter API types in that repository. Keep the reporter observational. It should not mutate annotations, retry tests itself, or decide to ignore failures.

Metrics should distinguish tests from attempts. Counting three failed attempts as three flaky tests exaggerates breadth, while counting only the final pass hides instability. Store both the test-level final category and attempt-level evidence.

Keep retry metrics low in cardinality and safe to retain. Test IDs, project names, sanitized error classes, and stable step titles are generally more useful than full dynamic titles or payloads. If results are sent outside the CI system, follow the same access and retention controls applied to test artifacts. A reporting pipeline should fail visibly when delivery is unavailable, but it should not rewrite the Playwright test outcome.

Use a dashboard to prioritize patterns, not to rank engineers. A cluster of navigation timeouts on one runner image suggests an infrastructure investigation. A repeated strictness error in one test suggests a locator review. The metric is valuable when it leads to a specific owner and corrective action, not when it becomes a target that encourages teams to hide retries.

10. Example: Verify Retry Behavior With a Controlled Probe

You can prove configuration in a disposable local test that fails the first attempt and passes the next. Do not commit intentionally flaky probes to the normal suite. This example writes only inside the test's output directory, which Playwright manages for the test run.

// retry-probe.spec.ts
import { promises as fs } from 'node:fs';
import { test, expect } from '@playwright/test';

test.describe.configure({ retries: 1 });

test('retry configuration probe', async ({}, testInfo) => {
  const marker = testInfo.outputPath('attempt-marker.txt');

  if (testInfo.retry === 0) {
    await fs.writeFile(marker, 'initial attempt', 'utf8');
    expect(testInfo.retry).toBe(1);
  }

  expect(testInfo.retry).toBe(1);
});

The output directory may be cleaned between attempts depending on runner artifact lifecycle, but the test does not rely on reading the marker. It relies on the documented retry index. Run it directly with npx playwright test retry-probe.spec.ts, observe one failure and one pass, then remove it.

For repeatable framework verification in a repository, a custom fixture that deliberately fails is rarely worth the maintenance risk. Prefer unit tests for configuration helpers and review effective CI command output. The probe is a teaching or troubleshooting tool.

11. Best Practices for Playwright Test Retry Annotation Examples

The most reliable examples follow a few enforceable rules:

  1. Keep retry configuration in one visible policy layer, with tightly scoped exceptions.
  2. Use a custom annotation only for metadata, never as imagined runner control.
  3. Record an owner and issue for each elevated or quarantined retry policy.
  4. Keep the same assertions, inputs, and acceptance criteria on every attempt.
  5. Make test data unique by run, worker, test, and retry where collisions are possible.
  6. Capture the initial failure and a first-retry trace according to storage policy.
  7. Prefer web-first assertions, expect.poll(), or a narrow service retry when they match the failing boundary.
  8. Treat a flaky pass as debt, not as equivalent to a first-attempt pass.
  9. Remove annotations, tags, and overrides when the linked issue is fixed.
  10. Never expose credentials or personal data through test metadata and artifacts.

Add a small framework test for helpers that generate unique identifiers, sanitize attachments, or classify retry events. Those helpers can often be tested without a browser. Keep an end-to-end smoke scenario for the integrated policy, but do not create a permanent intentionally flaky test just to prove the runner retries. Configuration review, a disposable probe, and reporter contract tests provide stronger maintenance value.

During code review, ask what changed between attempts. If the answer is only time, a bounded assertion or poll may be the correct primitive. If the answer is a fresh worker or remote connection, a full retry may be defensible. If the answer is weaker validation, reject the pattern because it changes the requirement instead of stabilizing its observation.

When retry failures originate in setup, audit lifecycle behavior using Playwright test fixtures override examples. When a long end-to-end flow needs readable evidence, structure one independent test with Playwright test.step examples and best practices rather than a serial chain that retries as a group.

Interview Questions and Answers

Q: Can a custom retry annotation make Playwright rerun a test?

No. Custom annotations are report metadata. Configure reruns through retries, the --retries option, or test.describe.configure({ retries }).

Q: How would you give one test a different retry count?

Place that test inside a dedicated test.describe() block and call test.describe.configure({ retries: n }) in the block. Add owner and issue annotations to explain the exception. Playwright test details do not have a retries field.

Q: Why include testInfo.retry in a data identifier?

The first failed attempt may leave external data behind. Including the retry index makes the next attempt use another record and avoids duplicate-name collisions. The technique supports isolation without changing assertions.

Q: When is expect.poll() better than a test retry?

Use it when one read-only value must converge over time, such as an export status. It polls inside the current test and avoids replaying login, setup, and creation side effects. The polling callback should be bounded and preferably read-only.

Q: How should a team treat a test that passes on retry?

Treat it as flaky and investigate the first failure. Track the outcome by owner and failure signature, preserve useful artifacts, and remove any exception after the cause is fixed. A flaky pass is useful diagnostic information, not a clean pass.

Q: What happens to worker fixtures during a retry?

The failed worker is discarded, so its worker-scoped fixtures are torn down. A new worker creates them again before the retry. External resources survive unless teardown or a test-data service removes them.

Q: Is test.fail() appropriate for quarantining flaky tests?

Usually no. It declares failure as the expected result, so an unexpected pass becomes a problem too. A separate tagged quarantine job preserves the real assertion and reports both failures and flaky passes.

Common Mistakes

  • Writing { retries: 2 } in a test details object, which is not a supported field.
  • Naming an annotation retry and assuming Playwright gives it execution semantics.
  • Using an elevated retry count without an owner, issue, or expiry condition.
  • Replaying a create operation when only a read-only status needed polling.
  • Reusing the same database key on all attempts and blaming duplicate failures on the runner.
  • Collecting evidence only after a retry passes, after the original state is gone.
  • Printing full request or response bodies that contain tokens or customer information.
  • Marking intermittent behavior with test.fail() and losing accurate pass and flaky categories.
  • Letting reporters change results instead of observing and publishing them.
  • Committing an intentionally failing retry probe into the normal regression suite.

Conclusion

Strong Playwright test retry annotation examples make the control plane explicit: retry settings rerun tests, annotations document policy, and testInfo.retry identifies the current attempt. The recipes in this guide keep retries isolated, observable, and bounded while preserving the same product assertion each time.

Adopt the base CI configuration first. Add scoped exceptions only with ownership, prefer narrow polling when the failure boundary permits it, and use flaky outcomes as input to engineering work rather than as a reason to raise the retry count.

Interview Questions and Answers

Show the supported way to retry one Playwright test.

I place the test inside a dedicated test.describe() block and call test.describe.configure({ retries: 2 }) there. I also add structured owner and issue annotations. There is no retries field in the test details object.

How do you prevent retry attempts from colliding with external data?

I generate unique records per test and attempt, or use an idempotent test-data API scoped to the test. I do not rely on module variables because the retry runs in a new worker. Cleanup must tolerate partial setup and already missing records.

Give a case where expect.poll is better than a retry.

An asynchronous export status is a good example. The create operation should happen once, then expect.poll() can read status until it becomes ready within a bound. Retrying the whole test could create duplicate exports.

What is a safe use of testInfo.retry?

A safe use is adding the index to a unique data key or enabling extra sanitized diagnostics. Both preserve the same behavior and assertions. Skipping validation or accepting another result on retry is unsafe.

How would you design a quarantine workflow?

I would tag the test, add an owner and issue annotation, and run it in a separate CI job. The assertions remain unchanged, and failures and flaky passes remain visible. The quarantine has a removal condition and does not block unrelated diagnostics.

What should retry metrics count?

They should preserve both test-level final classification and attempt-level results. Counting only attempts exaggerates the number of affected tests, while counting only final passes hides instability. Grouping first failures by signature helps identify shared causes.

Why keep a retry reporter observational?

Execution decisions belong to the runner, which already manages worker replacement and classification. A reporter should publish facts such as retry index, status, project, and annotations. Mutating results in the reporter makes behavior harder to reproduce and trust.

Frequently Asked Questions

What is a correct Playwright retry example for one test?

Wrap the test in a dedicated describe block and set test.describe.configure({ retries: 2 }). Put owner, issue, and retry-policy annotations on the block so the exception is visible and removable.

How can Playwright avoid duplicate data on retry?

Generate unique identifiers using stable run data plus worker and retry indexes. Alternatively, use an idempotent test-data service that cleans only records owned by the current test.

Can a fixture inspect the retry number?

Yes. Fixtures receive TestInfo or WorkerInfo in the third callback argument according to scope. A test-scoped fixture can inspect testInfo.retry and perform controlled recovery or diagnostics.

When should I use expect.poll instead of retries?

Use expect.poll() when one read-only value must eventually reach a state, such as a job becoming ready. It avoids repeating setup and side effects from the entire test.

How should flaky Playwright tests be quarantined?

Tag them and execute them in a separate visible CI job with the same assertions. Add an owner and issue, preserve pass, flaky, and failed outcomes, and define a removal date or condition.

Can a custom reporter trigger a retry?

A reporter should observe test attempts and publish results, not implement reruns or mutate outcomes. Retry execution belongs to the Playwright runner configuration.

How do I test that retry configuration works?

Use a disposable, directly invoked probe that fails when testInfo.retry is zero and passes when it is one. Observe the result, then remove the intentionally failing probe from the normal suite.

Related Guides