pytest, test-driven development, property-based tests, ruff and mypy, and a GitHub Actions pipeline that checks every change before it can reach a customer’s premium.
Companion notebook: lesson-02-testing-ci.ipynb — worked examples and self-checking exercises.
Lesson 1’s monolith ran for a year and overcharged 324 vehicles by $130,000, against the manual its own insurer filed.
Tests are executable specifications. “Drivers aged exactly 25 pay the 25–29 factor” written as a test is checked on every change, forever. Written in a memo, it is checked once.
Actuarial standards on models ask for evidence the model does what it is documented to do. A test suite, its coverage report, and a CI log are that evidence.
Small, fast, specific.
# tests/test_engine.py
def test_base_risk_pays_the_base_rates(engine, base_vehicle):
assert engine.quote(base_vehicle).premiums == {
"LIAB": 938.09, "COLL": 576.85, "COMP": 158.35}
def test_hand_calculated_quote(engine, base_vehicle):
"""Sports, driver 22, 16,000 miles, $45,000,
$1,000 COLL deductible, $250 COMP."""
v = base_vehicle.model_copy(update={...})
q = engine.quote(v)
assert q.premiums["LIAB"] == round(938.09 * 1.40 * 1.60 * 1.20, 2)
assert q.total == 4881.88
# tests/conftest.py: fixtures, shared by every test file
@pytest.fixture(scope="session")
def manual() -> RatingManual:
return load_manual()
@pytest.fixture
def base_vehicle() -> Vehicle:
"""Every factor 1.00: Sedan, 40, 12,000 miles,
$30,000, $500 deductibles."""
return Vehicle(driver_age=40, ...)
test_*.py, functions test_*: pytest finds themassert shows both sides of the comparison@pytest.mark.parametrize(("age", "factor"), [
(16.0, 1.60), (24.99, 1.60), (25.0, 1.15),
(29.99, 1.15), (30.0, 1.00), (64.99, 1.00),
(65.0, 1.10), (74.99, 1.10), (75.0, 1.35)])
def test_age_factor(age, factor):
assert age_factor(age) == factor
@pytest.mark.parametrize(("miles", "factor"), [
(7_499, 0.85), (7_500, 1.00),
(14_999, 1.00), (15_000, 1.20)])
def test_mileage_factor(miles, factor):
assert mileage_factor(miles) == factor
The same 13 cases, run against the logic copied out of the monolith:
..F.........F [100%]
__________ test_age_factor[25.0-1.15] __________
> assert age_factor(age) == factor
E assert 1.6 == 1.15
E + where 1.6 = age_factor(25.0)
________ test_mileage_factor[15000-1.2] ________
> assert mileage_factor(miles) == factor
E assert 1.0 == 1.2
2 failed, 11 passed in 0.12s
Bugs live at the edges. Test the last value in a band and the first value of the next, for every band.
Writing the test first forces the question what exactly should happen? before any code exists: what is earned premium for an exposure of −0.25 years?
# red: the rule, as a test
@pytest.mark.parametrize("exposure", [-0.25, 1.5, float("nan")])
def test_impossible_exposure_is_refused(engine, base_vehicle,
exposure):
with pytest.raises(ValueError, match="exposure"):
engine.quote(base_vehicle).earned(exposure)
# green: the code
def earned(self, exposure: float) -> float:
if not 0 <= exposure <= 1: # also False for NaN
raise ValueError(f"exposure must be between 0 "
f"and 1 year, got {exposure}")
return money(self.total * exposure)
| Case | What went wrong without a test | Test |
|---|---|---|
| Band boundaries | 25.0 and 15,000 priced in the wrong band | parametrized boundaries |
| Negative or > 1 exposure | negative earned premium, silently | pytest.raises(ValueError) |
| NaN exposure | NaN premium flows into totals and makes them NaN | NaN is refused too |
| Empty book | the monolith’s summary divides by zero | summarize(empty) returns a row of 0 and NaN |
| Half cents | round(836.645, 2) == 836.64 | money(836.645) == 836.65 |
| Unknown algorithm name | crash mid-run, or worse, a silent default | factory raises, listing valid names |
| Deductible without cover | a COLL deductible on a LIAB-only policy | ValidationError, “if and only if” |
from hypothesis import given, strategies as st
vehicles = st.builds(Vehicle,
driver_age=st.floats(16, 110),
vehicle_style=st.sampled_from(STYLES),
vehicle_value=st.floats(500, 500_000),
annual_mileage=st.floats(1, 150_000), ...)
@given(vehicles, st.floats(1, 50_000))
def test_more_miles_never_cost_less(v, extra):
further = v.model_copy(update={
"annual_mileage": min(v.annual_mileage + extra, 150_000)})
assert ENGINE.quote(further).total >= ENGINE.quote(v).total
@given(vehicles)
def test_higher_deductible_never_costs_more(v): ...
@given(vehicles)
def test_liability_ignores_vehicle_value(v): ...
In CI, add @settings(derandomize=True): the same examples every run, so a test can’t fail on Tuesday with no code change.
Name Stmts Miss Branch BrPart Cover
---------------------------------------------------------
src/ratingkit/algorithms.py 35 0 0 0 100%
src/ratingkit/book.py 33 0 6 0 100%
src/ratingkit/cli.py 27 2 6 3 85%
src/ratingkit/engine.py 21 0 4 0 100%
src/ratingkit/factory.py 18 0 2 0 100%
src/ratingkit/manual.py 29 0 8 0 100%
src/ratingkit/money.py 3 0 0 0 100%
src/ratingkit/schemas.py 26 0 8 0 100%
---------------------------------------------------------
TOTAL 196 2 34 3 98%
Required test coverage of 90.0% reached.
57 passed in 0.88s
Coverage says which lines ran during the tests, not whether the tests would notice if those lines were wrong.
Mutation testing answers that: plant one bug at a time (“25 counts as young”, “round halves to even”, “forget the Sports factor”) and see if a test fails. A mutant that survives marks a missing test.
In the notebook, a single happy-path test catches 0 of 6 mutants; a boundary-focused suite catches all 6.
Let machines check what machines can.
price_book.py:6:1: I001 Import block is un-sorted
price_book.py:8:8: F401 `math` imported but unused
price_book.py:27:5: SIM115 Use a context manager
for opening files
price_book.py:100:7: SIM115 Use a context manager
for opening files
price_book.py:108:7: UP031 Use format specifiers
instead of percent format
... (2 more UP031)
Found 7 errors.
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
ruff check --fix fixes the safe ones; ruff format rewrites layout (29 lines of diff here)price_book.py:63: error: Incompatible types in assignment
(expression has type "float", variable has type "int")
price_book.py:64: error: Incompatible types in assignment
... (4 more)
price_book.py:94: error: No overload variant of
"__setitem__" of "list" matches argument types
"int", "float"
Found 7 errors in 1 file
The monolith sets coll = 0 (an int) and later assigns a float premium; its running totals start as ints too. Harmless here, but it is the kind of inconsistency that becomes a bug.
$ uv run mypy src tests
Success: no issues found in 16 source files
mypy did not find either boundary bug: age <= 25 is perfectly typed. Types check shapes; tests check behavior. You need both.
Every change, checked, on a clean machine.
.github/workflows/ci.ymlname: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10
with:
python-version: ${{ matrix.python-version }}
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy src tests
- run: uv run pytest --cov --cov-report=term-missing
main and every pull request--locked fails if uv.lock is out of date: CI tests exactly what you lockedactionlint, a linter for workflow filesBranch protection makes the check required: a pull request can’t merge while it is red.
$ docker run --rm -v "$PWD":/src:ro \
ghcr.io/astral-sh/uv:python3.12-bookworm-slim sh -c '
cp -r /src /w && cd /w && uv sync --locked &&
uv run ruff check . && uv run ruff format --check . &&
uv run mypy src tests && uv run pytest -q --cov'
All checks passed!
17 files already formatted
Success: no issues found in 16 source files
TOTAL 211 2 34 3 98%
Required test coverage of 90.0% reached.
57 passed in 3.67s
29 seconds from an empty container, including installing every dependency. That is roughly what each CI run costs, and why the suite must stay fast.
tests/ and fixtures (manual, engine, base vehicle) to your Lesson 1 package| Monolith boundary tests | 2 failed, 11 passed |
| ruff on the monolith | 7 (8 with E501) |
| mypy --strict on the monolith | 7 errors |
| Reference suite | 57 tests, 98% |
| Notebook mutants caught | 6 of 6 |
A Monte Carlo ALM model for an annuity book: reproducible random numbers, profiling, vectorization, numba, and economic capital from a million scenarios.