Guides / Optimize CI tests on PRs

Too much work per run

Optimize CI tests on pull requests

By Keith Mazanec, Founder, CostOps ยท Updated February 6, 2026

A developer changes one file. GitHub Actions runs the full test suite: 400 tests, 18 minutes, every push. The same suite runs 50 times a day across the team. Most of those tests cover code that was never touched. Test steps are the single largest consumer of PR runtime for most repositories. Reducing what you test on PRs, without reducing what you test before merge, is one of the highest-ROI CI optimizations you can make.

Symptoms

How to tell if PR tests are costing too much

Open your repository's Actions tab and look at completed PR runs. These patterns indicate test steps are consuming more runtime than they should:

  • Tests dominate step runtime. When you break down a PR workflow by step category, test steps account for 50% or more of total step seconds. Build, lint, and setup are not the bottleneck. Tests are.

  • Full suite on every PR, regardless of scope. A one-line CSS change triggers the same test run as a database migration. There is no relationship between what changed and what gets tested.

  • Low test failure rate on PRs. Tests almost always pass. A failure rate under 5% means you are spending significant minutes confirming what you already expected: the code works. The test suite is acting as a tax, not a safety net.

Metrics

What full-suite testing on every PR actually costs

Consider a team running 50 PR-triggered workflows per day. Each workflow has a test job that takes 18 minutes on a Linux 2-core runner. Here is what happens when you scope tests to PRs and run full suites only on the merge queue:

Before: full suite on every PR

PR runs/day 50
Test minutes/run 18
Monthly test minutes 19,800
Monthly test cost $119/mo

At $0.006/min (Linux 2-core)

After: focused suite on PRs (40% fewer test min)

PR runs/day 50
Test minutes/run 11
Monthly test minutes 12,100
Monthly test cost $73/mo

Save $46/mo · $552/year · per workflow

That is one workflow on Linux. On macOS runners at $0.062/min, the same test minutes go from $1,228/mo to $750/mo. That is $478/mo saved on test steps alone. And this does not account for the developer time recovered by faster PR feedback loops.


Fix 1

Skip tests when only non-code files change

The simplest optimization is to not run tests at all when the change cannot possibly break them. Docs-only, config-only, and README-only PRs should not trigger your test suite. GitHub's native paths-ignore filter handles this at the workflow level, but it has a known limitation with required status checks (skipped workflows do not report a "pass" status).

For finer-grained control, use dorny/paths-filter to conditionally run test jobs based on which directories actually changed. The workflow always runs (satisfying branch protection), but the test job is skipped when only irrelevant files are modified.

.github/workflows/ci.yml
name: CI

on:
  pull_request:

jobs:
  changes:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: read
    outputs:
      backend: ${{ steps.filter.outputs.backend }}
      frontend: ${{ steps.filter.outputs.frontend }}
    steps:
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            backend:
              - 'app/**'
              - 'lib/**'
              - 'config/**'
              - 'Gemfile*'
            frontend:
              - 'client/**'
              - 'package*.json'

  backend-tests:
    needs: changes
    if: ${{ needs.changes.outputs.backend == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: bundle exec rspec

  frontend-tests:
    needs: changes
    if: ${{ needs.changes.outputs.frontend == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cd client && npm test

This pattern works well for repositories with clear directory boundaries (backend vs frontend, or monorepo packages). The changes job takes seconds to run and costs effectively nothing. When a PR only touches frontend code, the backend test job is skipped entirely, saving its full runtime.

One caveat: skipped jobs with an if condition still report a "pass" status to branch protection, unlike workflows skipped via paths-ignore. This makes dorny/paths-filter the safer choice when your test workflow is a required check.

Fix 2

Run a focused suite on PRs, full suite on merge queue

The core idea: PRs get fast feedback from a focused test suite (unit tests, smoke tests for the changed area). The full integration and E2E suite runs only when the PR enters the merge queue or after merging to main. This gives developers fast iteration cycles while maintaining full coverage at the merge boundary.

Use the merge_group event to trigger comprehensive testing. GitHub's merge queue tests PRs against the actual state they will merge into, catching integration issues that per-PR CI misses.

.github/workflows/ci.yml
name: CI

on:
  pull_request:
  merge_group:

jobs:
  # Always runs on PRs: fast feedback in under 5 minutes
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run test:unit

  # Only runs in merge queue: full suite, 15-20 minutes
  full-tests:
    if: ${{ github.event_name == 'merge_group' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run test:all
      - run: npm run test:e2e

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

If your team does not use merge queues, you can achieve a similar result by running the full suite only on push to main:

.github/workflows/full-tests.yml
name: Full test suite

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run test:all
      - run: npm run test:e2e

The tradeoff: failures discovered after merge require a follow-up fix instead of blocking the PR. For most teams, the faster PR cycle and lower cost outweigh this risk, especially when the full suite failure rate is low.

Fix 3

Shard tests to reduce wall-clock time (without increasing cost)

Test sharding splits your suite across parallel jobs to reduce wall-clock time. The key constraint: sharding does not save money unless you also reduce total minutes. Each shard has its own setup overhead (checkout, install dependencies, database setup), and GitHub Actions bills each job separately with per-minute rounding.

A 20-minute test suite split into 4 shards gives you 5-minute wall-clock time, but you pay for 4 × (setup + test time). If setup takes 3 minutes per shard, you go from 20 total minutes to 4 × 8 = 32 total minutes. You got faster feedback but paid 60% more.

The sweet spot is typically 2-4 shards for suites under 30 minutes. Beyond that, setup overhead dominates and total billable minutes increase. Tools like Playwright's built-in sharding make this straightforward:

.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          cache: npm
      - run: npm ci
      - run: npx playwright test --shard=${{ matrix.shard }}/4

Measure total minutes, not just wall-clock time. After adding shards, compare the sum of all shard durations against the original single-job duration. If total minutes went up, you have too many shards or too much setup overhead per shard. Reduce shard count or consolidate setup into a shared build job that runs once and passes artifacts to test shards.

For RSpec, Jest, and other frameworks without built-in sharding, split by test file or use timing-based splitting. Jest's --shard flag (available since v28) works the same way:

# Jest (v28+)
- run: npx jest --shard=${{ matrix.shard }}/4

# RSpec with parallel_tests
- run: bundle exec parallel_rspec --group-by=runtime --only-group=${{ matrix.shard }}

Fix 4

Run only tests affected by the change

Instead of running the full suite and filtering by directory, you can use test impact analysis to run only the tests that exercise the code paths your PR touches. This is the most aggressive optimization: teams report 50-80% reductions in PR test minutes.

Most major test frameworks support some form of change-aware test selection. The tradeoff is accuracy. These tools rely on dependency tracking, which can miss indirect dependencies or dynamic imports. Always run the full suite on main or in the merge queue as a safety net.

Jest / Vitest
# Jest: tests related to changed files
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
- run: npx jest --changedSince=origin/main

# Vitest: same concept
- run: npx vitest --changed origin/main
pytest / RSpec
# pytest-testmon: coverage-based selection
- run: pytest --testmon

# RSpec: run changed spec files only
- run: |
    SPECS=$(git diff --name-only origin/main \
      | grep _spec.rb)
    [ -n "$SPECS" ] && bundle exec rspec $SPECS

One caveat: Jest's --changedSince requires fetch-depth: 0 in the checkout step so git history is available for the diff. Shallow clones (the default) will cause it to fall back to running all tests. The RSpec git-diff approach only catches changed test files, not tests affected by changed source files. For deeper dependency analysis, consider tools like Crystalball (Ruby) or pytest-testmon (Python).

For monorepos using Nx or Turborepo, affected-target detection is built in. These tools understand your dependency graph and will automatically test only packages affected by the change:

# Nx: test only affected packages
- run: npx nx affected --target=test --base=origin/main --head=HEAD

# Turborepo: test packages changed since main
- run: npx turbo run test --filter='...[origin/main]'

Reference

Which approach to use

These fixes are not mutually exclusive. Most teams combine path-based filtering with a focused PR suite and full-suite merge queue testing. Use this table to prioritize based on your setup effort and expected savings:

Strategy Effort Typical savings
Path-based test filtering Low 30-50% of runs skipped
PR-focused + merge queue full suite Medium 40-60% fewer PR test min
Test sharding (2-4 shards) Medium 2-4x faster (wall-clock)
Affected test selection High 50-80% fewer test min

Start with path-based filtering (Fix 1). It requires minimal configuration and catches the easy wins. If tests still dominate your PR runtime after that, move to a PR-focused suite (Fix 2). Sharding (Fix 3) helps with wall-clock time but does not reduce cost unless you also reduce total test minutes. Affected test selection (Fix 4) delivers the largest savings but requires framework-specific tooling and a safety net on main.

Related guides

Guides / Optimize CI tests on PRs

See which tests are costing the most

CostOps breaks down PR runtime by step category so you can see exactly how much test steps cost. Spot the optimization opportunities before changing your YAML.

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

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