Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 046 / 100

Test annotations: Playwright best practices explained

Skip, fail and mark tests intentionally.

Expona Admin9 min read

Introduction

By day 46 of your Playwright journey, you've written hundreds of tests, but managing them at scale becomes tricky. You need tests to skip in certain environments, you need to mark flaky tests, and you need your team to understand why a test behaves differently than expected. Test annotations: Playwright best practices explained gives you the toolkit to make your test suite communicate intent and handle real-world complexity without resorting to hacks or brittle workarounds.

Annotations let you shape test behavior and metadata in ways that keep your CI/CD pipeline clean, your reports informative, and your team productive. When used correctly, they transform a messy test suite into a professional one.

Understanding Playwright Annotations

Annotations in Playwright are modifiers that attach metadata or conditional logic to tests without changing the core test code. They influence whether a test runs, how long it waits, and how it appears in reports. Think of them as pragmatic escape hatches for real-world testing scenarios.

The main annotations you'll encounter are test.skip, test.fixme, and test.slow. Each serves a distinct purpose, and combining them with conditions makes your suite adaptive to different environments, browsers, and deployment stages.

test.skip with Conditions

test.skip prevents a test from running entirely. You might skip a test when you know it's not relevant to the current context—a feature only available in production, a browser you're not testing, or a third-party integration that isn't configured in your CI environment.

The real power emerges when you skip conditionally. Instead of deleting tests or commenting them out, you use a boolean expression to decide at runtime whether to skip.

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

// Skip tests in CI if the feature flag is off
test.skip(
  process.env.FEATURE_NEW_CHECKOUT === 'true' === false,
  'New checkout feature not enabled in this environment'
);

test('can complete checkout with new flow', async ({ page }) => {
  await page.goto('/checkout');
  await page.fill('[data-testid="card-number"]', '4242424242424242');
  await page.click('[data-testid="submit"]');
  await expect(page).toHaveURL('/order-confirmation');
});

A more realistic scenario: you're testing a payment processor integration that only works with certain card types in the staging environment, and you want to skip tests on your local machine where the API isn't configured.

typescript
const isProduction = process.env.ENVIRONMENT === 'production';
const isLocalDev = process.env.ENVIRONMENT === 'local';

test.skip(
  isLocalDev,
  'Payment API not configured locally'
);

test('process payment with stripe webhook', async ({ page }) => {
  // This test only runs in staging and production
  await page.goto('/payment');
  // ... payment assertions
});

You can also use a function that receives test details as a parameter, giving you access to browser name, platform, or other metadata:

typescript
test.skip(({ browserName }) => browserName === 'webkit', 'WebKit has rendering issues with this component');

test('render complex svg animation', async ({ page }) => {
  await page.goto('/animations');
  await expect(page.locator('.animated-svg')).toBeVisible();
});

This pattern is essential when you support multiple browsers but encounter platform-specific bugs. Rather than ignore the issue, you document it explicitly in the test itself.

test.fixme for Known Issues

test.fixme marks a test as expected to fail. Unlike test.skip, it still runs, but Playwright treats a failing result as acceptable. You use test.fixme when you've identified a real bug in the application, you've filed a ticket, and you want the test to stay in your suite without breaking your CI pipeline.

This is more honest than skipping, because the test executes and the failure is logged and reported. Your team sees that the issue exists and isn't being hidden.

typescript
test.fixme(
  process.env.ENVIRONMENT === 'staging',
  'Bug in payment retry logic (TICKET-2847)'
);

test('retry payment after network failure', async ({ page }) => {
  await page.goto('/checkout');
  // Simulate network error
  await page.context().setOfflineMode(true);
  await page.click('[data-testid="pay-button"]');
  
  // This assertion will fail in staging due to the known bug
  await expect(page.locator('[data-testid="retry-prompt"]')).toBeVisible();
  
  await page.context().setOfflineMode(false);
  await page.click('[data-testid="retry-button"]');
  await expect(page).toHaveURL('/order-confirmation');
});

You can also use test.fixme without a condition, which means the test always runs but failures are tolerated:

typescript
test.fixme('complex accessibility navigation (reported to framework team)', async ({ page }) => {
  // This test might fail, and that's expected until the upstream issue is fixed
});

The advantage over manual skip management: you won't forget to remove the annotation. The test keeps failing, stays visible in reports, and prompts your team to re-evaluate when the underlying issue is resolved.

test.slow Triples the Timeout

test.slow increases the test timeout by a factor of three. By default, Playwright tests have a 30-second timeout. With test.slow, that becomes 90 seconds. Use this for tests that involve heavy computation, large file uploads, or complex database operations that are legitimately slow but not flaky.

typescript
test.slow('upload and process large video file', async ({ page }) => {
  // Default timeout: 30 seconds
  // With test.slow: 90 seconds
  
  await page.goto('/upload');
  const fileInput = page.locator('input[type="file"]');
  
  // Uploading a 500MB file takes time
  await fileInput.setInputFiles('/path/to/large-video.mp4');
  
  // Processing might take 60+ seconds
  await expect(page.locator('[data-testid="processing-complete"]')).toBeVisible({
    timeout: 70000
  });
});

Don't confuse test.slow with flakiness. A slow test is deterministic; it takes a long time but always completes. A flaky test is unreliable; it passes sometimes and fails other times due to timing issues or resource constraints.

Conditionally apply test.slow to handle environment differences:

typescript
test.slow(
  process.env.CI === 'true',
  'CI runners are resource-constrained'
);

test('import large dataset', async ({ page }) => {
  await page.goto('/import');
  await page.fill('[data-testid="paste-area"]', largeDataset);
  
  // In CI, this takes longer; test.slow ensures we don't timeout prematurely
  await expect(page.locator('[data-testid="import-progress"]')).toHaveText('100%', {
    timeout: 60000
  });
});

Annotations Appear in Reports

All annotations are captured in Playwright's HTML report, test results JSON, and JUnit XML output. This metadata flows into your CI/CD dashboards, team dashboards, and post-test analysis tools. When you run tests with --reporter=html, you'll see annotations clearly marked in the test details panel.

Here's what appears in reports:

  • Skipped tests show as "skipped" with the reason
  • Fixme tests show as "expected failure" or "flaky"
  • Slow tests are tagged so you can identify long-running tests by duration

This transparency is crucial for large teams. When a test result is unexpected, your team can immediately see why by checking the annotations. No guessing, no tribal knowledge.

You can also extend annotations with custom metadata:

typescript
test.annotate('issue', 'TICKET-2847');
test.annotate('severity', 'high');
test.annotate('owner', 'payments-team');

test('process refund', async ({ page }) => {
  // ... test code
});

When you export test results to JSON and process them in a dashboard or analysis tool, these annotations are available for filtering, sorting, and custom reporting.

Practical Scenario: E-commerce Checkout Flow

Here's how annotations work together in a realistic e-commerce suite:

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

const isProd = process.env.ENVIRONMENT === 'production';
const isCI = process.env.CI === 'true';

test.describe('checkout flow', () => {
  test('guest checkout', async ({ page }) => {
    await page.goto('/checkout');
    await page.fill('[data-testid="email"]', 'guest@example.com');
    await page.click('[data-testid="continue"]');
    
    await expect(page).toHaveURL(/\/checkout\/(shipping|payment)/);
  });

  test.skip(
    !isProd,
    'PCI compliance test only runs in production'
  );
  
  test('verify pci dss compliance certificate', async ({ page }) => {
    const response = await page.goto('/payment');
    const headers = response?.headers() || {};
    expect(headers['x-pci-version']).toBeTruthy();
  });

  test.slow(isCI, 'CI runners are slower');
  
  test('process payment with 3D secure', async ({ page }) => {
    await page.goto('/checkout');
    await page.fill('[data-testid="card"]', '4000002500003155');
    await page.click('[data-testid="pay"]');
    
    // 3D secure iframe modal can be slow in CI
    await expect(page.frameLocator('iframe[title="3D Secure"]').locator('input')).toBeVisible({
      timeout: 60000
    });
  });

  test.fixme(
    process.env.PAYMENT_GATEWAY === 'legacy',
    'Legacy gateway loses state on network retry (TICKET-1203)'
  );
  
  test('retry failed payment', async ({ page }) => {
    await page.goto('/checkout');
    await page.context().setOfflineMode(true);
    await page.click('[data-testid="pay"]');
    
    await page.context().setOfflineMode(false);
    await expect(page.locator('[data-testid="retry-option"]')).toBeVisible();
  });
});

This structure keeps your tests organized and intentional. Each annotation has a clear reason, making the suite maintainable as your application evolves.

Common Mistakes

Overusing skip for flaky tests: If a test is flaky, skipping it hides the problem. Use test.slow first, add explicit waits, or isolate the flaky component. Skip should be for irrelevant tests, not broken ones.

Forgetting to remove fixme annotations: Once the underlying bug is fixed, update the test immediately. Don't leave obsolete annotations in your suite; they reduce signal in reports and slow down test execution.

Setting timeout too high with test.slow: Don't use test.slow as a band-aid for undiagnosed timeouts. First, investigate why the test is slow. Is it a missing wait? A third-party service taking too long? A resource leak? Only use test.slow after you've confirmed the delay is legitimate.

Conditional annotations on internal logic: Avoid conditions based on application state or test data. Annotations should depend on environment variables, browser, or platform. If you're tempted to skip based on what the app does, you're probably testing the wrong thing.

Key Takeaways

  • test.skip prevents tests from running when they're not relevant to the current environment or configuration; use conditions to make them adaptive.
  • test.fixme allows known failures to pass without breaking CI; keep these visible in reports so your team addresses them.
  • test.slow triples the timeout for legitimately slow operations; use it after confirming the delay is not a bug.
  • Annotations appear in HTML reports, JSON results, and CI dashboards, providing transparency to your team about test behavior.
  • Be intentional: every annotation should have a clear reason documented in your code or in a ticket reference.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles