PRACTICAL GUIDE / Playwright monorepo browser project deduplication

Stop running the same Playwright project twice in a monorepo

Learn why Playwright repeats the same monorepo specs, prove where duplicate projects enter the run, and consolidate coverage without dropping a browser.

By The Testing AcademyUpdated August 4, 202621 min read
All field guides
In this guide6 sections
  1. Why the runner sees two legitimate jobs
  2. Build one coverage matrix, not several project arrays
  3. Trace duplication to collection, orchestration, or attempts
  4. Fix the runner layer without losing package ownership
  5. Roll out the change without hiding lost coverage
  6. Know when two similar projects should stay

What you will learn

  • Why the runner sees two legitimate jobs
  • Build one coverage matrix, not several project arrays
  • Trace duplication to collection, orchestration, or attempts
  • Fix the runner layer without losing package ownership

The same checkout test appears twice in CI, once under chromium and again under desktop-chrome, even though both launch the same browser against the same package. The second result doubles the browser work but adds no planned coverage. Renaming either project only makes the duplicate harder to spot. The fault is usually in the ownership of the project matrix, not in the test itself.

Why the runner sees two legitimate jobs

A Playwright project is a logical group of tests with one resolved configuration. Projects can select a browser, a device profile, a directory, a file pattern, an environment, or any combination of those settings. When the command does not include a --project filter, Playwright runs all configured projects. It does not look at two project objects and decide that their purpose seems similar enough to merge them.

That behavior matters in a monorepo because configuration is often assembled from several places. A platform package exports a standard Chromium project. The payments package adds its own Chromium project. A root config imports both arrays so CI can produce one report. If both projects point at packages/payments/e2e and both resolve to bundled Chromium with the same target URL, the same spec belongs to two projects. Playwright has been asked to execute both.

Different names do not change that mechanism. The reporter model treats a test running in multiple projects as multiple test cases in their corresponding project suites. A test case identity includes the project name along with the file and title. That is useful when firefox and webkit are deliberate coverage targets. It also means payments-chromium and desktop-chrome can look perfectly distinct in a report while representing the same coverage decision.

There are three places to look before editing the matrix. Collection duplication happens when multiple projects match the same spec under equivalent settings. Invocation duplication happens when CI starts more than one Playwright command that owns the same package. Attempt duplication happens after collection, through retries or --repeat-each. Those three cases can produce similar looking lines in a report, but only the first requires project deduplication.

Consider a workspace with a root config that recursively scans packages/**/e2e. The CI job also runs pnpm --recursive test:e2e, and each package script invokes its local config. The root project list can be completely clean while every test still runs twice. Removing a browser from the root matrix would reduce coverage and leave the orchestration bug intact. The evidence is two runner invocations in the job log, not two project entries in one collected suite.

The inverse failure is possible too. CI may start only one command, yet the root config concatenates sharedProjects, checkoutProjects, and a legacy browserProjects export. Two of those exports can describe the same checkout target. The job log shows one invocation, while playwright test --list shows the same file and title under two project names. That points to collection.

Retries are the common near-miss. A failed test can have more than one result, and a test that passes on a later retry is classified as flaky rather than becoming a second project. The retry index tells you which attempt you are reading. repeatEachIndex serves a similar purpose for an intentional repeated run. If the project name stays the same and only the attempt metadata changes, leave the browser matrix alone and investigate the original failure.

Sharding adds one more diagnostic trap. Correctly configured shards partition the selected tests. Seeing the same project name on several machines is expected because each shard uses that project configuration. Seeing the same test identity on two shards is different: it suggests overlapping shard assignments, duplicated CI matrix entries, or reports from separate runs being combined without a unique run boundary. Project names cannot settle that question by themselves.

Build one coverage matrix, not several project arrays

Start with the tuple your team means to cover. For a simple product, it might be package scope, browser engine, browser channel, base URL, locale, and storage state. Two entries with the same tuple are redundant by policy. Two entries that differ in a dimension the release decision actually uses should remain separate.

The safest root-config pattern keeps that tuple in plain data, validates it, and generates Playwright projects once. The check below is part of config evaluation, so adding a duplicate target makes the command fail before any browser starts. Unlike an assertion over a fixed demo fixture, this one reads the actual array used to build projects; changing that array can make the check fail.

TypeScript
import { defineConfig } from '@playwright/test';
import { normalize } from 'node:path';

type BrowserTarget = {
  projectName: string;
  packageName: string;
  testDir: string;
  browserName: 'chromium' | 'firefox' | 'webkit';
  channel?: string;
  baseURL: string;
  locale: string;
  storageState?: string;
};

const targets: BrowserTarget[] = [
  {
    projectName: 'checkout-chromium',
    packageName: 'checkout',
    testDir: './packages/checkout/e2e',
    browserName: 'chromium',
    baseURL: 'http://127.0.0.1:3100',
    locale: 'en-US',
  },
  {
    projectName: 'checkout-firefox',
    packageName: 'checkout',
    testDir: './packages/checkout/e2e',
    browserName: 'firefox',
    baseURL: 'http://127.0.0.1:3100',
    locale: 'en-US',
  },
  {
    projectName: 'account-chromium',
    packageName: 'account',
    testDir: './packages/account/e2e',
    browserName: 'chromium',
    baseURL: 'http://127.0.0.1:3200',
    locale: 'en-US',
  },
];

function coverageKey(target: BrowserTarget): string {
  return JSON.stringify([
    normalize(target.testDir),
    target.browserName,
    target.channel ?? 'bundled',
    target.baseURL,
    target.locale,
    target.storageState ?? 'anonymous',
  ]);
}

const ownerByKey = new Map<string, string>();
const projectNames = new Set<string>();
for (const target of targets) {
  if (projectNames.has(target.projectName)) {
    throw new Error(`Duplicate project name: ${target.projectName}`);
  }
  projectNames.add(target.projectName);

  const key = coverageKey(target);
  const existing = ownerByKey.get(key);
  if (existing !== undefined) {
    throw new Error(
      `Duplicate browser coverage: ${existing} and ${target.projectName} resolve to ${key}`,
    );
  }
  ownerByKey.set(key, target.projectName);
}

export default defineConfig({
  projects: targets.map((target) => ({
    name: target.projectName,
    metadata: { packageName: target.packageName },
    testDir: target.testDir,
    testMatch: '**/*.spec.ts',
    use: {
      browserName: target.browserName,
      channel: target.channel,
      baseURL: target.baseURL,
      locale: target.locale,
      storageState: target.storageState,
    },
  })),
});

This guard is intentionally a team policy, not a claim that those six fields capture every Playwright distinction. Add a field when it changes the coverage decision. Keep projectName explicit and unique, because two legitimate Chromium targets may differ by channel, locale, or authentication role. If one project enables a proxy and another does not, the proxy belongs in the target model and key. If the only difference is a timeout chosen to accommodate a slow package, decide whether that is a true coverage variant or merely runner tuning. Treating every config property as a dimension would bless accidental duplicates; treating too few properties as dimensions would reject useful variants.

The package name is kept out of this particular key because the test directory already establishes ownership. That choice exposes a copied target whose label changed but whose tests did not. In a repository where two packages intentionally reference a shared contract-test directory, package ownership may belong in the key. The important point is that the rule follows the suite's coverage contract instead of comparing display names.

One root matrix has a maintenance cost. A package team must update a central file when it adds a target, and a malformed entry can prevent every package from collecting. Review ownership and a small config-level check become important. The payoff is that CI and reporters see one graph, so redundant projects cannot hide behind separate package commands.

Local configs are also valid. They work well when packages ship independently, use different Playwright versions, or need unrelated web servers. In that model, do not import all package projects into a root config and also call every package script. Let CI discover changed packages and invoke each selected local config once. The deduplication boundary moves from the config array to the job planner.

Trace duplication to collection, orchestration, or attempts

Run collection from the same directory and with the same config that CI uses. Playwright's --list option lists collected tests without executing them. Add the project filter only after looking at the unfiltered inventory, because an early filter can hide the second owner you are trying to find.

Shell
pnpm exec playwright test --list
pnpm exec playwright test --list --project='*-chromium'
pnpm exec playwright test --list packages/checkout/e2e/cart.spec.ts
pnpm exec playwright test --help

Read each listed identity as project, file, location, and title. If the cart test appears under checkout-chromium and desktop-chrome in the first command, then disappears from one side when you filter, collection contains two owners. If it appears only once in --list but twice in the finished CI report, search the log for a second Playwright command, a retry, --repeat-each, or a second report upload.

File paths deserve attention. A broad root testDir can collect generated copies, build output, or package fixtures if testMatch is too generous. That is not project duplication even though the titles may match. The location will differ. Tighten testDir, testMatch, or testIgnore after confirming the extra file path. Deleting a browser project would conceal the extra copy for one environment and leave it active in the others.

Overlapping file groups are a related collection defect. A team may define a smoke-chromium project with testMatch: '**/*.smoke.spec.ts' and a default-chromium project whose broader pattern also accepts that suffix. The smoke file then runs in both projects. If the intention is to split one suite by risk, make the groups mutually exclusive by pairing the narrow project's testMatch with a corresponding testIgnore in the broad project. If the intention is to run smoke tests twice, perhaps once as a fast gate and once inside full regression, keep both and record that intent in reporting. The project list alone cannot infer which policy the team meant.

This near-miss is different from two semantically identical browser entries. The project settings may match, but the defect lives in set membership: one file belongs to two planned groups. Add the file pattern or suite role to the diagnostic key when your matrix divides tests this way. Then test the boundary with a real smoke file and a real regression file. The check should fail when a newly named smoke spec leaks into the default group, which makes it useful during later renames instead of proving only today's fixture.

A small reporter can make a large inventory easier to inspect. This reporter groups collected cases by source location, title, repeat index, and resolved browser settings, then prints project names when more than one project owns the same signature. It hashes an inline storage-state object instead of dumping cookies and origin data into the job log. It is a diagnostic, not a release gate. Playwright's reporter contract states that errors thrown from reporter methods are swallowed, so the config-time guard remains the place to stop CI.

TypeScript
import type {
  FullConfig,
  FullProject,
  Reporter,
  Suite,
  TestCase,
} from '@playwright/test/reporter';
import { createHash } from 'node:crypto';

function storageStateIdentity(storageState: unknown): string {
  if (typeof storageState === 'string') return `file:${storageState}`;
  if (storageState === undefined) return 'anonymous';

  const serialized = JSON.stringify(storageState) ?? String(storageState);
  return `inline:${createHash('sha256')
    .update(serialized)
    .digest('hex')
    .slice(0, 16)}`;
}

function engineIdentity(use: FullProject['use']): string {
  return use.browserName ?? use.defaultBrowserType ?? 'chromium';
}

function signature(test: TestCase): string | undefined {
  const project = test.parent.project();
  if (!project) return undefined;

  return JSON.stringify([
    test.location.file,
    test.location.line,
    test.location.column,
    test.title,
    test.repeatEachIndex,
    engineIdentity(project.use),
    project.use.channel ?? 'bundled',
    project.use.baseURL ?? '',
    project.use.locale ?? '',
    storageStateIdentity(project.use.storageState),
  ]);
}

export default class DuplicateCoverageReporter implements Reporter {
  onBegin(_config: FullConfig, root: Suite): void {
    const projectsBySignature = new Map<string, string[]>();

    for (const test of root.allTests()) {
      const key = signature(test);
      const projectName = test.parent.project()?.name;
      if (!key || !projectName) continue;

      const owners = projectsBySignature.get(key) ?? [];
      owners.push(projectName);
      projectsBySignature.set(key, owners);
    }

    for (const [key, owners] of projectsBySignature) {
      if (owners.length > 1) {
        console.error(
          `DUPLICATE_PROJECT_COVERAGE ${JSON.stringify({ key, owners })}`,
        );
      }
    }
  }

  printsToStdio(): boolean {
    return true;
  }
}

The engineIdentity helper is the part that most home-grown versions of this reporter get wrong, and getting it wrong is expensive in the direction nobody checks. Reading project.use.browserName on its own looks obvious and is undefined for any project built from a device descriptor. devices['Desktop Chrome'] carries defaultBrowserType: 'chromium' and no browserName key at all, and the same is true of Desktop Firefox and Desktop Safari. FullProject.use is the merged configuration use object, not the fixture-resolved browser, so nothing fills that gap for you at reporting time.

The consequence is a reporter that fires on a perfectly healthy cross-browser matrix. Three projects spread from three different device descriptors all hash null in the engine slot, every other dimension matches because they are deliberately testing the same package at the same URL, and the reporter prints one DUPLICATE_PROJECT_COVERAGE line naming the Chromium, Firefox, and WebKit projects as redundant. A reader who trusts that output deletes two real browsers. The reporter is not inert; it is confidently wrong in the direction that costs coverage, which is worse than emitting nothing.

The fallback order matters as much as the fallback itself. In Playwright's installed fixture definitions, browserName is declared as an option whose default value is defaultBrowserType, and defaultBrowserType itself defaults to chromium. An explicit browserName therefore wins over a descriptor's defaultBrowserType, which is why the helper reads browserName first. A project that spreads devices['Desktop Firefox'] and then sets browserName: 'chromium' really does launch Chromium, and the reporter should say Chromium. The trailing 'chromium' default keeps a bare project, one that names no browser at all, hashing the same as a project that spells out browserName: 'chromium', because both launch the same engine.

Prove the fix in both directions before trusting it. Point the reporter at three projects built from Desktop Chrome, Desktop Firefox, and Desktop Safari over one shared spec and confirm it prints nothing. Then point it at two projects that both spread devices['Desktop Chrome'] with the same base URL and locale, and confirm it prints exactly one line naming both. A duplicate detector that has only ever been run against a duplicate has not been tested; it has been demonstrated.

Register it beside a normal reporter during diagnosis. Keeping the standard line or dot reporter matters because this custom reporter deliberately emits only suspicious groups. Once the matrix guard is stable, you can remove the diagnostic reporter or retain its output as non-blocking inventory.

TypeScript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [
    [process.env.CI ? 'dot' : 'line'],
    ['./reporters/duplicate-coverage-reporter.ts'],
  ],
});

If the duplicate occurs only after a failure, inspect the result metadata rather than collection. The same test case can contain several results. A retry value of zero is the first attempt; later values identify retry attempts. The HTML report also distinguishes flaky tests, and the trace for the failed attempt can show why another attempt was scheduled. Neither changing the project name nor removing a project addresses that sequence.

When two CI jobs are involved, compare their complete commands and inputs. Look for a root job plus a package job, two matrix rows with the same browser and shard, or a reusable workflow called by both the push workflow and a package workflow. A single job name is weak evidence because reusable workflows and matrix expansions create separate executions from one YAML declaration. The command boundary is the useful unit.

Artifact directories can support the diagnosis, but they are not the primary oracle. Playwright creates a unique subdirectory for each running test inside a project's output directory, which protects parallel test output within a run. CI upload steps can still make two separate runs look like one evidence set if they publish under an indistinguishable external name. Include the package, project, shard, and run identity in uploaded artifact names, then use the report metadata to decide whether execution was redundant.

Fix the runner layer without losing package ownership

Choose one execution topology before deleting anything. In a central topology, one root config owns collection and CI filters that matrix. In a federated topology, package configs own collection and CI invokes selected packages. Mixing the two is what creates the hardest duplicates because each individual config looks reasonable.

For the central model, make project names predictable and filter only the browser dimension in the CI matrix. The root config in the earlier example produces checkout-chromium, checkout-firefox, and account-chromium. A browser job can select every package target for its browser with Playwright's supported wildcard project filter. Do not add a second package matrix unless each row selects a disjoint project set.

YAML
name: browser-tests

on:
  pull_request:

jobs:
  playwright:
    strategy:
      fail-fast: false
      matrix:
        browser: [chromium, firefox]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v6
        with:
          version: 10
          run_install: false
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps "${{ matrix.browser }}"
      - run: pnpm exec playwright test --project="*-${{ matrix.browser }}"

The order of the setup steps is deliberate. The pnpm action puts pnpm on PATH before Node's dependency-cache step asks for the pnpm store. The browser install and test command are each owned by one matrix row. If the repository caches browser binaries separately, adjust installation according to that policy, but keep the one-owner property visible.

For a federated model, the job planner should pass an explicit config path or execute in one package directory. Avoid a catch-all root Playwright command in the same workflow. A diagnostic shell sequence can print the selected configs before execution so reviewers can see whether a package was planned twice.

Shell
selected_configs=(
  "packages/account/playwright.config.ts"
  "packages/checkout/playwright.config.ts"
)

printf '%s\n' "${selected_configs[@]}"

for config in "${selected_configs[@]}"; do
  pnpm exec playwright test --config="$config" --project=chromium
done

This loop is intentionally serial and explicit. A production planner may run packages in parallel, but parallelism does not establish uniqueness. Generate the list from changed-package data, verify it contains unique normalized paths, and then fan it out. If two entries resolve to the same config, fail planning before either test command starts.

Project dependencies are not a substitute for monorepo scheduling. They are useful when setup tests must pass before dependent projects, and Playwright includes those dependencies when selected primary tests require them. A setup project appearing before several browser projects is not redundant merely because it has no browser coverage of its own. Keep it when the dependent tests need its state. Use --no-deps only for a deliberate diagnostic or workflow that supplies equivalent prerequisites, not as a blanket way to make the report shorter.

The central model can also expose server ownership problems. Two package targets with different base URLs may require two web servers, which is a genuine dimension and a real operational cost. Collapsing them into one project would point tests at the wrong application. Deduplicate the browser target only when the application scope and environment match; do not flatten independent products to make the project count aesthetically smaller.

Roll out the change without hiding lost coverage

Begin with an inventory, not deletion. Save the unfiltered --list output for the current root config and record each planned tuple: source file, title, package, browser, channel, environment, and intentional variant. Collection output is much cheaper to compare than two full browser runs, and it avoids repeating tests that create orders, send messages, or mutate shared environments.

Add the config-time uniqueness guard while the existing matrix is still present. It should identify the exact pair of owners and print the normalized key. Resolve one duplicate at a time. If the guard flags a project that the team believes is distinct, name the missing dimension and add it to both the data model and the key. That discussion is valuable because it turns an accidental configuration difference into an explicit coverage reason.

Move CI invocation in a separate step from project consolidation. First make one layer the owner while keeping the same intended targets. Then remove redundant project entries. Combining both changes makes a lower test count hard to explain: you cannot tell whether jobs stopped overlapping or a browser disappeared.

Canary the consolidated topology on one non-destructive suite or one browser. Compare collected identities, not only the number of passing tests. A total count can remain equal while the checkout package loses Firefox and another package gains a redundant Chromium row. The useful comparison checks that every intended tuple appears exactly once.

Keep old and new full runs away from the same shared test environment unless the tests are designed for concurrent duplication. Shadow execution sounds safe, but two suites can consume the same one-time token, update the same account, or race over cleanup. For stateful systems, compare collection first, then run the new topology in an isolated environment or on a read-only slice.

Reporter history also needs a migration rule. Dashboards may group trends by project name. Consolidating desktop-chrome into checkout-chromium can split the history even when browser behavior stays identical. Publish a small mapping for the reporting team or preserve the established name as the surviving label. That cost is preferable to continuing redundant execution, but it should not surprise release reviewers.

Artifact retention usually changes with the project count. Fewer redundant executions mean fewer traces and videos, yet central runs can produce larger combined reports. Give each job a unique artifact name and verify that report merging receives one blob per intended shard. Do not infer success from a smaller storage bill; verify the test inventory first, because lost coverage also reduces storage.

Once the new topology has passed several representative changes, remove the obsolete workflow lane or config export. Leaving a disabled or undocumented legacy path invites a later maintainer to re-enable it. Add a code-owner rule around the matrix factory if several packages contribute targets, and make the uniqueness failure explain how to declare a legitimate new dimension.

The trade-off is centralized coupling. A strict key can block a package that adds an unusual browser option, and a root matrix can become a coordination point for independent teams. A federated planner avoids some of that coupling but moves complexity into changed-package detection and report aggregation. Pick the cost your organization can operate, then keep one visible owner for each coverage tuple.

Know when two similar projects should stay

Do not collapse projects merely because browserName matches. Bundled Chromium and a branded Chrome channel can support different release questions. A stable channel and a prerelease channel are also distinct when the team uses them to catch upcoming browser changes. Keep separate names and include channel in the coverage key so the reason survives refactoring.

Device projects can share a browser engine while applying different viewport, user agent, touch, or mobile-emulation settings. Those settings affect observable behavior. If desktop and mobile are part of the release contract, deduplicating them by engine would remove coverage. Model a device or profile identifier as an explicit dimension instead.

Authentication roles are another legitimate split. An administrator and a read-only user may both run in Chromium against the same files and URL while loading different storage states. If assertions depend on authorization, storage state belongs in the tuple. If the two files contain identical public checks and never use the role, the second project may be redundant, but prove that from test intent rather than from the config label.

Locales, time zones, color schemes, reduced-motion settings, proxies, and feature flags can all justify separate projects when they protect a named behavior. They also multiply cost. Keep only variants tied to a release risk and make that risk discoverable in the target manifest. A hidden use override copied into a package config is difficult to review and easy to mistake for duplication.

Repeated runs should also remain when the repetition is deliberate. --repeat-each can expose nondeterminism, while retries can collect evidence after a failure. Those attempts answer a different question from cross-browser coverage. Label stress or flake-detection jobs clearly, retain attempt metadata, and exclude them from dashboards that claim unique feature coverage.

Correctly partitioned shards are not duplicate projects. Each shard sees the same project definition but owns a different subset of tests. Preserve shard identity in artifacts and verify the CI matrix contains each shard index once. If the same spec is present on two shards from the same run, fix shard planning or report composition before touching the browser config.

Finally, a setup dependency may appear in runs for several browsers because those browsers depend on it. Playwright's dependency graph is doing requested work, not cloning browser coverage. Consolidate the setup only when its state can be safely shared and the dependency contract permits it. Otherwise, the extra setup time is the price of isolation, and removing it turns a tidy report into a less reliable suite.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why does Playwright run the same spec twice in a monorepo?

Playwright creates a test case for every matching project. If two projects collect the same file with the same effective browser settings, both executions are valid from the runner's point of view, even when your coverage plan considers one redundant.

Does Playwright automatically deduplicate equivalent projects?

No automatic semantic deduplication is documented. Give one configuration owner responsibility for the project matrix, and reject duplicate coverage tuples while that configuration is being evaluated.

Can retries make a project look duplicated in the report?

A retry is another result for the same test case, not another collected browser project. Check the project name and retry index before changing the matrix, because removing a project will not fix a flaky first attempt.

Should a monorepo use one root config or one Playwright config per package?

Either model works when only one layer owns invocation. A root config gives you one visible matrix, while package configs preserve local autonomy but require CI to avoid launching the same package again through a root collector.

What should a duplicate-project CI check compare?

Compare the package scope, matched test set, browser engine, channel, target environment, and any deliberate variant such as locale or authentication state. Project names alone are labels, so different names do not prove different coverage.