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
90 runs × 25 min × 22 days × $0.006/min
After: smoke on PR, full on merge
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.
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:
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:
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.
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:
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.
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.
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
Flaky Tests Cost Real Money
Detect and fix test flakiness that drives unnecessary reruns and wasted minutes.
Reduce Full CI on Branch Pushes
Scope triggers to PRs and main to cut non-PR CI minutes 60-80%.
Stop Docs Changes Triggering Full CI
Use path filters to skip CI on README, changelog, and config-only commits.
Canceled Runs Are Wasting Your CI Minutes
Use concurrency groups to auto-cancel superseded runs and minimize waste.