Module 7: Other Advanced Techniques

8. Mini-project: a fixture factory, a table and a mutant

Description

You reached the end of the toolbox with six new techniques in your belt. This mini-project makes them work together in a flow that is, moreover, a mini quality-habit you'll be able to repeat in any project: you build data with a fixture factory (lesson 4), feed it to a parametrized table of cases with pytest.param and readable ids (lessons 2 and 3), run the suite green against the real code, and then —the step almost nobody takes— you mutate a function (lesson 6) to measure whether your own suite is any good. The deliverable is not just "the green suite"; it is the green suite plus the drill's verdict: did your table catch the mutant, or did it let it survive? That last step turns a suite that looks good into a suite that proved to be good.

By the end you are going to have integrated factory + parametrize + mutation into a single executable flow; you are going to have a table of Reservo refund cases fed by a factory; you are going to have measured your suite by mutating the 24-hour guard; and —most important— you are going to have lived the complete cycle of the skilled tester: write the suite, doubt it, and test it with a mutant. It is the capstone that consolidates the module before, in module 8, you use everything to catch a real bug with property-based.

Connection with the module: this is the closing of the toolbox. It integrates the three "pytest and measurement" families of the module —advanced fixtures (factory), sophisticated parametrize (table with id), mutation testing (the drill)— into a flow that needs them all. It leaves out, on purpose, the fuzzing/property-based of lesson 7: not because it doesn't matter, but because its capstone is the entire following module (M8 is "find a real bug with property-based"). Here the focus is the pytest toolbox. The border with M8: there you look for a real and unknown bug with Hypothesis; here you inject a known bug with a mutant to measure your suite. Different intentions, sister techniques.

An analogy: the chef who tastes their own seasoning

A novice cook plates the stew and sends it to the table: it looks good, smells good, done. A skilled chef makes an extra gesture before sending it: they dip the spoon, taste, and sometimes frown and adjust the salt. The difference is not the recipe —both followed the same steps—; it is that the chef doesn't trust that it looks good: they verify that it is good by tasting it themselves. And they go further: a demanding chef sometimes has someone else with a trained palate taste it, someone who will tell them "it's missing something," because they know their own tongue gets used to the dish and stops noticing what's missing.

This mini-project is that gesture of the chef. The green suite is the stew that looks good: plated, appetizing. But "it looks green" is not "it tastes good." The mutant is the spoon you dip in to taste your own seasoning: you inject a known defect into the dish and see whether your palate —the suite— detects it. If it detects it (the suite goes red), your seasoning is tuned. If it doesn't detect it (the suite stays green with the defect inside), your palate got used to it and needs tuning —add the case that was missing. The novice sends the plate because it looks good; the chef tastes it first. With this flow, you stop being the novice who trusts the green and become the chef who verifies their own seasoning.

The assignment

Your task has four steps, and you'll do them over refund_cents, Reservo's star function:

  1. Write a fixture factory make_booking that builds Bookings on demand, with price_cents and hours overridable.
  2. Write a parametrized table of refund cases with pytest.param and readable ids, that covers the three tiers of the policy (full, half, nothing) and the exact edges (48 h, 24 h), feeding each case with the factory.
  3. Run the suite green against the real refund_cents. (Mutation testing rule: green against the real code before mutating anything.)
  4. Mutate refund_cents (the guard >= 24> 24) and run again. Report the verdict: did the suite catch the mutant or did it survive? If it caught it, which case killed it?

Before looking at the solution, take the time to do it yourself. The substantial part is not writing the @given (there is no Hypothesis here) or the factory (you already know): it is designing the table so it catches the mutant —choosing the right edge cases— and then checking with the drill that your design worked. That is the muscle this mini-project trains.

The solution, step by step

Steps 1 and 2: the factory and the table

We join the fixture factory with the parametrized table in a single file. The factory manufactures the booking of each case; the table enumerates the scenarios with readable ids.

# test_mini_project.py — fixture factory + parametrized table of Reservo cases
from datetime import datetime, timedelta

import pytest

from reservo import Booking, refund_cents          # <-- mutate by changing this import

START = datetime(2026, 3, 10, 12, 0)


@pytest.fixture
def make_booking():
    """Fixture factory: builds Reservo Bookings on demand."""
    def _make(price_cents=6000, hours=2):
        return Booking(id="bk-1", room_id="r-focus", member_id="m-1",
                       start=START, end=START + timedelta(hours=hours),
                       status="confirmed", price_cents=price_cents)
    return _make


# Table of cases: (hours_in_advance, paid, expected_refund)
REFUND_CASES = [
    pytest.param(72, 6000, 6000, id="early-full"),
    pytest.param(48, 6000, 6000, id="boundary-48h-full"),
    pytest.param(36, 6000, 3000, id="mid-half"),
    pytest.param(24, 6000, 3000, id="boundary-24h-half"),
    pytest.param(12, 6000, 0,    id="late-nothing"),
    pytest.param(72, 0,    0,    id="early-but-paid-zero"),
]


@pytest.mark.parametrize("hours_before,paid,expected", REFUND_CASES)
def test_refund_table(make_booking, hours_before, paid, expected):
    booking = make_booking(price_cents=paid)
    now = START - timedelta(hours=hours_before)
    assert refund_cents(booking, paid, now) == expected

Note how each technique of the module plays its role:

  • The fixture factory make_booking (lesson 4) manufactures the booking of each case on demand: make_booking(price_cents=paid) builds the booking with the price the case needs. One case uses paid=0, the rest paid=6000; the factory absorbs that variation without the table repeating the complete Booking(...).
  • The table with pytest.param and id (lessons 2 and 3) enumerates six scenarios with names that read themselves: early-full, boundary-48h-full, mid-half, boundary-24h-half, late-nothing, early-but-paid-zero. When a case fails, the id will tell you which one without opening the file.
  • The table design includes on purpose the two exact edges —48 h and 24 h—, besides the three tiers and the paid=0 case. Those edges are the key to step 4: they are right where a >=> mutant gives itself away. Designing the table thinking about the mutants that could exist is the craft this mini-project trains.

Step 3: green against the real code

We run the table against the real refund_cents. It must pass entirely —if not, we can't measure anything with the mutant:

What to expect. Six green:

$ python3 -m pytest test_mini_project.py -v
collecting ... collected 6 items

test_mini_project.py::test_refund_table[early-full] PASSED               [ 16%]
test_mini_project.py::test_refund_table[boundary-48h-full] PASSED        [ 33%]
test_mini_project.py::test_refund_table[mid-half] PASSED                 [ 50%]
test_mini_project.py::test_refund_table[boundary-24h-half] PASSED        [ 66%]
test_mini_project.py::test_refund_table[late-nothing] PASSED             [ 83%]
test_mini_project.py::test_refund_table[early-but-paid-zero] PASSED      [100%]

============================== 6 passed in 0.08s ===============================

Six green against the real code. The baseline is established: the suite passes, so any red we see when mutating will be because of the mutant, not because of an already-broken test. Now, the drill.

Step 4: the mutant and the verdict

We create the mutant of refund_cents, mutating the second guard —>= 24> 24— in a separate file, and we change the suite's import to point at it:

# refund_mutant2.py — MUTANT: the 24h guard uses > instead of >=
def refund_cents(booking, price_paid_cents, now):
    hours_until = (booking.start - now).total_seconds() / 3600
    if hours_until >= 48:
        return price_paid_cents
    if hours_until > 24:                 # MUTANT: was >=
        return price_paid_cents * 50 // 100
    return 0

This mutant differs from the original only when hours_until is exactly 24: with >=, 24 hours gives a half refund (3000); with >, it falls to return 0. We run the table against the mutant:

What to expect. Red: the boundary-24h-half case catches the mutant:

$ python3 -m pytest test_mini_project_mutant.py -v
test_mini_project_mutant.py::test_refund_table[early-full] PASSED         [ 16%]
test_mini_project_mutant.py::test_refund_table[boundary-48h-full] PASSED  [ 33%]
test_mini_project_mutant.py::test_refund_table[mid-half] PASSED           [ 50%]
test_mini_project_mutant.py::test_refund_table[boundary-24h-half] FAILED  [ 66%]
test_mini_project_mutant.py::test_refund_table[late-nothing] PASSED       [ 83%]
test_mini_project_mutant.py::test_refund_table[early-but-paid-zero] PASSED [100%]

=================================== FAILURES ===================================
_____________________ test_refund_table[boundary-24h-half] _____________________

hours_before = 24, paid = 6000, expected = 3000

>       assert refund_cents(booking, paid, now) == expected
E       AssertionError: assert 0 == 3000

=========================== 1 failed, 5 passed in 0.08s ========================

Verdict: the suite caught the mutant. The boundary-24h-half case went red: against the mutant, canceling with exactly 24 hours gives 0 (the mutant fell to return 0) when it should give 3000. The assert 0 == 3000 explodes, and the mutant dies. And the readable id did its job: the report says boundary-24h-half, so you know at a glance which case caught the mutant —the 24-hour edge, exactly the one you designed thinking about this kind of mutant.

The complete drill, then: you wrote the suite (factory + table), tested it green against the real code, injected a known bug, and checked that your suite detects it. You went from "my suite looks good" to "my suite proved it catches this bug." That is the habit you take with you.

The contrast that gives the lesson: what happens if you remove the edge

To really feel the value of step 4, ask yourself the chef's question: what if my table hadn't had the 24-hour case? Mentally remove the boundary-24h-half row from REFUND_CASES and run against the mutant again. The five remaining cases —72 h, 48 h, 36 h, 12 h, and paid=0— none touches the exact 24-hour edge where the mutant differs. Result: 5 passed, total green... against broken code. The mutant would survive.

There is the lesson measured: the difference between a suite that catches the mutant and one that lets it through was a single row —the 24-hour edge case. And you didn't know it because the suite looked good (both versions, with and without the edge, look reasonable and pass green against the real code); you knew it because you tasted your seasoning with the mutant. Without step 4, you would have sent to the table a suite with a gap, convinced it was complete because it was green. Mutation testing is what turns that blind conviction into a datum.

This contrast is also a design advice that transcends the exercise: a table of cases for logic with boundaries must test each boundary exactly. Off-by-one bugs (>= vs >, < vs <=) —among the most common in real code— live precisely at the edges, and only a case at the edge catches them. Designing the table includes asking "what are the boundaries of this logic?" and putting a case at each one. The mutant is the way to verify that you didn't miss any.

Common mistakes

Delivering the green suite without the mutant step. What happens: someone writes the factory and the table, sees six green, and considers the work done. Why it happens: the green feels like the goal. How to detect it: if you didn't mutate anything, you don't know whether your suite catches bugs —you only know it passes. How to fix it: step 4 is not optional in this flow; it is what turns "it looks good" into "I proved it is good." Mutating a guard and seeing whether the suite reacts is the gesture of the chef who tastes before serving. An unaudited suite is an untasted dish.

Designing the table without thinking about the boundaries. What happens: someone puts "round" cases (72 h, 100 h, 10 h) that fall comfortably inside each tier, but none at the exact edge (48 h, 24 h). Why it happens: the values inside each tier feel representative. How to detect it: if your cases avoid the exact values of the policy's boundaries, a >=> mutant will survive. How to fix it: identify each boundary of the logic (here, 48 and 24) and put a case at each one. Edge bugs are only caught with edge cases; the interior of the tiers doesn't see them.

Confusing "mutant caught" with "code fixed." What happens: the suite catches the mutant (red), and someone "fixes" the code so it goes back to green... by editing the mutant. Why it happens: the instinct to put the suite green. How to detect it: remember the mutant is your fake bug; the red is the desired result of the drill, not a problem to fix. How to fix it: when the suite catches the mutant, the work finished well —your suite works. You discard the mutant and go back to the real code (which was always correct). The mutant's red is a success, not a pending task.

Exercises

Exercise 1

Extend the REFUND_CASES table so it also catches the mutant of the first guard (>= 48> 48), not only that of the second. The table already has boundary-48h-full. Without running, explain why that case already catches that mutant, and then add a mental mutant of a third kind (not comparison) that your current table would not catch.

View solution

The table already catches the mutant >= 48> 48 thanks to the case boundary-48h-full (48 h → 6000). Against that mutant, canceling with exactly 48 hours: 48 > 48 is false, falls to the second guard 48 >= 24 (true), and returns 3000 instead of 6000. The case expects 6000, so it fails —assert 3000 == 6000— and the mutant dies. That is why the table, as it is, already covers both comparison mutants of the guards: it has a case at each boundary (48 and 24).

A mutant of a third kind that the table would not catch: mutate the percentage constant of the middle tier, * 50 // 100* 60 // 100 (60% instead of 50%). This mutant is not comparison, it is constant. Against it, the case mid-half (36 h → 3000) would give 6000 * 60 // 100 = 3600, not 3000, so... wait, it would catch it (assert 3600 == 3000 fails). A mutant that would truly survive: mutate the 50 to a value that gives the same result with paid=6000, or mutate a line the table doesn't exercise with an exact assert. For example, if refund_cents had a fourth branch for a case the table doesn't include (say an intermediate tier at 6 hours), a mutant in that branch would survive because no case touches it. The lesson: a table catches the mutants of the branches and boundaries it exercises with exact asserts; mutants in logic the table doesn't cover survive. That is why real mutation testing tests hundreds of mutants: it reveals the branches and values your table forgot.

Exercise 2

Rewrite step 4 using refund_mutant2.py (the mutation >= 24> 24) but removing the boundary-24h-half case from the table. Predict the result without running, and explain what it proves about the table design.

View solution

Without the boundary-24h-half case, the table is left with five cases: 72 h, 48 h, 36 h, 12 h and paid=0 (all at 72 h). None has hours_before=24, which is the only value where >= 24 and > 24 differ. Prediction: against the mutant, 5 passed —total green—, the mutant survives.

Let's verify it with the reasoning: at 72 h and 48 h, both versions enter the first guard (>= 48) and give a full refund, equal. At 36 h, both fall to the second guard (36 > 24 and 36 >= 24 are both true) and give 3000, equal. At 12 h, both fall to return 0, equal. The paid=0 case at 72 h gives 0 in both. In no case is the 24-hour edge touched, so the mutant never gives itself away: five green against broken code.

What it proves: the power to catch a mutant lives in a single well-chosen case —the edge one. The same table, with or without that row, looks reasonable and passes green against the real code; the difference between catching the bug and letting it through is that single row. It is the whole justification of mutation testing: you can't know, looking at a green suite, whether it is missing the edge case; only the drill (mutate and measure) reveals it to you. Designing the table and auditing it with a mutant are two distinct steps, and the second is the one this mini-project teaches you not to skip.

Exercise 3

Apply the complete flow (factory + table + mutant) to a different Reservo function: price_cents. Write a fixture factory make_room, a parametrized table of price cases with readable ids that includes the pro discount, run it green, and design a mutant that your table should catch. Describe the mutant and which case would kill it.

View solution
from datetime import datetime
import pytest
from reservo import Room, Member, price_cents


@pytest.fixture
def make_room():
    def _make(hourly_cents=2500):
        return Room(id="r-focus", name="Focus", capacity=4, hourly_cents=hourly_cents)
    return _make


BASIC = Member(id="m-b", name="Ann", tier="basic")
PRO = Member(id="m-p", name="Ben", tier="pro")

PRICE_CASES = [
    pytest.param(2500, BASIC, 3, 7500, id="basic-3h"),
    pytest.param(2500, PRO,   3, 6000, id="pro-3h-discounted"),   # 7500 * 0.8
    pytest.param(2500, BASIC, 0, 0,    id="zero-hours"),
    pytest.param(5000, PRO,   2, 8000, id="pro-expensive-room"),  # 10000 * 0.8
]


@pytest.mark.parametrize("hourly,member,hours,expected", PRICE_CASES)
def test_price_table(make_room, hourly, member, hours, expected):
    room = make_room(hourly_cents=hourly)
    assert price_cents(room, member, hours) == expected

The four cases pass against the real code (the basic price is hourly * hours; the pro is 80% of that: 7500 → 6000, 10000 → 8000).

A mutant the table catches: mutate the percentage of the pro discount, base * 20 // 100base * 25 // 100 (25% instead of 20%). This mutant changes the pro price. The case pro-3h-discounted kills it: against the mutant, price_cents(FOCUS, PRO, 3) would be 7500 - 7500*25//100 = 7500 - 1875 = 5625, not 6000. The assert 5625 == 6000 fails, the mutant dies. The case catches it because it fixes the exact value of the pro price with an equality assert —exactly what a constant mutant needs to give itself away. If the table only had a loose case (assert price >= 0), the mutant would survive (5625 is still ≥ 0), as you saw in exercise 3 of lesson 6. The moral repeats: exact asserts catch constant mutants; loose asserts let them through.

Summary and next step

In this mini-project you integrated the module's toolbox into a single flow, which is also a repeatable quality habit: fixture factory (lesson 4) to build the data on demand, parametrized table with pytest.param and id (lessons 2 and 3) to enumerate the refund cases readably, green against the real code as the baseline, and mutation (lesson 6) to audit the suite. The drill's verdict was the point: your table caught the mutant >= 24> 24 thanks to the case boundary-24h-half (assert 0 == 3000), and the contrast —removing that row and watching the mutant survive with 5 green— measured that the difference between protecting and being loose was a single row, the edge case.

Above all you take away the chef's gesture: don't trust that the suite looks green, but test it with a known mutant. An unaudited green suite is an untasted dish. And the design advice that accompanies it: a table for logic with boundaries must have a case at each exact boundary (48 h, 24 h), because off-by-one bugs live at the edges and only an edge case catches them —and the mutant is how you verify you didn't miss any.

With this you close module 7 and the whole toolbox: sophisticated parametrize, advanced fixtures, mutation testing and fuzzing vs property-based, each one really run over Reservo. You now have the complete belt of the skilled tester.

Only putting it all together over a real problem remains. Module 8, the guide's capstone, closes the circle: you'll take a Reservo function with a subtle and unknown bug —not a mutant you injected, but a real error hidden at an edge—, you'll write the properties that catch it, let Hypothesis find the minimal example that reproduces it (the shrinking of module 5), and you'll fix it. Everything you learned since module 1 —finding properties, writing strategies, reading the minimal counterexample, and now the toolbox— converges there. Let's move on to the capstone.

Resources