Lesson 10: Testing

Research code that is not tested is likely wrong in ways you have not found yet. This is not hypothetical. Every physicist who has written numerical code has eventually found a bug they thought was physics. Tests do not eliminate bugs, but they catch the common ones early and give you confidence when refactoring.


pytest basics

pytest is the standard Python testing framework.

pip install pytest

A test file is any file matching test_*.py or *_test.py. A test function starts with test_.

# tests/test_flux.py
from mypackage.physics import compute_flux

def test_flux_at_unit_energy():
    # flux(1 GeV, gamma=2) = 1^(-2) = 1
    assert compute_flux(energy=1.0, gamma=2.0) == 1.0

def test_flux_scales_with_spectral_index():
    # Flux at 10x the energy should drop by 10^gamma
    flux_low = compute_flux(energy=1e3, gamma=2.0)
    flux_high = compute_flux(energy=1e4, gamma=2.0)
    assert abs(flux_low / flux_high - 100.0) < 1e-10

Run all tests:

pytest

Run with verbose output:

pytest -v

Floating-point comparisons

Never use == to compare floats. Use pytest.approx, which allows a small relative tolerance:

from pytest import approx

def test_flux_value():
    result = compute_flux(1e5, 2.37)
    assert result == approx(1e5 ** -2.37, rel=1e-6)

Prometheus uses this correctly throughout tests/test_detector_unit.py. Using bare == on floats produces intermittent failures that are hard to debug.


Testing error cases

Test that your code raises the right exception for invalid input:

import pytest
from mypackage.physics import compute_flux

def test_negative_gamma_raises():
    """Negative spectral index is unphysical."""
    with pytest.raises(ValueError, match="gamma must be positive"):
        compute_flux(1e5, gamma=-1.0)

The match argument verifies the error message, making tests self-documenting and preventing silent mis-matches where the right exception type is raised for the wrong reason.


Fixtures

A fixture is setup code shared between multiple tests. Define one with @pytest.fixture:

import pytest
import numpy as np
from mypackage.detector import Detector

@pytest.fixture
def simple_detector():
    return Detector(n_modules=10, spacing=50.0, seed=42)

def test_detector_has_correct_module_count(simple_detector):
    assert len(simple_detector.modules) == 10

def test_detector_volume_is_positive(simple_detector):
    assert simple_detector.volume > 0

Put shared fixtures in tests/conftest.py. Pytest finds them automatically. Prometheus uses conftest.py to define detectors and particle configurations reused across its 16+ test modules.


What to test

Test type Example
Known analytical result compute_flux(1.0, 2.0) == 1.0
Boundary conditions Zero energy, empty list, single event
Error paths Wrong input raises the right exception with a clear message
Physics invariants Total flux integrates to the expected value; direction vector norm is 1
Regression test Fix a bug, add a test that would have caught it

What not to test: implementation details that change during refactoring, third-party library behaviour, trivial one-line getters.


Slow tests

Mark end-to-end tests that take minutes as slow so they can be excluded from fast development runs:

import pytest

@pytest.mark.slow
def test_full_neutrino_simulation():
    ...

Configure the marker in pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["slow: full end-to-end simulation tests, excluded from CI"]

Run excluding slow tests:

pytest -m "not slow"

Prometheus uses exactly this pattern. The e2e tests in tests/test_e2e.py are marked slow and excluded from the CI workflow. They are run locally before opening a pull request.


Docstring examples as tests

Short usage examples in docstrings can be tested automatically:

def compute_flux(energy: float, gamma: float) -> float:
    """
    Examples
    --------
    >>> compute_flux(1.0, 2.0)
    1.0
    """
    return energy ** (-gamma)

Run with:

pytest --doctest-modules mypackage/

This is a lightweight way to keep examples in docstrings accurate without maintaining a separate test file.


Lesson 11 covers packaging: making your project installable with pip install so collaborators can use it without copying files.