---
title: "Playwright Tags and Annotations: Organize Large CI Suites"
description: "Use Playwright tags and annotations across thousands of tests: grep projects for PR and nightly CI, a quarantine tag, and pass rate per tag."
canonical: "https://currents.dev/posts/playwright-tags-and-annotations-organize-large-ci-suites"
updated: "2026-10-27"
---

# Playwright Tags and Annotations: Organize Large CI Suites

Published 2026-10-27 by Walter Galvão.

Canonical URL: https://currents.dev/posts/playwright-tags-and-annotations-organize-large-ci-suites

Use Playwright tags and annotations across thousands of tests: grep projects for PR and nightly CI, a quarantine tag, and pass rate per tag.

---


In Playwright, a tag is a string (or an array of strings) that must start with `@`. Someone adds `@smoke` to the login test, someone else adds `@slow` to the export flow, and each label means whatever its author meant. At a few thousand tests, `@smoke`, `@Smoke` and `@smoke-test` all exist, and nobody can say which one the PR pipeline runs.

> **TL;DR**
>
> There are two ways to tag a Playwright test: pass a details object when you declare the test, or put an `@` token in the test title. Either way, the tag has to start with `@`. Use tags for anything CI selects on (tier, owning team, quarantine), and use annotations for facts about a test that you want in reports.
>
> 1. Enforce them with the lint reporter in the nightly run, the one run that sees every test.
> 2. Quarantine with a tag and an issue annotation, and run the `quarantine` project every night.
> 3. Report pass rate, flaky count and duration per tag from shard files merged in one job.

Selection makes it worse. The `--grep` option runs only the tests that match a regular expression, so picking tests by tag means writing a regex. That regex gets pasted into each workflow file, and each copy drifts a little from the last.

Flaky tests end up somewhere worse still. A test that fails now and then gets parked with `test.fixme`, and Playwright stops running it, so reports show it only as skipped. Nobody sees it start passing again. Nobody sees the feature it covered break, either.

Playwright already has the parts. Tags and annotations both show up in the test report. A custom reporter reads tags from `TestCase.tags`, and it reads annotations from `TestCase.annotations`. What's missing is the convention on top. A custom reporter can also read each result's running time from `testResult.duration`.

## How tags attach to tests and what --grep really matches

Playwright gives you two places to put a tag: a details object passed when you declare the test, or an `@` token in the test title. Either way, the tag has to start with `@`. The details object arrived in v1.42. Pass the same details to `test.describe` and every test in the group carries the tag.

In this file the describe block carries the team tag, one test has a single tier tag, another has an array, and the last one uses the older title form:

```ts
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';

test.describe('checkout', { tag: '@team-payments' }, () => {
  test('guest can pay by card', { tag: '@tier1' }, async ({ page }) => {
    await page.goto('/checkout'); // relative to use.baseURL, e.g. 'http://localhost:3000' in playwright.config.ts
    await page.getByRole('button', { name: 'Pay now' }).click();
    await expect(page.getByText('Payment received')).toBeVisible();
  });

  test('saved card is offered on return', { tag: ['@tier2', '@quarantine'] }, async ({ page }) => {
    await page.goto('/checkout');
    await expect(page.getByRole('radio', { name: /Visa ending/ })).toBeVisible();
  });

  // Older form: the tag is part of the title
  test('coupon applies to the total @tier2', async ({ page }) => {
    await page.goto('/checkout?coupon=SPRING');
    await expect(page.getByTestId('total')).toContainText('SPRING');
  });
});
```

The details on `test.describe` tag all tests in the group. Reporters see the merged set: `testCase.tags` is the list of tags defined on the test or suite via `test()` or `test.describe()`, as well as `@`-tokens extracted from test and suite titles.

Selection is where the details start to bite. Playwright tests the grep regex against one string made of the project name, the test file name, the describe name, the test name and the tags, separated by spaces. The docs give an example of that string: `chromium my-test.spec.ts my-suite my-test`.

<AntiPattern title="Title tags and an unanchored grep" id="anti-pattern-title-tags-and-an-unanchored-grep">

The tags live in the title, next to whatever else the title says, and CI selects with a bare pattern:

```ts
// Anti-pattern: the title mixes a tag with an email address, and both are parsed as tags
import { test, expect } from '@playwright/test';

test('invite sent to ops@acme.com @tier1', async ({ page }) => {
  await page.goto('/team/invite');
  await page.getByLabel('Email').fill('ops@acme.com');
  await page.getByRole('button', { name: 'Send invite' }).click();
  await expect(page.getByText('Invite sent')).toBeVisible();
});
```

```bash
# Anti-pattern: also matches @tier10 and @tier1-legacy
npx playwright test --grep @tier1
```

That grep is tested against the whole string: the project name, the test file name, the `test.describe` name (if any), the test name and the test tags divided by spaces. The title causes a second problem. One user reported that an email address in a test title turns into a tag ([#39677](https://github.com/microsoft/playwright/issues/39677)). A maintainer replied that they don't want to change the behaviour for backwards-compatibility reasons. Playwright's source collects title tags with `const titleTags = path.join(' ').match(/@[\S]+/g) || [];`.

<AntiPattern.Fix>

Put tags in the details object, keep `@` out of titles altogether, and end every pattern with a boundary. The grep string separates tags with spaces, so a lookahead for whitespace or the end of the string stops `@tier1` from matching a longer tag:

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

test('invite is sent to a new teammate', { tag: '@tier1' }, async ({ page }) => {
  await page.goto('/team/invite');
  await page.getByLabel('Email').fill('ops@acme.com');
  await page.getByRole('button', { name: 'Send invite' }).click();
  await expect(page.getByText('Invite sent')).toBeVisible();
});
```

```bash
npx playwright test --grep "@tier1(?=\s|$)"
```

</AntiPattern.Fix>

</AntiPattern>

## Tags select tests, annotations describe them

Use tags for anything CI selects on: tier, owning team, quarantine. Use annotations for facts about a test that you want in reports: who owns it, which issue tracks it. An annotation has a `type` and a `description`, and the reporter API exposes it. Annotations also stack: `testInfo.annotations` holds the annotations from the test, from every `test.describe()` group it belongs to and from the test file.

The built-in HTML reporter shows every annotation except those whose type starts with `_`. Give internal bookkeeping annotations a `_` prefix, and they stay available to reporters without cluttering the HTML report.

An owner annotation also pays off outside your own reports: [Currents](https://docs.currents.dev/guides/playwright-annotations) shows the value of an annotation with type `owner` in various areas of its dashboard, so your team can see who owns the test. Annotation support requires `@currents/playwright` 1.5.0 or later, and the dashboard keeps at most 32 distinct annotations per test.

<AntiPattern title="Owners and tickets as tags" id="anti-pattern-owners-and-tickets-as-tags">

Owners and ticket numbers become tags: `@owner-payments`, `@JIRA-1234`. Every new ticket adds a tag nobody selects on. Each one also lands in the string that grep is tested against, which consists of the project name, the test file name, the `test.describe` name, the test name and the test tags. The pattern is matched like a substring: passing `grep: /cart/` should only run tests with "cart" in the title. Each tag must start with `@`, while an annotation's description can hold an issue URL.

<AntiPattern.Fix>

Keep the team tag for selection, and move the owner and the issue into annotations:

```ts
// tests/refunds.spec.ts
import { test, expect } from '@playwright/test';

test.describe('refunds', {
  tag: '@team-payments',
  annotation: { type: 'owner', description: 'payments-oncall' },
}, () => {
  test('partial refund updates the balance', {
    tag: '@tier1',
    annotation: [
      { type: 'issue', description: 'https://github.com/acme/shop/issues/1234' },
      { type: '_source', description: 'migrated-from-cypress' },
    ],
  }, async ({ page }) => {
    await page.goto('/orders/1001/refund'); // resolved against use.baseURL in playwright.config.ts
    await page.getByLabel('Amount').fill('20');
    await page.getByRole('button', { name: 'Refund' }).click();
    await expect(page.getByTestId('balance')).toContainText('80');
  });
});
```

</AntiPattern.Fix>

</AntiPattern>

### Modifiers decide whether a test runs

You can add your own annotations, but Playwright also comes with a few built-in ones. `test.skip` and `test.fixme` stop a test from running. `test.fixme()` marks the test as failing. Playwright will not run this test, as opposed to the fail annotation. The docs recommend fixme when running the test is slow or crashes. `test.slow()` marks the test as slow and triples the test timeout. With `test.only()`, only the focused tests run. Tests in a `test.describe.skip()` group are never run. Tests in a `test.describe.fixme()` group are marked as fixme and not executed. None of them is the right home for a flaky test you still want to watch.

## Keep the tag set small and enforce it

A tag set nobody owns grows until no grep pattern catches all of it.

<AntiPattern title="Anyone can invent a tag" id="anti-pattern-anyone-can-invent-a-tag">

Every author picks a spelling. A year in, `@smoke`, `@Smoke`, `@smoke-test` and `@critical` all mean the same thing, and the PR pipeline's pattern catches one of them. The tests behind the other spellings stop running on PRs, and nothing fails to tell you.

<AntiPattern.Fix>

Allow three families and nothing else: exactly one tier tag (`@tier1` or `@tier2`), exactly one owning-team tag, and `@quarantine` when it applies. Export them from one module as constants, so test files never type a tag by hand:

```ts
// tags.ts
export const Tier = {
  t1: '@tier1',
  t2: '@tier2',
} as const;

export const Team = {
  payments: '@team-payments',
  growth: '@team-growth',
  platform: '@team-platform',
} as const;

export const QUARANTINE = '@quarantine';
```

Put the team tag on the file's top-level `test.describe` once, since tags passed to a describe group reach every test in it:

```ts
// tests/refunds.spec.ts
import { test, expect } from '@playwright/test';
import { Team, Tier } from '../tags';

test.describe('refunds', { tag: Team.payments }, () => {
  test('full refund closes the order', { tag: Tier.t1 }, async ({ page }) => {
    await page.goto('/orders/1002/refund'); // resolved against use.baseURL in playwright.config.ts
    await page.getByRole('button', { name: 'Refund all' }).click();
    await expect(page.getByTestId('order-status')).toHaveText('Closed');
  });
});
```

A misspelled key doesn't exist on those objects, so the TypeScript compiler reports an error for the typo. Playwright does not check the types and will run tests even if there are non-critical TypeScript compilation errors, so run the TypeScript compiler (`npx tsc -p tsconfig.json --noEmit`) alongside Playwright, as [its docs](https://playwright.dev/docs/test-typescript) recommend. Teams have asked Playwright for a shared, typed tag taxonomy enforced at TypeScript level during development ([#30077](https://github.com/microsoft/playwright/issues/30077)). The request was closed as not planned ([#30077](https://github.com/microsoft/playwright/issues/30077)). So the taxonomy is yours to enforce.

</AntiPattern.Fix>

</AntiPattern>

### Catch untagged tests in the nightly run

Constants stop typos, not omissions. A test with no tier tag compiles fine and falls out of any pipeline that selects by tier. A reporter catches it: tags are available to a custom reporter via the `TestCase.tags` property, and `onTestEnd` is called after a test has finished in the worker process, so it sees every test that ran.

```ts
// reporters/tag-lint.ts
import fs from 'fs';
import type { Reporter, TestCase } from '@playwright/test/reporter';

const TIER = /^@tier\d+$/;
const TEAM = /^@team-[a-z-]+$/;

export default class TagLintReporter implements Reporter {
  private problems = new Map<string, string>();

  onTestEnd(test: TestCase) {
    const tags = [...new Set(test.tags)];
    const tiers = tags.filter((tag) => TIER.test(tag));
    const teams = tags.filter((tag) => TEAM.test(tag));
    if (tiers.length !== 1 || teams.length !== 1) {
      const where = `${test.location.file}:${test.location.line}`;
      this.problems.set(test.id, `${where} "${test.title}" [${tags.join(' ')}]`);
    }
  }

  onEnd() {
    if (this.problems.size === 0) return;
    const lines = [...this.problems.values()];
    fs.writeFileSync('tag-violations.txt', lines.join('\n') + '\n');
    console.log(`${lines.length} tests break the tag rules, see tag-violations.txt`);
  }
}
```

Run this check in the nightly suite, not on PRs. A grep-filtered run never executes the tests its patterns exclude, so the reporter never sees them. The nightly workflow further down fails the job when `tag-violations.txt` exists.

## Put tag selection in playwright.config, not in workflow YAML

A grep pattern inside a workflow file is test selection that nobody reviews as test selection.

<AntiPattern title="A grep string in every workflow" id="anti-pattern-a-grep-string-in-every-workflow">

Each pipeline carries its own copy of the selection:

```yaml
# Anti-pattern: two copies of the selection, already drifting apart
name: e2e
on:
  pull_request:
  schedule:
    - cron: '0 3 * * *'
jobs:
  pr:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --grep "@tier1|@smoke" --grep-invert @quarantine
  nightly:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --grep-invert '@quarantine|@flaky'
```

The PR job still selects a retired `@smoke` tag. The nightly job excludes an `@flaky` tag that no test carries anymore, with different quoting. Neither anchors its patterns. To learn what a pipeline runs, you read YAML and evaluate regex in your head.

<AntiPattern.Fix>

Move selection into projects. `testProject.grep` runs only the tests that match one of its patterns, and `testProject.grepInvert` runs only the tests that match none of them. In a project, `grep` keeps only tests whose title matches one of its patterns, and `grepInvert` keeps only tests whose title matches none of its patterns. In the config below, the `SUITE` environment variable picks which projects exist:

```ts
// playwright.config.ts
import { defineConfig, devices, type PlaywrightTestConfig } from '@playwright/test';

type Suite = 'pr' | 'nightly' | 'quarantine';
const suite = (process.env.SUITE ?? 'pr') as Suite;

const desktop = { ...devices['Desktop Chrome'] };
const tier1 = /@tier1(?=\s|$)/;
const quarantine = /@quarantine(?=\s|$)/;

const projects: Record<Suite, PlaywrightTestConfig['projects']> = {
  pr: [{ name: 'pr', grep: [tier1], grepInvert: [quarantine], use: desktop }],
  nightly: [
    { name: 'nightly', grepInvert: [quarantine], use: desktop },
    { name: 'quarantine', grep: [quarantine], use: desktop },
  ],
  quarantine: [{ name: 'quarantine', grep: [quarantine], use: desktop }],
};

export default defineConfig({
  testDir: './tests',
  retries: process.env.CI ? 2 : 0,
  tag: `@ci-${suite}`,
  reporter: [['html'], ['./reporters/tag-lint.ts'], ['./reporters/tag-report.ts']],
  projects: projects[suite],
});
```

`testConfig.tag` adds a tag to every test in the report, which keeps results from the PR and nightly pipelines apart. Because `testConfig.tag` is prepended to each test in the report, a run-level tag spelled like a per-test tag would show up on every test. Each workflow now sets `SUITE`, and the regex lives in one reviewed file. [The `testProject` reference](https://playwright.dev/docs/api/class-testproject#test-project-grep) documents both filters, `grep` and `grepInvert`.

</AntiPattern.Fix>

</AntiPattern>

### Keep --grep for local runs

On your own machine, the CLI flags beat a config change. To run tests with either tag, use a regex alternation such as `@fast|@slow`; to run tests with both, use one regex lookahead per tag, `(?=.*@fast)(?=.*@slow)`. `-G`/`--grep-invert` runs only the tests that do not match the regular expression:

```bash
npx playwright test --grep "@tier1|@tier2"
npx playwright test --grep "(?=.*@tier1(?=\s|$))(?=.*@team-payments)"
npx playwright test --grep "@team-growth" --grep-invert "@quarantine"
```

Playwright's docs write the same OR pattern three ways: Bash quotes it, while PowerShell and Batch also escape the pipe as `^|`. Shell quoting is worth fighting once in a terminal and not worth copying into every workflow.

Teams have asked for tag expressions as a dedicated tag option ([#31181](https://github.com/microsoft/playwright/issues/31181)). The request was closed as not planned. You run tagged tests with `--grep` on the command line, and you can filter them in the config via `testConfig.grep` and `testProject.grep`.

## Quarantine with a tag, not test.fixme

<AntiPattern title="Parking flaky tests with test.fixme" id="anti-pattern-parking-flaky-tests-with-testfixme">

A checkout test fails on some runs, someone parks it with `test.fixme` and a comment, and the PR goes green:

```ts
// Anti-pattern: fixme stops the test from running, so nobody learns when it passes again
import { test, expect } from '@playwright/test';

// Flaky since the payment iframe change, fix later
test.fixme('guest can pay with a saved card', async ({ page }) => {
  await page.goto('/checkout'); // resolved against use.baseURL in playwright.config.ts
  await page.getByRole('radio', { name: /Visa ending/ }).check();
  await page.getByRole('button', { name: 'Pay now' }).click();
  await expect(page.getByText('Payment received')).toBeVisible();
});
```

Playwright doesn't run a fixme test. Tests that are statically skipped with `test.skip()` or `test.fixme()` aren't counted when balancing shards either, because they don't run. Playwright marks the test as expected to be skipped and never runs it, so nothing records whether it would pass today.

<AntiPattern.Fix>

Tag it `@quarantine`, link the issue in an annotation, and let the config decide where it runs. A project's `grepInvert` filters to only run tests with a title not matching any of its patterns, the opposite of `grep`. So the `pr` project's `grepInvert` excludes it, and the `quarantine` project's `grep` selects it every night:

```ts
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { QUARANTINE, Team, Tier } from '../tags';

test.describe('checkout', { tag: Team.payments }, () => {
  test('guest can pay with a saved card', {
    tag: [Tier.t1, QUARANTINE],
    annotation: { type: 'issue', description: 'https://github.com/acme/shop/issues/482' },
  }, async ({ page }) => {
    await page.goto('/checkout'); // resolved against use.baseURL in playwright.config.ts
    await page.getByRole('radio', { name: /Visa ending/ }).check();
    await page.getByRole('button', { name: 'Pay now' }).click();
    await expect(page.getByText('Payment received')).toBeVisible();
  });
});
```

The test still fails on some nights. Now it fails where you can see it, next to its owner and its issue, which is where [fixing the flaky test itself](/posts/what-is-a-flaky-test-and-how-to-fix-it#how-to-fix-a-flaky-test) starts.

</AntiPattern.Fix>

</AntiPattern>

### Leaving quarantine

Release a test only after several consecutive nightly runs where it passed on the first try. Playwright gives a test that passes on a second retry the outcome `'flaky'`. A quarantined test that still comes out flaky isn't fixed. A reporter can read `testCase.outcome()`, which returns `'skipped'`, `'expected'`, `'unexpected'` or `'flaky'`, so the reporter further down counts flaky results apart from clean passes.

## PR and nightly workflows with tags and --shard

With selection in the config, a workflow only says which suite it wants and which shard it is. To shard the suite, you pass `--shard=x/y` on the command line ([the sharding docs](https://playwright.dev/docs/test-sharding)).

The PR workflow sets `SUITE` to `pr` and splits the run across three machines:

```yaml
# .github/workflows/e2e-pr.yml
name: e2e-pr
on:
  pull_request:
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3]
        shardTotal: [3]
    env:
      SUITE: pr
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
```

The nightly workflow runs on a schedule with `SUITE` set to `nightly`, which brings in both the `nightly` and `quarantine` projects. It also fails on tag violations and uploads each shard's per-tag report:

```yaml
# .github/workflows/e2e-nightly.yml
name: e2e-nightly
on:
  schedule:
    - cron: '0 3 * * *'
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3, 4, 5, 6]
        shardTotal: [6]
    env:
      SUITE: nightly
      TAG_REPORT_FILE: tag-report-${{ matrix.shardIndex }}.json
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
      - name: Fail on tag rule violations
        if: always()
        run: test ! -f tag-violations.txt || (cat tag-violations.txt && exit 1)
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: tag-report-${{ matrix.shardIndex }}
          path: tag-report-${{ matrix.shardIndex }}.json
```

Both workflows run the same test command. `SUITE`, `TAG_REPORT_FILE` and the shard count are what differ, so the PR pipeline runs `@tier1` tests without `@quarantine` across three machines, and the nightly pipeline runs the whole suite, quarantine included, across six.

Quarantine changes the nightly shard math. Tests parked with `test.fixme` cost the shards nothing, since statically skipped and fixme tests aren't counted when balancing shards. Once those tests move to `@quarantine`, they run in the `quarantine` project and count like any other test. Expect the nightly shards to carry more work after the switch, and size the matrix for it, weighing [more shards against more workers per machine](/posts/optimizing-test-runtime-playwright-sharding-vs-workers).

## Report pass rate and duration per tag from a custom reporter

<AntiPattern title="Tags set at runtime" id="anti-pattern-tags-set-at-runtime">

A test learns something while it runs, such as which backend region served it, and pushes it onto `testInfo.tags` to report on later. Changes made to `testInfo.tags` while the test is running are not visible to reporters, so the per-tag report never sees the region.

<AntiPattern.Fix>

Record runtime facts as annotations. You can add annotations to `test.info().annotations` while the test is running. `testResult.annotations` includes the annotations appended to `testInfo.annotations` during the run:

```ts
// tests/search.spec.ts
import { test, expect } from '@playwright/test';
import { Team, Tier } from '../tags';

test.describe('search', { tag: Team.growth }, () => {
  test('results load for a broad query', { tag: Tier.t1 }, async ({ page }) => {
    await page.goto('/search?q=shoes'); // relies on use.baseURL in playwright.config.ts
    const region = (await page.getByTestId('served-by').textContent()) ?? 'unknown';
    test.info().annotations.push({ type: 'region', description: region });
    await expect(page.getByRole('listitem')).not.toHaveCount(0);
  });
});
```

</AntiPattern.Fix>

</AntiPattern>

### The per-tag reporter

The reporter keeps one entry per test, keyed by test ID so a retried test counts once, and adds up `testResult.duration`, the running time in milliseconds, across attempts. Test tags are displayed in the test report, and are available to a custom reporter via the `TestCase.tags` property:

```ts
// reporters/tag-report.ts
import fs from 'fs';
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';

type Annotation = { type: string; description?: string };
type Entry = { test: TestCase; durationMs: number; annotations: Annotation[] };
type Bucket = { tests: number; passed: number; flaky: number; failed: number; durationMs: number };

export default class TagReportReporter implements Reporter {
  private entries = new Map<string, Entry>();

  onTestEnd(test: TestCase, result: TestResult) {
    const entry = this.entries.get(test.id) ?? { test, durationMs: 0, annotations: [] };
    entry.durationMs += result.duration;
    entry.annotations = [...test.annotations, ...result.annotations];
    this.entries.set(test.id, entry);
  }

  onEnd() {
    const byTag: Record<string, Bucket> = {};
    const quarantined: Array<Record<string, string>> = [];

    for (const { test, durationMs, annotations } of this.entries.values()) {
      const outcome = test.outcome();
      if (outcome === 'skipped') continue;

      for (const tag of new Set(test.tags)) {
        const bucket = (byTag[tag] ??= { tests: 0, passed: 0, flaky: 0, failed: 0, durationMs: 0 });
        bucket.tests += 1;
        bucket.durationMs += durationMs;
        if (outcome === 'expected') bucket.passed += 1;
        else if (outcome === 'flaky') bucket.flaky += 1;
        else bucket.failed += 1;
      }

      if (test.tags.includes('@quarantine')) {
        const find = (type: string) =>
          annotations.find((a) => a.type === type)?.description ?? 'unknown';
        quarantined.push({
          title: test.titlePath().join(' > '),
          owner: find('owner'),
          issue: find('issue'),
          outcome,
        });
      }
    }

    const file = process.env.TAG_REPORT_FILE ?? 'tag-report.json';
    fs.writeFileSync(file, JSON.stringify({ byTag, quarantined }, null, 2));
  }
}
```

Pass rate per tag is `passed / tests`, with `flaky` kept apart. A tag whose tests pass only on retry has a problem that a combined pass rate hides.

Each shard writes its own file, named from `TAG_REPORT_FILE` in the nightly workflow. Add a final job that downloads the `tag-report-*` artifacts, sums the buckets per tag and concatenates the quarantine lists. You get one table of pass rate, flaky count and duration per tier and per team, plus every quarantined test with its owner, its issue link and how it did last night.

If you'd rather not [maintain a custom reporter](/posts/playwright-custom-reporters-build-your-own#the-ongoing-cost-maintaining-a-custom-reporter) at all, [Currents](https://docs.currents.dev/guides/playwright-tags) applies a tag on a `test.describe` group to every test in that group and to the created run, and makes the tags available for reports, metrics, filtered results and narrowed Slack notifications.

## Where tags stop helping

Tags changed at runtime never reach the report: changes made to `testInfo.tags` while the test is running are not visible to test reporters. A region, a feature flag's state or a build number goes into an annotation.

A selection that needs three lookaheads has outgrown tags. AND in grep takes one regex lookahead per tag, as in `--grep "(?=.*@fast)(?=.*@slow)"`. By the third one, nobody reviewing the config can say what it selects. Split that slice of the suite into its own project or folder.

Configuration differences belong in projects or in separate runs, not in tags. A browser or an environment describes the run, not each test. Label the run with `testConfig.tag`, which prepends a tag to each test in the report and is useful for telling CI environments apart, instead of tagging each test in code.

Title tags need migrating before any of this. A test with a tag in its title and another in its details carries both. The lint reporter flags a test with two tier tags. Move the title tags into details objects first, then turn on enforcement.

## A setup to start from

The whole setup fits in four files and two workflows. Build it in this order:

1. Allow three tag families (tier, team and `@quarantine`), export them from `tags.ts`, and put the team tag on each file's top-level `test.describe`.
2. Enforce them with the lint reporter in the nightly run, the one run that sees every test.
3. Keep selection in `playwright.config.ts` projects picked by `SUITE`, and keep `--grep` for your terminal.
4. Quarantine with a tag and an issue annotation, and run the `quarantine` project every night.
5. Report pass rate, flaky count and duration per tag from shard files merged in one job.

One rule keeps the setup from sliding back into the mess it replaced: if a label decides what runs, it's a tag, and if it only describes the test, it's an annotation. Hold that line and the tag set stays small enough to enforce.

## Frequently asked questions


### How do I add a tag to a Playwright test?

You can pass a details object when you declare the test, or put an `@` token in the test title, and either way the tag has to start with `@`. The details object arrived in v1.42. If you pass the same details to `test.describe`, every test in the group carries the tag, as [how tags attach to tests](#how-tags-attach-to-tests-and-what---grep-really-matches) shows.


### How do I run only Playwright tests with a certain tag?

Use `--grep`, which runs only the tests that match a regular expression, so picking tests by tag means writing a regex. To run tests with either tag, use a regex alternation such as `@fast|@slow`. To run tests with both, use one regex lookahead per tag, `(?=.*@fast)(?=.*@slow)`, as [Keep --grep for local runs](#keep---grep-for-local-runs) shows.


### What is the difference between tags and annotations in Playwright?

Use tags for anything CI selects on, such as tier, owning team and quarantine. Use annotations for facts about a test that you want in reports, such as who owns it and which issue tracks it. An annotation has a `type` and a `description`, and the reporter API exposes it, as [tags select tests, annotations describe them](#tags-select-tests-annotations-describe-them) explains.


### Why does grep @tier1 also match @tier10?

Playwright tests the grep regex against one string made of the project name, the test file name, the describe name, the test name and the tags, separated by spaces.


### Can I filter tests by tag per project in playwright.config?

Yes. `testProject.grep` runs only the tests that match one of its patterns, and `testProject.grepInvert` runs only the tests that match none of them.


### Can a Playwright reporter see tags added while a test is running?

No. Changes made to `testInfo.tags` while the test is running are not visible to reporters. Record runtime facts as annotations instead, since `testResult.annotations` includes the annotations appended to `testInfo.annotations` during the run ([tags set at runtime](#anti-pattern-tags-set-at-runtime)).


### Why does an email address in a Playwright test title become a tag?

Playwright collects title tags with `const titleTags = path.join(' ').match(/@[\S]+/g) || [];`, so an email address in a title is parsed as a tag. When a user reported it, a maintainer replied that they don't want to change the behaviour for backwards-compatibility reasons. Put tags in the details object and keep `@` out of titles altogether, as [title tags and an unanchored grep](#anti-pattern-title-tags-and-an-unanchored-grep) recommends.




---

## About the author

**Walter Galvão**, Software Engineer. Walter Galvão is a software engineer at Currents. He writes about Playwright in practice: debugging flaky tests, structuring suites for CI, and the engineering workflows that keep large test suites reliable.

LinkedIn: https://www.linkedin.com/in/waltergalvao/
