tox
tox builds a fresh virtual environment per configured target, installs your package into it the way a user would, and runs the commands you listed. That is the whole idea: instead of testing the source tree you happen to have imported, you test the built wheel against a clean set of dependencies, once per Python version or dependency combination. Configuration lives in tox.ini, tox.toml or pyproject.toml under tool.tox, and the same file drives your laptop and your CI job, so a green run locally means something. Version 4 was a full rewrite of version 3 and the two are not config compatible.
Still the default answer for testing a Python library across versions, and the tooling around it (plugins, CI integrations, near-zero open issues) shows it. Skip it for applications with a single pinned environment, and budget real time if you are migrating a tox 3 config.
Use it if
- You publish a library that must work on several Python versions or several versions of a key dependency, and you want a matrix you can run locally
- You want CI and local runs to execute the same commands from the same file instead of drifting apart in a workflow YAML
- You want tests to run against the built wheel or sdist rather than the working directory, which catches missing package data and bad packaging config
- You want one command that also runs lint, type check and docs builds as separate isolated environments
- You are building an application, not a library: if you deploy one pinned environment there is nothing to matrix over and a Makefile plus a lock file is less machinery
- You are already committed to hatch or another all-in-one project manager, which does environment matrices as part of the same tool
- You expect tox to install interpreters for you: it finds Pythons that already exist on the machine and skips or fails on the ones that do not
- You are inheriting a tox 3 config and cannot afford the migration, because tox 4 rejects a fair amount of tox 3 syntax and most search results still answer for tox 3
- Your test suite is slow and you mostly run one Python version, since paying for environment rebuilds on every run buys you nothing
Setup reality
pip install tox is quick, and the first real run is not: every environment in env_list gets its own virtualenv under .tox plus a build of your package, so expect minutes before the first test executes. After that runs are cached, which is also the main source of confusion, because a changed transitive dependency or an edited pyproject.toml can leave a stale environment behind and the fix is almost always tox --recreate. Two other things bite newcomers. The interpreters must already exist on the machine, so a py3.9 environment on a box with only 3.12 either fails or silently skips depending on skip_missing_interpreters. And the short forms read oddly: tox r means the run subcommand while tox -r means recreate, so the two are easy to type in the wrong order.
Patterns
Minimal tox.ini for a libraryminimal-ini
[tox]
env_list = py310, py311, py312, py313
[testenv]
deps = pytest
commands = pytest {posargs}envlist still works as an alias for env_list, but new files should use env_list to match the current docs.
The same config as tox.tomltoml-config
env_list = [ "3.11", "3.12", "3.13" ]
[env_run_base]
description = "run tests under {env_name}"
package = "wheel"
deps = [ "pytest" ]
commands = [ [ "pytest", { replace = "posargs", default = [ "tests" ], extend = true } ] ]In TOML each command is a list of arguments, so nothing is shell-split; env_run_base is the TOML equivalent of the [testenv] section.
Keep the config in pyproject.tomlpyproject-config
[tool.tox]
env_list = [ "3.12", "3.13", "type" ]
[tool.tox.env_run_base]
deps = [ "pytest" ]
commands = [ [ "pytest", { replace = "posargs", default = [ "tests" ], extend = true } ] ]
[tool.tox.env.type]
deps = [ "mypy" ]
commands = [ [ "mypy", "src" ] ]One config file fewer, at the cost of a pyproject.toml that gets long; tox reads tox.toml first if both exist.
Run a single environment and pass arguments throughrun-one-env
tox run -e py312 -- -k test_login -x
tox r -e py312 # r is the short alias for runEverything after the bare -- lands in {posargs}; without it your pytest flags are read by tox instead.
Run the whole matrix in parallelparallel
tox run-parallel
tox p --parallel-no-spinnerOutput is buffered per environment and printed when it finishes, so a hung environment looks like silence rather than a stuck log.
See what tox thinks your config saysinspect-config
tox list # or: tox l
tox config -e py312 # or: tox c -e py312tox config expands every substitution, which is the fastest way to settle an argument about where a value came from.
Matrix over Python and a dependency versiongenerative-envs
[tox]
env_list = py{311,312,313}-django{42,52}
[testenv]
deps =
pytest
django42: Django>=4.2,<5.0
django52: Django>=5.2,<5.3
commands = pytest {posargs}The text before the colon is a factor filter; a line with no factor applies to every environment in the matrix.
Set and pass environment variablesenv-vars
[testenv]
set_env =
PYTHONWARNINGS = error
DATABASE_URL = sqlite:///{env_tmp_dir}/test.db
pass_env =
CI
PYTEST_*
commands = pytest {posargs}Environments are isolated by default, so anything your tests read from the shell (tokens, proxy settings) has to be listed in pass_env.
Add a lint environment that skips the installlint-env
[testenv:lint]
skip_install = true
deps = ruff
commands =
ruff check .
ruff format --check .skip_install saves a package build for environments that never import your code; without it every lint run rebuilds the wheel.
Install the project in editable mode for one environmenteditable-install
[testenv:dev]
package = editable
deps = pytest
commands = pytest {posargs}package = wheel is the default and the one that actually tests your packaging; use editable only where the fast feedback loop matters more.
Materialise an environment your editor can point atdevenv
tox devenv -e py312 .venvThis creates a normal editable virtualenv at the given path, which is how you get IDE completion without hand-building a second environment.
Force a clean rebuildrecreate
tox --recreate
tox -r -e py312The answer to most 'but I updated that dependency' reports; note the collision between tox -r (recreate) and tox r (run).
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| nox | PyPI | You would rather write sessions as Python functions than a config file, especially when the matrix needs real logic |
| hatch | PyPI | You want environment management, build and publish in one tool instead of assembling tox plus a build backend |
| uv | PyPI | You mainly want fast environment creation and dependency resolution for an application, without the per-environment test matrix |