Testing Python packages requires more than testing applications. You must verify your code works across Python versions, handles optional dependencies gracefully, and behaves correctly when installed. This guide covers modern testing practices for distributable packages.
Project Structure for Testing
The recommended structure separates source and tests:
my-package/
├── src/
│ └── my_package/
│ ├── __init__.py
│ └── core.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_core.py
│ └── integration/
│ └── test_integration.py
├── pyproject.toml
├── tox.ini # or noxfile.py
└── README.md
Why the src Layout?
# Without src layout (flat)
# tests/test_core.py might import local source instead of installed package!
from my_package import core # Which my_package?
# With src layout
# Only the installed package is importable during tests
from my_package import core # Imports installed version
pyproject.toml Test Configuration
[project]
name = "my-package"
version = "1.0.0"
[project.optional-dependencies]
test = [
"pytest>=7.0",
"pytest-cov>=4.0",
"pytest-mock>=3.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
pytest: The Testing Foundation
pytest is the standard testing framework for Python packages.
Installation
pip install pytest pytest-cov pytest-mock
Basic Test Structure
# tests/test_core.py
import pytest
from my_package import calculate, DataProcessor
class TestCalculate:
def test_basic_calculation(self):
result = calculate(2, 3)
assert result == 5
def test_negative_numbers(self):
result = calculate(-1, 1)
assert result == 0
def test_raises_on_invalid_input(self):
with pytest.raises(TypeError):
calculate("not", "numbers")
class TestDataProcessor:
@pytest.fixture
def processor(self):
return DataProcessor(config={"mode": "test"})
def test_process_returns_dict(self, processor):
result = processor.process({"key": "value"})
assert isinstance(result, dict)
def test_process_empty_data(self, processor):
result = processor.process({})
assert result == {}
Fixtures in conftest.py
# tests/conftest.py
import pytest
import tempfile
import os
@pytest.fixture
def temp_dir():
"""Provide a temporary directory."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
@pytest.fixture
def sample_data():
"""Provide sample data for tests."""
return {
"users": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
]
}
@pytest.fixture
def mock_env(monkeypatch):
"""Set test environment variables."""
monkeypatch.setenv("API_KEY", "test-key-123")
monkeypatch.setenv("DEBUG", "true")
yield
Parametrized Tests
# tests/test_validation.py
import pytest
from my_package import validate_email
@pytest.mark.parametrize("email,expected", [
("[email protected]", True),
("[email protected]", True),
("invalid", False),
("@example.com", False),
("user@", False),
("", False),
])
def test_validate_email(email, expected):
assert validate_email(email) == expected
@pytest.mark.parametrize("input_val,multiplier,expected", [
(2, 3, 6),
(0, 5, 0),
(-2, 3, -6),
(2.5, 2, 5.0),
])
def test_multiply(input_val, multiplier, expected):
from my_package import multiply
assert multiply(input_val, multiplier) == expected
Testing Optional Dependencies
# tests/test_optional.py
import pytest
# Skip if pandas not installed
pandas = pytest.importorskip("pandas")
def test_dataframe_processing():
from my_package.pandas_support import process_dataframe
df = pandas.DataFrame({"a": [1, 2, 3]})
result = process_dataframe(df)
assert len(result) == 3
# Skip test conditionally
@pytest.mark.skipif(
not pytest.importorskip("numpy", reason="numpy required"),
reason="numpy not installed"
)
def test_numpy_integration():
pass
Markers for Test Categories
# tests/test_integration.py
import pytest
@pytest.mark.slow
def test_large_file_processing():
"""This test takes several seconds."""
pass
@pytest.mark.integration
def test_database_connection():
"""Requires database to be running."""
pass
@pytest.mark.parametrize("size", [100, 1000, 10000])
@pytest.mark.slow
def test_performance_scaling(size):
pass
# pyproject.toml
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests requiring external services",
]
# Run only fast tests
pytest -m "not slow"
# Run only integration tests
pytest -m integration
# Run everything
pytest
tox: Multi-Environment Testing
tox creates isolated environments and runs tests for each Python version.
Installation
pip install tox
Basic tox.ini
# tox.ini
[tox]
envlist = py39, py310, py311, py312, py313
isolated_build = True
[testenv]
deps =
pytest>=7.0
pytest-cov>=4.0
commands =
pytest {posargs:tests}
[testenv:lint]
deps =
black
mypy
ruff
commands =
black --check src tests
ruff check src tests
mypy src
Running tox
# Run all environments
tox
# Run specific environment
tox -e py311
# Run with arguments passed to pytest
tox -- -v --tb=long
# Recreate environments (after dependency changes)
tox --recreate
# Run in parallel
tox -p auto
Advanced tox Configuration
# tox.ini
[tox]
envlist = py{39,310,311,312,313}, lint, coverage
isolated_build = True
skip_missing_interpreters = True
[testenv]
deps =
pytest>=7.0
pytest-cov>=4.0
pytest-mock>=3.0
setenv =
PYTHONPATH = {toxinidir}/src
MY_CONFIG = test
commands =
pytest --cov=my_package --cov-report=term-missing {posargs:tests}
[testenv:lint]
basepython = python3.11
deps =
black>=23.0
ruff>=0.1
mypy>=1.0
commands =
black --check src tests
ruff check src tests
mypy src --ignore-missing-imports
[testenv:docs]
basepython = python3.11
deps =
sphinx>=7.0
sphinx-rtd-theme>=1.0
commands =
sphinx-build -W -b html docs docs/_build
[testenv:coverage]
basepython = python3.11
deps =
pytest>=7.0
pytest-cov>=4.0
commands =
pytest --cov=my_package --cov-report=html --cov-report=xml tests
Testing with Optional Dependencies
# tox.ini
[testenv]
extras = test
commands = pytest tests
[testenv:full]
extras =
test
pandas
numpy
commands = pytest tests --run-optional
[testenv:minimal]
deps = pytest
commands = pytest tests -m "not optional"
nox: Python-Powered Testing
nox uses Python for configuration, offering more flexibility than tox.
Installation
pip install nox
Basic noxfile.py
# noxfile.py
import nox
@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"])
def tests(session):
"""Run the test suite."""
session.install(".[test]")
session.run("pytest", "tests", *session.posargs)
@nox.session(python="3.11")
def lint(session):
"""Run linters."""
session.install("black", "ruff", "mypy")
session.run("black", "--check", "src", "tests")
session.run("ruff", "check", "src", "tests")
session.run("mypy", "src")
@nox.session(python="3.11")
def coverage(session):
"""Run tests with coverage."""
session.install(".[test]")
session.run(
"pytest",
"--cov=my_package",
"--cov-report=html",
"--cov-report=term-missing",
"tests",
)
Running nox
# Run all sessions
nox
# Run specific session
nox -s tests
# Run specific Python version
nox -s tests-3.11
# List available sessions
nox -l
# Reuse existing environments (faster)
nox -r
# Pass arguments to pytest
nox -s tests -- -v --tb=long
Advanced noxfile
# noxfile.py
import nox
nox.options.sessions = ["tests", "lint"] # Default sessions
PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13"]
@nox.session(python=PYTHON_VERSIONS)
def tests(session):
"""Run the test suite."""
session.install("-e", ".[test]")
args = session.posargs or ["tests"]
session.run("pytest", *args)
@nox.session(python=PYTHON_VERSIONS)
@nox.parametrize("django", ["4.0", "4.1", "4.2", "5.0"])
def tests_django(session, django):
"""Test against multiple Django versions."""
session.install(f"django=={django}")
session.install("-e", ".[test]")
session.run("pytest", "tests/django_compat/")
@nox.session(python="3.11")
def docs(session):
"""Build documentation."""
session.install("-e", ".[docs]")
session.run("sphinx-build", "-W", "-b", "html", "docs", "docs/_build")
@nox.session(python="3.11")
def build(session):
"""Build the package."""
session.install("build", "twine")
session.run("python", "-m", "build")
session.run("twine", "check", "dist/*")
@nox.session(python="3.11")
def safety(session):
"""Check for security vulnerabilities."""
session.install("safety")
session.install("-e", ".")
session.run("safety", "check")
Code Coverage
Setting Up pytest-cov
pip install pytest-cov
# Run with coverage
pytest --cov=my_package tests/
# Generate HTML report
pytest --cov=my_package --cov-report=html tests/
# Fail if coverage below threshold
pytest --cov=my_package --cov-fail-under=80 tests/
Coverage Configuration
# pyproject.toml
[tool.coverage.run]
source = ["src/my_package"]
branch = true
omit = [
"*/tests/*",
"*/__init__.py",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
]
fail_under = 80
show_missing = true
[tool.coverage.html]
directory = "htmlcov"
Coverage in CI
# .github/workflows/test.yml
- name: Run tests with coverage
run: pytest --cov=my_package --cov-report=xml tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
CI/CD Integration
GitHub Actions
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[test]"
- name: Run tests
run: pytest --cov=my_package --cov-report=xml tests/
- name: Upload coverage
uses: codecov/codecov-action@v3
if: matrix.python-version == '3.11'
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install linters
run: pip install black ruff mypy
- name: Run black
run: black --check src tests
- name: Run ruff
run: ruff check src tests
- name: Run mypy
run: mypy src
Using tox in CI
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install tox
run: pip install tox
- name: Run tox
run: tox -p auto
Testing Best Practices
1. Test the Installed Package
# Install in editable mode for development
pip install -e ".[test]"
# Tests import installed package, not local source
pytest tests/
2. Use Fixtures for Setup
# conftest.py
@pytest.fixture(scope="session")
def database():
"""Create test database once per session."""
db = create_test_database()
yield db
db.cleanup()
@pytest.fixture
def client(database):
"""Create fresh client for each test."""
return Client(database)
3. Isolate Tests
# Each test should be independent
def test_create_user(client):
user = client.create_user("alice")
assert user.name == "alice"
# Don't rely on this user existing in other tests
def test_another_operation(client):
# Fresh client, no users from previous test
users = client.list_users()
assert len(users) == 0
4. Test Edge Cases
@pytest.mark.parametrize("input_val", [
"", # Empty string
None, # None
[], # Empty list
{}, # Empty dict
" ", # Whitespace
"\n", # Newline
"a" * 10000, # Very long string
])
def test_handles_edge_cases(input_val):
# Should not raise
result = process_input(input_val)
assert result is not None
5. Test Error Conditions
def test_raises_on_invalid_config():
with pytest.raises(ValueError, match="invalid configuration"):
MyClass(config={"invalid": True})
def test_raises_with_message():
with pytest.raises(TypeError) as exc_info:
process(None)
assert "expected string" in str(exc_info.value)
Command Reference
pytest
| Command | Description |
|---|---|
pytest | Run all tests |
pytest tests/test_core.py | Run specific file |
pytest -k "test_calculate" | Run tests matching pattern |
pytest -m slow | Run tests with marker |
pytest -x | Stop on first failure |
pytest --lf | Run last failed tests |
pytest -v | Verbose output |
pytest --cov=pkg | With coverage |
tox
| Command | Description |
|---|---|
tox | Run all environments |
tox -e py311 | Run specific environment |
tox -p auto | Run in parallel |
tox --recreate | Recreate environments |
tox -l | List environments |
nox
| Command | Description |
|---|---|
nox | Run default sessions |
nox -s tests | Run specific session |
nox -r | Reuse environments |
nox -l | List sessions |
Next Steps
- Learn to Publish to PyPI with tested packages
- See our pyproject.toml Guide for test configuration
- Explore the Python Packaging Complete Guide for the full ecosystem
For more Python development guides, explore our complete Python packaging series.