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.
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.
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:
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.
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:
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.
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:
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:
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:
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.skipprevents tests from running when they're not relevant to the current environment or configuration; use conditions to make them adaptive.test.fixmeallows known failures to pass without breaking CI; keep these visible in reports so your team addresses them.test.slowtriples 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.






Comments
Loading comments…