A test suite can contain thousands of passing tests and still create a serious problem for a development team: the pipeline takes too long to finish.
When a developer submits a pull request, automated tests are expected to provide quick feedback. If a small change triggers a test suite that takes 40 minutes, developers spend more time waiting for results and less time fixing problems. The delay becomes even more noticeable when several branches are being validated simultaneously.
The difficulty is that test execution time is rarely distributed evenly. A handful of tests may consume a large portion of the total runtime because they repeatedly access databases, start containers, launch browsers, wait for external services, or perform expensive setup operations.
Finding these tests requires more than checking the total duration of a pipeline. Teams need visibility into individual test execution times, setup and teardown costs, resource consumption, and changes in performance over multiple runs.
This article explains how to identify slow tests, investigate their actual cost, and improve CI execution without removing important test coverage.
Why Some Tests Become Slow
Not every test takes the same amount of time. A unit test that checks a calculation may complete in milliseconds, while an integration test that starts a database and sends HTTP requests can take several seconds.
The main causes usually fall into a few categories.
Expensive test setup
A test may spend more time preparing its environment than executing its actual assertions.
For example, creating a new database, inserting hundreds of records, or initializing a complex application context before every test can significantly increase execution time.
If several tests share the same setup requirements, repeated initialization may be unnecessary.
External service dependencies
Tests that communicate with real APIs, databases, message brokers, or cloud services depend on network availability and external response times.
A test that normally takes 200 milliseconds may take several seconds when an external service is slow.
These dependencies also make test execution less predictable.
Browser automation
End-to-end tests using Selenium, Playwright, or other browser automation tools often take longer than unit tests.
Launching a browser, navigating pages, waiting for elements, authenticating users, and interacting with the interface all contribute to execution time.
Unnecessary waiting
Fixed delays are another common source of slow tests.
For example:
import time
def test_order_confirmation():
create_order()
time.sleep(5)
assert get_order_status() == "confirmed"The test waits five seconds regardless of whether the order is confirmed after 100 milliseconds.
A condition-based wait is generally more appropriate:
import time
def wait_for_order_confirmation(order_id, timeout=5):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if get_order_status(order_id) == "confirmed":
return True
time.sleep(0.1)
return FalseThis example uses polling to check the expected condition. In production test suites, prefer the application's event mechanism or the testing framework's condition-based wait utilities where available.
The key difference is that the test continues as soon as the required condition is satisfied instead of waiting for an arbitrary fixed duration.
Identify the Slowest Tests First
Before changing the test suite, collect execution-time information.
For Python projects using pytest, the built-in duration reporting option provides a simple starting point.
Run:
pytest --durations=20This displays the 20 slowest test execution phases.
To ignore very short entries and focus on tests taking at least one second:
pytest --durations=20 --durations-min=1.0An example report might look like this:
============================= slowest 20 durations =============================
8.42s call tests/test_orders.py::test_import_large_catalog
5.31s setup tests/test_api.py::test_customer_search
4.18s call tests/test_checkout.py::test_apply_coupon
3.75s teardown tests/test_browser.py::test_user_login
2.91s call tests/test_reports.py::test_generate_monthly_report
============================= 125 passed in 42.6s ===============================The report provides more than a list of test names. It identifies whether the time was spent during setup, execution, or teardown.
This distinction changes how the problem should be investigated.
For example:
A slow setup phase may indicate expensive fixtures or database initialization.
A slow call phase may indicate inefficient application code or excessive external requests.
A slow teardown phase may indicate incomplete resource cleanup or unnecessary shutdown operations.
A test that appears slow may not have an expensive assertion at all. Its setup or cleanup process may be responsible for most of the delay.
Track Test Duration Across CI Runs
A single test execution is not enough to determine whether a test is consistently slow.
Execution time can vary because of CPU availability, network conditions, shared infrastructure, database load, and parallel test execution.
A test that takes three seconds once may not need immediate optimization. A test that consistently takes eight seconds and accounts for a large portion of the pipeline deserves closer attention.
Store test execution results from every CI run.
Useful information includes:
Test name and unique identifier.
Commit SHA.
Branch name.
Test outcome.
Execution duration.
Setup and teardown duration, where available.
CI runner type.
Test environment.
Retry count.
For example, a historical report might look like this:
Test | Previous duration | Current duration | Change |
|---|---|---|---|
test_import_large_catalog | 3.2 sec | 8.4 sec | +5.2 sec |
test_customer_search | 2.8 sec | 5.3 sec | +2.5 sec |
test_apply_coupon | 4.0 sec | 4.2 sec | +0.2 sec |
test_user_login | 3.5 sec | 3.8 sec | +0.3 sec |
The first two tests deserve investigation because their execution times increased significantly.
A useful monitoring system should also compare tests within similar environments. Comparing a local laptop result with a heavily loaded CI runner can produce misleading conclusions.
Use averages or medians across several runs rather than treating the latest duration as a reliable baseline. Keep maximum duration available as well because occasional spikes can reveal infrastructure problems.
Add Slow-Test Reporting to a CI Pipeline
Once test duration reporting works locally, integrate it into the automated pipeline.
For a GitHub Actions workflow using pytest:
name: Automated Tests
on:
pull_request:
push:
branches:
- main
jobs:
tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest
- name: Run tests with duration reporting
run: |
pytest --durations=20 --durations-min=1.0 \
--junitxml=test-results.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results.xmlThe workflow runs tests and generates a JUnit XML report that can be retained as a CI artifact.
The duration output helps developers identify slow tests directly in the build logs. The XML report provides structured test results that can be processed by reporting tools.
For a production implementation, retain the reports from multiple builds and associate each result with its commit and runner environment.
Also remember that the example assumes dependencies are available through requirements.txt. Projects using Poetry, uv, or another dependency manager should use their existing installation process.
Join the conversation! Your thoughts help the community grow.