testcost
Find out where your test suite's time actually goes.
pytest --durations ranks tests by how long each one took. That answers the wrong question most
of the time: a four minute suite rarely contains a four minute test. It contains a session fixture
that builds a database, or three hundred modules imported during collection, or a function-scoped
fixture that costs 20ms and runs two thousand times.
testcost attributes the time to collection, imports, fixture setup and teardown, and the test bodies themselves, so the largest line in the report is the thing worth fixing.
Installation
pip install testcost
Requires Python 3.10+ and pytest 8+.
Quick start
testcost run -- tests
4m12.3s for 1,284 tests
18.4s of that is importing, pytest included
seconds share where
------- ----- ------------
2m41.0s 64% fixtures
58.2s 23% tests
19.8s 8% collection
13.3s 5% unattributed
fixtures
total runs each scope fixture
------ ---- ----- -------- --------------------------------
1m52.0s 1 112s session postgres_container tests.conftest
38.4s 1920 20ms function db_transaction tests.conftest
10.6s 64 166ms module seeded_catalogue tests.fixtures.data
worth a wider scope
db_transaction costs 20ms and runs 1920 times (38.4s total). If it does not need to be rebuilt
per test, a module or session scope removes most of that.
What the numbers mean
fixtures is setup plus teardown, totalled per fixture across every time it ran. The each
column is that total divided by the number of setups, so it includes teardown work too. Teardown is
attributed by wrapping finalizers as they are registered, since pytest reports setup time for you
but hands teardown to finalizers attached to the fixture definition.
tests is the call phase only. pytest charges fixture setup to the test's setup phase, so adding the setup phase and the fixture totals together would count the same seconds twice.
collection is the time between pytest starting collection and finishing it, which is where your test modules get imported.
unattributed is whatever the run spent that none of the buckets explain: interpreter startup, pytest's own import, reporting, plugin overhead. It is reported rather than hidden, because a large value there is itself worth knowing.
imports is measured separately, in its own --collect-only run under -X importtime, with a
bare interpreter's startup subtracted. It covers pytest and its plugins as well as your modules,
so it overlaps the breakdown rather than being another slice of it. That is why it sits on its own
line. Skip it with --no-imports if you only want the session numbers.
Fixtures with the same name in different files are tracked separately, so a client fixture
defined in three conftests does not appear as one confusing total.
Budgets in CI
[tool.testcost]
pytest_args = ["tests"]
max_total_seconds = 300
max_collect_seconds = 10
max_import_seconds = 5
testcost check
fail 6m02.1s total, 19.8s collecting, 24.1s importing, 1284 tests
total took 6m02.1s, over the 5m00.0s budget by 1m02.1s
imports took 24.1s, over the 5.00s budget by 19.1s
heaviest fixtures: postgres_container (1m52.0s), db_transaction (38.4s), seeded_catalogue (10.6s)
Exits non-zero when a budget is blown, and names the heaviest fixtures so the next step is obvious.
Configuration
All keys live under [tool.testcost] in pyproject.toml. All are optional.
| Key | Type | Default | Meaning |
|---|---|---|---|
pytest_args |
list of strings | [] |
Arguments passed to pytest when none are given on the command line |
max_total_seconds |
number | none | Fail check if the whole run takes longer |
max_collect_seconds |
number | none | Fail check if collection takes longer |
max_import_seconds |
number | none | Fail check if imports take longer |
Command reference
| Command | What it does |
|---|---|
testcost run -- <pytest args> |
Profile a run and print the breakdown |
testcost run --json |
The same, as JSON |
testcost run --limit N |
Rows per table, default 15 |
testcost run --no-imports |
Skip the separate collect-only import pass |
testcost check |
Profile and exit non-zero if a budget is exceeded |
Both commands exit non-zero if pytest itself did, so a CI step cannot go green after the suite went red. The profile is still printed, because it is still valid.
How it compares
| Tool | Ranks tests | Fixture attribution | Import cost | CI budget | Maintained |
|---|---|---|---|---|---|
pytest --durations |
yes | no | no | no | yes |
pytest-durations |
yes | no | no | no | yes |
pytest-profiling |
yes | no | no | no | last release 2024 |
pytest-monitor |
yes | no | no | no | last release 2023 |
| testcost | yes | yes | yes | yes | yes |
Notes
Times come from one run, so a suite with genuinely variable timing needs more than one look. There is no averaging across runs yet.
The plugin is installed as a pytest entry point, which means it is imported by every pytest
process on the machine. Without --testcost-report or TESTCOST_REPORT it registers nothing and
every hook returns immediately.
Fixture teardown attribution wraps finalizers on the fixture definition. If another plugin replaces the finalizer list wholesale after setup, that fixture's teardown time will be missing rather than wrong.
Fixtures are identified by the file that defines them rather than the module name, because every
standalone conftest.py imports as conftest and three of them defining client would otherwise
collapse into one row belonging to none of them.
Running under -n with pytest-xdist is not supported yet: timings from several worker processes
are not merged.
Contributing
Bug reports and pull requests are welcome. uv sync then uv run pytest to get started.
License
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file testcost-0.1.0.tar.gz.
File metadata
- Download URL: testcost-0.1.0.tar.gz
- Upload date:
- Size: 64.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5e7eda5aaa7beda3fc694fbf2831b9447fb73674a1e39362cee4d37ca4778beb
|
|
| MD5 |
abbeb172de4209e3a3a51e9be0c1ed0e
|
|
| BLAKE2b-256 |
1464ab4887ef74b52e3a12ed903f93e566527bf4c1f41fbb813a654e32e1be51
|
File details
Details for the file testcost-0.1.0-py3-none-any.whl.
File metadata
- Download URL: testcost-0.1.0-py3-none-any.whl
- Upload date:
- Size: 16.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e2429f90e37cc620a154d263cc928604e65a12e642c6a08b4cdd373120e16d14
|
|
| MD5 |
33bc0accc2011b89fc2b2db9391400ea
|
|
| BLAKE2b-256 |
283c4af7854ddcc7c7ca134d22c858b61861027162b16ff56f98c3917059acec
|