Guides / E2E and integration tests running too often

Too much work per run

E2E and integration tests running too often

By Keith Mazanec, Founder, CostOps ยท Updated January 30, 2026

A developer pushes a one-line CSS fix to a PR. GitHub Actions spins up a full Playwright suite, burning 25 minutes on browser launches, database seeding, and API calls. The tests pass, like they do 95% of the time. The developer waits, the runner bills, and nothing was learned that a unit test didn't already cover.

E2E and integration tests are the most expensive jobs in most CI pipelines. Running them on every PR push is the default, but it's rarely the right default. The fix is to match test frequency to risk by running a smoke subset on PRs and deferring the full suite to merge time.

Symptoms

How to tell if E2E tests are dominating your CI spend

Open your repository's Actions tab and sort by workflow runtime. If your E2E workflow is consistently at the top, you likely have this problem:

  • E2E dominates PR runtime. Your E2E or integration test job takes 15–30 minutes per run, while lint and unit tests finish in 2–3 minutes. The E2E job accounts for 70–80% of the total workflow minutes, even though it's just one of several jobs.

  • Low E2E failure rate on PRs. Your E2E tests pass 90–98% of the time on pull requests. The rare failures are either flaky tests or integration issues that only surface after merge. The signal-to-cost ratio is poor, because you're spending the most minutes on the job that catches the fewest bugs at the PR stage.

  • E2E runs on every push, not just review-ready PRs. Every intermediate commit, including WIP pushes, fixup commits, and rebases, triggers a full E2E suite. Developers push 3–5 times per PR, and each push runs 25 minutes of E2E tests. Even with concurrency groups cancelling stale runs, you're burning minutes on tests the developer didn't ask for.

  • Non-code changes trigger E2E. A docs-only or config-only change still runs the full E2E suite because there are no path filters on the E2E job. The suite takes 25 minutes to confirm that a README edit didn't break the checkout flow. See how docs changes trigger full CI for path filter patterns.

Metrics

How much do unnecessary E2E runs cost

E2E tests are expensive because they're long. A typical Playwright or Cypress suite runs 15–30 minutes. On a team pushing 30 PRs/day with an average of 3 pushes per PR, the math gets serious fast. Here's a realistic scenario on Linux runners:

Before: E2E on every push

E2E runs/day 90
Minutes/E2E run 25
Monthly E2E minutes 49,500
Monthly E2E cost $297/mo

90 runs × 25 min × 22 days × $0.006/min

After: smoke on PR, full on merge

PR smoke runs/day 90
Minutes/smoke run 5
Full E2E runs/day (merge) 30
Monthly total minutes 26,400
Monthly E2E cost $158/mo

Save $139/mo · $1,668/year · per workflow

That's on Linux at $0.006/min. On macOS runners at $0.062/min, which is common for mobile or desktop E2E suites, the before cost is $3,069/mo and the after is $1,637/mo, saving $1,432/mo. E2E is where runner costs hit hardest because the durations are long and the per-minute rates compound.


Fix 1

Run a smoke subset on PRs, full suite on merge

Most E2E suites follow the Pareto principle: a small subset of tests covers the critical user paths (login, checkout, signup, core CRUD). Running just those 10–20 smoke tests on every PR gives you fast feedback on obvious breakage in 3–5 minutes instead of 25. The full suite runs on merge to main, where it validates the complete set.

Both Playwright and Cypress support tagging tests. In Playwright, use @smoke tags in test titles and filter with --grep. In Cypress, use spec file patterns or tags via cypress-grep.

.github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps

      # Smoke tests on PRs, full suite on main
      - name: Run E2E tests
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            npx playwright test --grep @smoke
          else
            npx playwright test
          fi

Tag your critical-path tests with @smoke in the test title:

tests/checkout.spec.ts
test('user can complete checkout @smoke', async ({ page }) => {
  // This test runs on every PR
  await page.goto('/checkout');
  // ...
});

test('admin can export CSV report', async ({ page }) => {
  // This test only runs on merge to main
  await page.goto('/admin/reports');
  // ...
});

A good smoke set covers 10–20 tests across your core user journeys: authentication, the primary CRUD flow, payment/checkout, and any high-traffic pages. If a PR breaks one of these, the smoke suite catches it in 3–5 minutes. Edge cases and admin flows get tested on merge.

If your E2E failures are mostly from flaky tests rather than real bugs, fixing flakiness is even more impactful than reducing frequency.

Fix 2

Defer full E2E to the merge queue

Instead of running the full E2E suite on every PR push, run it only when the PR enters the merge queue. This means E2E tests run once per PR (at merge time) instead of 3–5 times per PR (on every push). The merge queue also tests against the actual state of main, so you catch integration issues that PR-level testing misses.

Use the merge_group event to trigger the full suite, and limit pull_request to lightweight checks only:

.github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main]
  merge_group:  # Full E2E runs in the merge queue

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

Enterprise Cloud Merge queues require GitHub Enterprise Cloud for private repositories. Public repositories in an organization can use them on any plan.

With this approach, the E2E workflow doesn't trigger on pull_request at all. PR checks include only lint, type-check, and unit tests. When the PR is approved and added to the merge queue, the full E2E suite runs against the merged state. If E2E fails, the PR is ejected from the queue without blocking other PRs.

One caveat: this removes E2E feedback during development. Developers won't see E2E results until they add the PR to the merge queue. If your team relies on E2E as a development feedback loop, combine this with Fix 1 and run smoke tests on PRs while running the full suite in the merge queue.

For a broader look at gating CI behind merge queues, see reducing full CI on branch pushes.

Fix 3

Skip E2E when only non-code files change

Documentation updates, config file tweaks, and CI workflow changes don't need E2E validation. But if your E2E workflow triggers on all pull_request events without path filters, every docs PR runs 25 minutes of browser tests. Use paths-ignore to skip E2E for changes that can't affect application behavior.

.github/workflows/e2e.yml
name: E2E Tests

on:
  pull_request:
    paths-ignore:
      - 'docs/**'
      - '**.md'
      - '.github/**'
      - 'LICENSE'
      - '.gitignore'
      - '.vscode/**'

Alternatively, use paths as an allow-list to trigger E2E only when application code changes. This is stricter, since forgetting to add a new directory means E2E won't run for it, but it avoids the opposite mistake of forgetting to ignore a new non-code directory:

allow-list approach
on:
  pull_request:
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'
      - 'package-lock.json'
      - 'playwright.config.ts'

One caveat: if the E2E workflow is a required status check, skipping it with paths-ignore leaves the check in a permanent “Pending” state, which blocks docs-only PRs from merging. The workaround is to use a dorny/paths-filter action inside the job so the workflow always runs (satisfying the required check) but skips the expensive E2E steps when paths don't match.

Fix 4

Add a label trigger for on-demand E2E

For PRs that do need E2E feedback during development, such as a risky refactor, a new feature touching the checkout flow, or a dependency upgrade, give developers a way to explicitly request E2E. A PR label like run-e2e triggers the full suite on demand, without running it on every push by default.

.github/workflows/e2e-on-demand.yml
name: E2E Tests (On Demand)

on:
  pull_request:
    types: [labeled, synchronize]

jobs:
  e2e:
    if: contains(github.event.pull_request.labels.*.name, 'run-e2e')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

The labeled event type triggers the workflow when a label is added. The synchronize type triggers on new pushes, so once the label is applied, subsequent pushes also run E2E. The if condition ensures the job only runs when the run-e2e label is present. No label, no E2E, no cost.

This pairs well with Fix 1. By default, PRs get smoke tests. Developers who need full E2E add the run-e2e label. The merge queue runs the full suite regardless. Three tiers of coverage, each matched to the level of risk.


Reference

Recommended E2E testing strategy by trigger

Match E2E coverage to the trigger event. Higher-risk events get more testing. Lower-risk events get lighter coverage:

Trigger E2E coverage Duration
pull_request Smoke tests only (@smoke tag) 3–5 min
pull_request + run-e2e label Full E2E suite (on demand) 15–25 min
merge_group Full E2E suite (pre-merge gate) 15–25 min
push (main) Full E2E suite (post-merge validation) 15–25 min

The key insight: E2E tests on PRs are a convenience, not a gate. The real gate is the merge queue or the post-merge run on main. PR-level E2E is optional feedback, so make it opt-in, not default.

Reference

Complete E2E workflow with tiered coverage

Here's all four fixes combined into a single workflow. Smoke tests run on PRs, full E2E runs on the merge queue and main, and the run-e2e label overrides to full coverage during development.

.github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main]
    paths-ignore:
      - 'docs/**'
      - '**.md'
      - 'LICENSE'
  pull_request:
    types: [opened, synchronize, labeled]
    paths-ignore:
      - 'docs/**'
      - '**.md'
      - 'LICENSE'
  merge_group:

concurrency:
  group: e2e-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps

      # Determine test scope
      - name: Run E2E tests
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ] && \
             ! echo "${{ join(github.event.pull_request.labels.*.name, ',') }}" | grep -q "run-e2e"; then
            echo "Running smoke tests only"
            npx playwright test --grep @smoke
          else
            echo "Running full E2E suite"
            npx playwright test
          fi

Related guides

Guides / E2E and integration tests running too often

See which E2E workflows burn the most minutes

CostOps tracks per-workflow cost, runtime distribution, and failure rates. See exactly how much your E2E suite costs per PR before you restructure it.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.