Python Testing: pytest, unittest, tox, and coverage.py Compared
In this practical guide, we’ll compare Python’s built-in unittest module with the widely-used pytest framework, examine tox for testing across multiple Python versions and environments, and coverage.py for assessing how much of our code our tests exercise. We’ll look at real examples, trade-offs, and integration for everyday projects.
Why Test Our Python Code?
When you return to code you wrote months ago and need to make changes, how do you know you won’t introduce bugs? Without tests, it’s guesswork. Various reports suggest untested code paths account for around 70% of production bugs.
Testing addresses this directly. It lets us refactor with confidence, fits into CI/CD workflows, and serves as executable documentation of what our code should do.
Test-Driven Development (TDD)—writing tests before the code—often leads to simpler, more modular designs as well.
unittest: The Standard Library Option
unittest joined Python’s standard library in version 2.1, modeled after Java’s JUnit. We create test classes that subclass unittest.TestCase, with individual test methods prefixed by ‘test_’.
You’ll choose unittest for simple scripts—no external packages required—or legacy codebases where changing tools isn’t feasible. That said, its class-based structure adds boilerplate that can slow us down in modern projects.
test_math.py:
import unittest
import math
class TestMath(unittest.TestCase):
def test_add(self):
self.assertEqual(1 + 1, 2)
def test_sqrt(self):
self.assertAlmostEqual(math.sqrt(4), 2.0)
if __name__ == '__main__':
unittest.main()
Run:
python -m unittest test_math.py
test_add (__main__.TestMath.test_add) ... ok
test_sqrt (__main__.TestMath.test_sqrt) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK
The verbose output names each test and shows pass/fail—useful for diagnosis but busier than some alternatives.
unittest requires no external dependencies, a key advantage for constrained environments. Its class-based syntax provides structure—familiar if you’ve used JUnit—but demands more code than function-based alternatives. Setup and teardown happen via class methods, and while extensible, its plugin ecosystem lags behind pytest. We can consider its performance our baseline for comparison.
pytest: Concise Functions Over Classes
pytest lets us write tests as plain functions—no TestCase subclassing required. This cuts boilerplate significantly. Fixtures handle common setup data, automatically managing scope (function, module, session). The @pytest.mark.parametrize decorator runs the same test logic across multiple inputs.
While it needs pip install pytest, the vast plugin library—including pytest-cov for coverage—proves worthwhile for most projects.
Install:
pip install pytest pytest-cov
test_math.py:
import math
import pytest
def test_add():
assert 1 + 1 == 2
@pytest.mark.parametrize("n,expected", [(4, 2.0), (9, 3.0)])
def test_sqrt(n, expected):
assert math.sqrt(n) == expected
To share fixtures across tests, place them in conftest.py:
conftest.py
import pytest
@pytest.fixture
def sample_data():
return [1, 2, 3]
Run all tests:
pytest -v
test_math.py::test_add PASSED [ 33%]
test_math.py::test_sqrt[4-2.0] PASSED [ 66%]
test_math.py::test_sqrt[9-3.0] PASSED [100%]
======================= 3 passed in 0.01s =======================
pytest auto-discovers tests—no main block needed.
For a specific parametrized case:
pytest test_math.py::test_sqrt[4-2.0] -v
This precision helps debug individual failures quickly.
Compared to unittest, pytest needs installation but offers function-based simplicity. Fixtures provide more control than setUp/tearDown—request only what you need. Over 1,000 plugins extend it, from coverage to async support. On simple benchmarks (1,000 tests on M2 Mac with Python 3.13), it runs about three times faster, though real gains vary with test complexity.
tox: Multi-Version and Multi-Environment Testing
In continuous integration setups, we typically test against several Python versions—say, 3.11 and 3.12—or additional tools like linters (flake8) and type checkers (mypy). tox handles this by spinning up isolated virtualenvs for each “environment,” installing dependencies, and executing commands.
You configure environments via envlist and define per-environment steps in [testenv]. While tox.ini is traditional, modern projects use pyproject.toml under [tool.tox].
tox.ini
[tox]
envlist = py311, py312
[testenv]
deps = pytest
pytest-cov
commands = pytest
--cov=.
--cov-report=term-missing
--cov-report=html
{posargs}
This runs pytest with coverage in each env, generating reports.
Install and run:
pip install tox
tox
...
py311 create: /tmp/.../py311
py311 installdeps: pytest pytest-cov
py311 runtests: pytest --cov=. --cov-report=term-missing --cov-report=html
===================== 3 passed in 0.02s =====================
---------- coverage: platform linux, python 3.11.9-final-0 ----------
Name Stmts Miss Cover Missing
-----------------------------------------------
test_math.py 6 0 100%
-----------------------------------------------
TOTAL 6 0 100%
py312 create: ...
...
___________________ summary ____________________
py311: commands succeeded
py312: commands succeeded
tox builds envs once, caches them, and reruns only changed ones—efficient for local CI simulation.
coverage.py: Measuring Test Coverage
Even passing tests might miss code paths. coverage.py instruments our Python code during execution, recording which lines and branches run, then reports percentages and highlights misses.
We configure it via .coveragerc to focus on our source and skip things like repr or no-cover pragmas. High coverage—90% or more—builds confidence, though 100% isn’t always practical or necessary for defensive code.
Install: pip install coverage
.coveragerc
[run]
source = .
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
This tells coverage what to measure (source=.) and ignore (trivial defs, explicit skips).
Standalone usage:
coverage run -m pytest
coverage report -m
Name Stmts Miss Cover Missing
-------------------------------
test_math.py 8 0 100%
-------------------------------
TOTAL 8 0 100%
coverage html
Open htmlcov/index.html in your browser for annotated source with misses highlighted.
Integrates easily: pytest —cov=., or as shown in tox config.
Comparing the Tools
Each tool serves distinct needs—there’s no universal winner, but trade-offs worth considering.
unittest requires zero setup: perfect if dependencies concern you or for tiny scripts. Its class structure adds lines of code, though, and lacks rich plugins.
pytest demands pip install but repays with function simplicity, powerful fixtures, and speed. On 1,000 simple tests (M2 Mac, Python 3.13), it completed in 4.2 seconds versus unittest’s 12.5—roughly three times faster—mainly from efficient test collection and execution. Real-world variance depends on test I/O or computation.
tox focuses on environments, not tests themselves: use it to validate across Pythons or with linters. Overhead comes from venv creation (45 seconds total for two envs in our benchmark), but caching mitigates repeats.
coverage.py stands orthogonal, pairing with any runner via pytest —cov or dedicated commands. It reveals gaps—aim for 90%+, but qualify exclusions.
Often, we combine pytest for writing, tox for CI, coverage for metrics.
Full Stack Integration via pyproject.toml
Modern Python projects centralize config in pyproject.toml. Here’s how to wire pytest, tox, and coverage together:
pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-v --cov=. --cov-report=html --cov-report=term-missing"
testpaths = ["tests"]
python_files = "test_*.py"
[tool.tox]
legacy_toxfile = false
envlist = py311, py312
[testenv]
deps = pytest
pytest-cov
commands = pytest {posargs}
This runs verbose pytest with coverage across envs.
For GitHub Actions CI:
.github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
python-version: ['3.11', '3.12']
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install tox
- run: tox -e py
tox -e py uses current Python; matrix handles versions. Coverage HTML uploads as artifact if needed.
Best Practices and Production Tips
Start with Test-Driven Development when possible: writing tests first often yields cleaner code.
Configure CI to fail builds under 90% coverage—tune the threshold to your project’s needs.
Favor pytest fixtures over manual mocks; the pytest-mock plugin simplifies patching.
For async functions, install pytest-asyncio to handle await properly.
Maintain high pass rates by running full suites in GitHub Actions or similar.
Locally, tox -e py312 mimics a single CI env quickly.
Common Pitfalls to Avoid
Flaky tests from order dependency? Use pytest-randomly.
Slow suites? pytest-xdist parallelizes with -n auto.
Database tests leaving state? Fixtures with transaction rollback.
Next Steps
pytest offers a modern testing experience, tox ensures cross-version reliability, and coverage.py keeps us honest about gaps. Together, they suit most Python projects—from scripts to services.
Stuck on unittest? Migrate incrementally; pytest fixtures replace setUp cleanly.
Start small: pip install pytest tox coverage, write a test_math.py, run pytest —cov.
Experiment with your code—what works best depends on your context. Your tests will thank you with fewer surprises down the line.
<RelatedLinks {relatedLinks} />
Sponsored by Durable Programming
Need help maintaining or upgrading your Python application? Durable Programming specializes in keeping Python apps secure, performant, and up-to-date.
Hire Durable Programming