“Can’t wait for CI to finish.” “It’ll pass anyway.” — When CI takes too long, teams stop waiting and stop watching. This article explains how slow CI quietly destroys engineering culture, and walks through 7 concrete strategies to speed it up: pytest-xdist parallelization, stage splitting, caching, mocking, and more.
CI does more damage when it’s too slow than when it’s broken. A CI that fails in 3 minutes and a CI that fails in 30 minutes both catch the same bug — but they have completely different effects on the team.
📌 Who This Article Is For
- Teams where CI takes more than 10 minutes and people have given up waiting
- Engineers where “merge without waiting for CI” has become the norm
- QA engineers who want specific, actionable techniques to speed up GitHub Actions
- Anyone tired of CI getting slower every time the test suite grows
✅ What You Will Learn
- The 7 root causes of slow CI and how each one leads to “nobody watches” culture
- Ready-to-implement speedup strategies: pytest-xdist, stage splitting, caching, mocking
- An early-warning checklist to catch the problem before the culture collapses
👤 About the Author
Written by Yoshi, a QA engineer and test automation engineer with over 15 years of hands-on experience. Having lived through multiple projects where CI became something nobody waited for, and having recovered from E2E-heavy, no-parallelization, no-caching setups, these strategies come directly from the field.
📖 How This Article Differs from Related Content
- Escaping Flaky Test Hell: CI trust collapse caused by flaky results → If your tests are unreliable, start there
- GitHub Actions × Playwright CI Setup: How to configure CI → If you need CI setup instructions, start there
- This article: How CI being too slow causes cultural collapse — and 7 concrete speed-up strategies
📌 Key Takeaways
- CI speedup priority order: ① parallelization → ② stage splitting → ③ caching → ④ mocking → ⑤ test audit
- The “nobody waits” habit tends to set in over a period of months — and the longer it goes unaddressed, the harder it is to reverse. Act at the warning signs
- The ideal target is “first feedback within 5 minutes of a push.” When it exceeds 10 minutes, that’s when most teams start looking at improvements
“Is CI done?” “Still running. Just merge it for now.” — Once that conversation happens even once, the CI speed problem has already become a culture problem.
Unlike flaky tests, slow CI doesn’t break anything. It just quietly, gradually erodes the culture of checking CI at all. This article covers the collapse process that slow CI triggers — and the concrete strategies to stop it.
- How Slow CI Leads to “Nobody Watches” Culture
- 7 Root Causes of Slow CI
- Fix ①: Parallelize with pytest-xdist
- Fix ②: Split CI into Stages for Early Feedback
- Fix ③: Cache Dependencies to Skip Reinstalls
- Fix ④: Mock External I/O to Remove Wait Time
- Fix ⑤: Replace time.sleep with Explicit Waits
- Fix ⑥: Profile and Audit Slow Tests
- Fix ⑦: Fix the Inverted Test Pyramid
- Act Before It Collapses: Early Warning Checklist
- FAQ
How Slow CI Leads to “Nobody Watches” Culture
| Stage | Team Behavior | Typical CI Duration |
|---|---|---|
| ① Tolerable | “A bit long, but I can wait” | under 5 min |
| ② Disengaged | “I’ll watch CI while doing other things” | 5–10 min |
| ③ Ignoring | “Merging without waiting for CI” becomes the norm | 10–20 min |
| ④ Collapsed | “CI is red, whatever, merging anyway” | 20+ min |
7 Root Causes of Slow CI
Leaving CI as “just slow somehow” means it never gets fixed. Identifying which root cause is driving the slowness is the first step to making real improvements.
| Cause | Typical Symptom | Speed Impact |
|---|---|---|
| ① E2E-heavy | Policy of “write everything as an E2E test” | 🔴 High |
| ② No parallelization | Tests run sequentially, one at a time | 🔴 High |
| ③ No caching | pip install / npm install runs from scratch every time | 🔴 High |
| ④ External I/O dependency | Tests hit real DBs and external APIs | 🟡 Medium |
| ⑤ No stage design | All tests run in one job, all at once | 🟡 Medium |
| ⑥ Unnecessary sleep | time.sleep(3) scattered throughout tests | 🟡 Medium |
| ⑦ Bloated test suite | Old, duplicate tests have never been cleaned up | 🟢 Low–Medium |
Fix ①: Parallelize with pytest-xdist
The highest-impact fix. pytest-xdist distributes tests across multiple CPU cores. For suites with 100+ tests, it commonly cuts execution time by 2–4x.
# Install
# pip install pytest-xdist
# Local: auto-detect CPU count and run in parallel
pytest tests/ -n auto
# GitHub Actions: specify worker count explicitly
pytest tests/ -n 4
# For CI-only activation (recommended — avoids conflicts with local debugging)
# Set in GitHub Actions step, not in pytest.ini:
# run: pytest tests/ -n auto
#
# ⚠️ Adding -n auto to pytest.ini addopts causes issues with:
# VSCode Test Explorer, debugger, coverage measurement, flaky investigationpytest --randomly-seed=1234 (requires pip install pytest-randomly) to confirm there are no order-dependent tests.scope="function" driver fixture is generally safe to parallelize. Note: state-modifying scope="session" fixtures (login state, DB writes) are not safe to parallelize — workers share state and produce flakiness. Read-only data or immutable config fixtures are safe to keep as session-scoped.Fix ②: Split CI into Stages for Early Feedback
“Nothing to learn until all tests finish” is a design that has a ceiling regardless of how fast it runs. Splitting into unit → integration → E2E stages means bugs surface at the earliest possible point.
# GitHub Actions stage split example (.github/workflows/test.yml)
# Note: for large projects, consider reusable workflows or composite actions
# to avoid repeating the setup steps below in every job.
name: CI Pipeline
on: [push, pull_request]
jobs:
# Stage 1: unit tests (target: under 1 minute)
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip" # built-in pip cache — no separate cache step needed
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run unit tests
run: pytest tests/unit/ -n auto --tb=short
# Stage 2: integration tests (target: under 3 minutes)
# runs only if unit tests pass
integration-tests:
needs: unit-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run integration tests
run: pytest tests/integration/ -n auto
# Stage 3: E2E tests (target: under 5 minutes)
# runs only if integration tests pass
e2e-tests:
needs: integration-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Install Playwright browsers
run: playwright install chromium --with-deps
- name: Run E2E tests
run: pytest tests/e2e/ -n 2Fix ③: Cache Dependencies to Skip Reinstalls
Running pip install from scratch every run can cost 2–5 minutes by itself. When dependencies haven’t changed, caching skips the install entirely.
# GitHub Actions caching example
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ✅ Option A: built-in pip cache via setup-python (simpler)
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip" # invalidates when requirements.txt changes
- name: Install dependencies
run: pip install -r requirements.txt
# ✅ Option B: manual cache with actions/cache (more control)
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
# ✅ Cache Playwright browser binaries
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('requirements.txt') }}
- name: Install Playwright browsers (only when cache misses)
run: playwright install chromium --with-depshashFiles('requirements.txt') ensures the cache only invalidates when dependencies actually change. Playwright browser caching is particularly valuable — it saves the 1–3 minute install on almost every run. The cache: "pip" option in actions/setup-python@v5 is the simpler option and handles the key design automatically.Fix ④: Mock External I/O to Remove Wait Time
Tests that hit real DBs, external APIs, or email services add network latency and third-party response time as a CI bottleneck.
from unittest.mock import patch, MagicMock
import requests
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, declarative_base
# Base definition (in real projects, this lives in a models file)
# from sqlalchemy.orm import declarative_base
# Base = declarative_base()
Base = declarative_base()
# ❌ Slow: calls a real external API every time
def test_send_notification():
result = requests.post(
"https://api.slack.com/api/chat.postMessage",
json={"channel": "#test", "text": "Test complete"}
)
assert result.status_code == 200
# 200–500ms per network round-trip
# ✅ Fast: mock the external API — returns instantly
@patch("requests.post")
def test_send_notification_fast(mock_post):
mock_post.return_value = MagicMock(status_code=200)
result = requests.post(
"https://api.slack.com/api/chat.postMessage",
json={"channel": "#test", "text": "Test complete"}
)
assert result.status_code == 200
# Under 1ms — no network involved
# ✅ Use an in-memory DB fixture instead of a real database
@pytest.fixture
def db_session():
"""Fast DB fixture using SQLite in-memory database"""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session
# Automatically closes and rolls back when the with block exitsFix ⑤: Replace time.sleep with Explicit Waits
If 100 tests each have one time.sleep(2), that’s over 3 minutes of fixed waste. Sleep is also a primary cause of flakiness — it’s the first thing to clean up from a CI speed perspective.
# Find all time.sleep usage (grep -R avoids matching asyncio.sleep or page.wait_for_timeout)
# grep -R "time.sleep(" ./tests
# ❌ Slow: fixed wait — always waits the full duration
import time
time.sleep(3)
driver.find_element(By.ID, "result").click()
# ✅ Fast: wait only until the condition is met
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
element = wait.until(EC.element_to_be_clickable((By.ID, "result")))
element.click()
# Executes the instant the element is ready — average 0.3s instead of 3sFix ⑥: Profile and Audit Slow Tests
“We need all of them” without a regular audit leads to CI bloat. Profiling for the slowest 10–20 tests immediately surfaces the highest-value cleanup targets.
# pytest built-in feature — no additional plugin required
pytest tests/ --durations=20
# Recommended: only show tests that take 1 second or more (reduce noise)
pytest tests/ --durations=20 --durations-min=1.0
# Example output:
# 15.23s call tests/e2e/test_checkout.py::test_full_purchase_flow
# 8.91s call tests/e2e/test_login.py::test_login_with_mfa
# 6.44s call tests/integration/test_report.py::test_generate_pdf_report
# 3.12s call tests/unit/test_user.py::test_create_user ← investigate: unit test over 3s
# ❌ Warning patterns:
# - Unit test taking 3+ seconds → external I/O may have leaked in
# - E2E test taking 20+ seconds → scope may be too broad
# - Multiple tests that are minor variations of the same scenario → consolidate with parametrizeFix ⑦: Fix the Inverted Test Pyramid
When CI is fundamentally slow, the root cause is often an inverted test pyramid — E2E tests keep accumulating while unit and integration tests stay thin.
| Test Type | Ideal Ratio | Typical Speed | What It Covers |
|---|---|---|---|
| Unit tests | 70% | < 0.1s each | Individual functions and classes |
| Integration tests | 20% | < 1s each | Module interactions, APIs |
| E2E tests | 10% | 10–30s each | Core user scenarios |
Act Before It Collapses: Early Warning Checklist
3 or more matching items means it’s time to start improving now.
| Check Item | Applies? |
|---|---|
| CI takes more than 10 minutes to complete | ✅ / ❌ |
| PRs are sometimes merged before CI finishes | ✅ / ❌ |
| Tests run sequentially — no parallelization | ✅ / ❌ |
| pip install / npm install runs from scratch every time | ✅ / ❌ |
10 or more time.sleep() calls in the test code | ✅ / ❌ |
| E2E tests make up more than 50% of the suite | ✅ / ❌ |
| All tests are in one job — nothing runs until everything is done | ✅ / ❌ |
🚀 Start Here: Top 3 Immediate Actions
| 1st | Parallelize with pytest-xdist (do it today — highest impact)pip install pytest-xdist → add -n auto to your CI run command |
| 2nd | Add caching to GitHub Actions (takes 1–2 hours) Add cache: "pip" to setup-python, and cache Playwright browsers |
| 3rd | Find and replace time.sleep callsgrep -R "time.sleep(" ./tests — list every instance and replace with explicit waits |
FAQ
Q. What is a realistic target CI execution time?
A practical target is getting the first feedback within 5 minutes of a push — many teams find this is around where waiting becomes viable again. That said, what’s tolerable varies by project size and team workflow. When CI exceeds 10 minutes, it’s generally worth considering improvements. A concrete starting goal: “reduce current time by 30%.” That’s more actionable than an absolute number.
Q. pytest-xdist makes my tests unstable — what do I do?
Increased flakiness after parallelization almost always traces back to shared state between tests or execution order dependencies. Run pytest --randomly-seed=1234 to check for order dependencies, and make test data unique with uuid. State-modifying scope="session" fixtures are the most common culprit — switching them to scope="function" resolves most cases.
Q. We have 200 E2E tests. Can we parallelize all of them?
If tests are independent (no shared DB or global state), yes. But 200 E2E tests is itself a warning sign. Auditing which ones actually need to be E2E — and moving validation logic, API response checks, and error message tests to unit or API tests — combined with parallelization can produce much larger gains than parallelization alone.
Q. How much does GitHub Actions caching actually help?
When dependencies are unchanged, caching typically saves 2–5 minutes for pip install and 1–3 minutes for Playwright browser install — often 3–8 minutes saved per run. It consistently has one of the best effort-to-impact ratios of any CI speedup. The cache: "pip" option in actions/setup-python@v5 is the simplest implementation. Just make sure your cache key is tied to requirements.txt so stale dependencies don’t silently slip through.
📖 Related Articles
CI/CD Setup and Speed
- How to Automate E2E Tests with GitHub Actions × Playwright
- Setting Up Allure Reports | Visualizing CI Results
Test Quality and Design
- Escaping Flaky Test Hell | Restoring CI Trustworthiness
- Top 5 Test Automation Failures | The E2E-Heavy Trap
- 7 Test Cases You Should Not Automate
- Page Object Model | The Design Pattern That Prevents Maintenance Cost Explosion
Roadmap
CI exists to protect code quality — but when it takes too long, it becomes something nobody waits for. A technical problem silently becomes a cultural one. Measure your current execution time today, and make one improvement. A team with a 5-minute CI and a team with a 30-minute CI will look very different a year from now.
📋 Summary
- Slow CI creates cultural collapse. The habit of ignoring CI tends to set in over months — the longer it goes unaddressed, the harder it is to reverse
- Fix priority: ① pytest-xdist parallelization (highest impact, do today) → ② caching (saves 3–8 min) → ③ stage splitting (early feedback)
- Find
time.sleepwithgrep -R "time.sleep(" ./testsand replace with WebDriverWait / expect() - E2E-heavy test suites are the root cause of structural CI slowness. Target 70% unit / 20% integration / 10% E2E
- Use
pytest --durations=20 --durations-min=1.0to surface the slowest tests (built-in — no plugin needed) - Parallelization flakiness → data conflict. Use unique data per test and avoid state-modifying session-scoped fixtures
