mrkeyoor.com_
Fri 07 Aug 22:56 UTC
PyPITestingupdated 07 Aug 2026

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.

Verdict

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.

API stability4/5The 4.x line has been additive for 297 releases and legacy ini key names are still accepted alongside the newer env_list and set_env spellings; the deduction is for the 3 to 4 rewrite, which changed enough config syntax that old files need real edits
Docs4/5tox.wiki carries a tutorial, a full configuration reference and a detailed per-release changelog; the awkward part is that ini and TOML forms are documented side by side and most third-party answers you find still describe tox 3
Maintenance5/54.58.0 shipped 2026-07-21, the repo was pushed 2026-08-03, and the tracker sits at a single open item on a 3.9k-star project; recent releases fix real edge cases like pylock.toml installs and package environment state corruption
Ecosystem5/58.6M weekly downloads, a plugin API with maintained plugins for uv and GitHub Actions, and a place in the PyPA-adjacent toolchain alongside pytest and virtualenv

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
Skip it if

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 run

Everything 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-spinner

Output 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 py312

tox 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 .venv

This 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 py312

The answer to most 'but I updated that dependency' reports; note the collision between tox -r (recreate) and tox r (run).

Alternatives

PackageRegistryPick it when
noxPyPIYou would rather write sessions as Python functions than a config file, especially when the matrix needs real logic
hatchPyPIYou want environment management, build and publish in one tool instead of assembling tox plus a build backend
uvPyPIYou mainly want fast environment creation and dependency resolution for an application, without the per-environment test matrix