Home/Blog/Testing Python Packages: pytest, tox, and nox for Multi-Version Testing
Software Engineering

Testing Python Packages: pytest, tox, and nox for Multi-Version Testing

Master Python package testing with pytest, tox, and nox. Learn multi-version testing, CI/CD integration, and testing strategies for distributable packages.

By InventiveHQ Team

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

CommandDescription
pytestRun all tests
pytest tests/test_core.pyRun specific file
pytest -k "test_calculate"Run tests matching pattern
pytest -m slowRun tests with marker
pytest -xStop on first failure
pytest --lfRun last failed tests
pytest -vVerbose output
pytest --cov=pkgWith coverage

tox

CommandDescription
toxRun all environments
tox -e py311Run specific environment
tox -p autoRun in parallel
tox --recreateRecreate environments
tox -lList environments

nox

CommandDescription
noxRun default sessions
nox -s testsRun specific session
nox -rReuse environments
nox -lList sessions

Next Steps

For more Python development guides, explore our complete Python packaging series.

Frequently Asked Questions

Find answers to common questions

Packages must work across multiple Python versions, may have optional dependencies, and need to test both installed and editable modes. Users will install your package in diverse environments, so you need to test those scenarios.

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.