Module 5: Shared Utilities And The Harness
7. When a helper is worth it
Overview
By the end of this lesson you will have a clear criterion for the decision that opens and closes every piece of the library: is this helper worth it, or do I not write it?. The previous lessons gave you the tools (helpers, assertions, harness) and the principle to use them without hiding (lesson 6). This one answers the question that comes before writing any of them: should it exist?. Because a helper is not free. Each one is a new thing to learn and maintain: a name someone has to recognize, a function someone has to open when they doubt what it does, a piece someone has to update when the domain changes. Duplication has an obvious cost —repeated lines—, but the abstraction also has its own —indirection, one more layer between the test and what it verifies—, and sometimes that cost is greater than that of the repetition it removes. A helper used a single time almost always costs more than it saves. Knowing when a helper earns its place —and when it does not— is what keeps the library at its right size: neither scattered copy-paste, nor a zoo of utilities no one remembers.
This matters because the utilities library has two ways of failing, and they are opposite. One is falling short: not factoring what really repeats, and dragging copy-paste that gets misaligned. That is what lessons 1 to 6 attacked. The other is overdoing it: factoring by reflex, creating a helper every time two lines look alike, until the suite has forty utilities —half used once— and reading a test requires jumping through five functions to understand what it does. A team that learned to make helpers and did not learn when not to make them ends up with a library that gets in the way more than the copy-paste it replaced. This lesson is the brake: the criteria to say "yes, this is worth it" and, harder, "no, leave this one repeated". The good architect of a framework is not the one who abstracts most; it is the one who abstracts just enough.
Connection to the module: this lesson is the judgment that governs the whole library. It picks up the thread of lessons 2 (what tool for what repetition) and 6 (factor without hiding), and turns it into a yes/no decision applicable to any candidate. It closes the module's conceptual arc before the mini-project (lesson 8), where you are going to apply these criteria to decide what goes into Reservo's library and what does not. The boundary appears as one of the "no" criteria: if what you want to factor is setup, it is a fixture (module 2); if it is manufacturing varied data, it is a builder (module 7); neither of the two is a helper of this library.
The shortcut that only pays off where people walk
Think of it with a public garden and its paths. When the garden is new, people walk on the grass looking for the shortest route between two points. Over time, where many pass a desire path forms: the grass wears out, a trail is marked. There —and only there— does it make sense for the gardener to lay a paved path: where people already showed they walk. Paving a desire path is pure benefit: it formalizes a real route, makes it comfortable, avoids the mud. It is the abstraction that earns its place: you factor what already repeats, where the pattern has been demonstrated.
But imagine an enthusiastic gardener who, instead of waiting for the desire paths, paves paths just in case: crosses the garden with routes where they believe someone might want to walk. The result is a disaster: the garden fills with trails no one uses, that have to be maintained (swept, repaired), that get in the way and spoil the landscape, and that on top of that do not coincide with the routes people do want —because they guessed them—. Paving before the trail forms costs more than it is worth. With helpers it is the same. A helper that factors a real and demonstrated repetition is a paved desire path: it is worth it. A helper created just in case, before seeing the pattern, guessing that "maybe this will repeat", is a path to nowhere: it costs to maintain it and it probably does not even fit what you actually end up needing. The gardener's rule is the helper's rule: pave where people already walk, not where you think they might walk.
The criteria for yes
A helper earns its place when it meets the three criteria. One is not enough; the three together.
First: the repetition is real and stable —the rule of three. The pattern already appeared, you are not anticipating it. A common heuristic: wait for the third time. The first time, you write the code. The second, you notice it looks alike but leave it —two do not make a pattern—. The third, it is already a desire path: factor. Waiting for the third avoids paving imaginary routes, and along the way it gives you three real examples to design the correct helper (with two you could guess the shape wrong). "Stable" adds a nuance: if the three appearances still change often —the domain is in flux—, factoring now forces you to redo the helper with each change; wait for the pattern to settle.
Second: it names a domain concept —it raises the abstraction. The good helper replaces how with what. hours_before(monday_9am, 72) replaces monday_9am - timedelta(hours=72): the reader no longer decodes a subtraction, they read a concept ("72 hours before"). assert_refund(booking, now, 6000) replaces the refund computation and its comparison with the intent ("the refund must be 6000"). If your helper does not raise the abstraction —if def add_hours(a, b): return a + b only renames a sum without adding domain meaning—, it is not worth it: the name costs and does not clarify. The helper should make the test read closer to the business, not just shorter.
Third: it stays visible and honest. The criterion of lesson 6: the helper factors the setup, the computation or the verification without hiding what the test asserts. An assertion that receives the expected and only compares: honest. A god-helper that does the act and buries the expected value: dishonest, not worth it however much it repeats. The repetition justifies factoring; the honesty criterion decides how, and if it cannot be factored without hiding, it is better to leave the repetition.
Reservo's three star candidates pass the three criteria. hours_before: it repeats in every refund test (real), it names "N hours before a start" (domain), and it is called in plain sight with the number the test chooses (visible). assert_refund: it repeats (real), it names "the expected refund" with its message (domain), and it receives the expected and only compares (honest). The harness reservo_env: it repeats in every integration test and even outside pytest (real), it names "a clean Reservo environment" (domain), and its lifecycle is explicit (honest). The three are desire paths.
The criteria for no
Equally important: when not to write the helper, even if the hand itches.
One-off. If the code appears once, there is no repetition to remove —there is an anticipation to resist—. Factoring something of a single use adds a layer of indirection (a function to open, a name to understand) in exchange for zero duplication eliminated: cost without benefit. The rule of three in reverse: fewer than two uses, it is not a helper. Leave the code in the test, in plain sight.
It hides the intent. If the only way to factor that repetition is a helper that leaves the test mute —that mixes act with verification, or buries the expected value—, do not do it. Here readable repetition (DAMP) wins: you prefer three tests that say assert booking.price_cents == 6000 to a god-helper that collapses them and mutes them. Honesty rules over DRY.
It is premature. Writing the helper before seeing the pattern —"surely this will repeat"— is paving just in case. Even if you are right that it will repeat, with a single example you design the helper blind and you almost always get the shape wrong; when the second and third use arrive, you will have to redo it. Wait. The pattern will tell you the correct shape when it exists.
It is another tool's job. If what you want to factor is setup, it is not a helper: it is a fixture (module 2) —it goes invisible, injected—. If it is manufacturing varied domain data (bookings with different prices, members of different tiers, on demand), it is not a helper of this library: it is a builder (module 7 and the doubles guide). Putting setup or data manufacturing in a helper of helpers.py is using the wrong tool —lesson 2 treated it for the setup; here it extends to the builders—. The helper of this library is for named computation and verification; the rest has its own layer.
Worked example: the paved trail versus the path to nowhere
Let us see it with two concrete candidates, one that passes and one that does not. Let us start with the one that passes: hours_before. Before, three tests with the subtraction copied:
def test_full_refund_72h_ahead(paid_booking, monday_9am):
now = monday_9am - timedelta(hours=72)
assert refund_cents(paid_booking, paid_booking.price_cents, now) == 6000
# ... and the same with 36 and 12
The subtraction appears three times (real and stable), decoding it costs (a named helper would raise the abstraction), and the number is chosen by the test (it can be called in plain sight). The three criteria for yes. It is factored, and the three tests end up using the library. Let us run Reservo's complete suite leaning on its helpers and assertions:
python3 -m pytest
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo_m5
collected 7 items
tests/integration/test_booking_flow.py .. [ 28%]
tests/unit/test_pricing.py .. [ 57%]
tests/unit/test_refund.py ... [100%]
============================== 7 passed in 0.01s ===============================
Seven green, leaning on hours_before, assert_refund and the harness env: the helpers that earned their place. Now the candidate that does not pass: a build_weird_scenario(env) that someone wants to write for a single test that needs a calendar with three overlapping bookings in an odd pattern. It appears once. Factoring it would give this:
# tests/helpers.py — candidate that is NOT worth it
def build_weird_scenario(env):
# assembles a calendar with 3 bookings in a specific pattern
... # 8 lines of setup very specific to ONE test
# the only test that uses it
def test_the_weird_overlap_case(env):
build_weird_scenario(env) # <-- what does it assemble? you have to open the helper
assert ...
Not worth it, by two "no" criteria. One-off: it is used in one test, so it eliminates no duplication —it adds a function to open to understand the test—. And it is another tool's job: "assembling a calendar with specific bookings" is setup/data manufacturing —a fixture local to that test, or a builder (module 7)—, not a computation helper. The correct form is to leave that setup in the test (in plain sight, it is single-use) or, if it grows, in a fixture local to that folder. Writing build_weird_scenario in helpers.py would be paving a path a single pedestrian uses, with material that is not even its own.
Common mistakes
Factoring on the second appearance (of a rushed rule of three). What happens: someone sees two similar lines and immediately makes a helper. Why it happens: two already feels like "repetition". How to detect it: if you have helpers used exactly twice that you also had to retouch when a third different case arrived, you factored before knowing the shape. How to fix it: wait for the third appearance. With three real examples, you see the true shape of the pattern —what varies, what stays— and design the correct helper at once. With two, you guess, and often wrong. (The rule of three is a heuristic, not a law: if two appearances are identical and the pattern is obvious and stable, factoring on the second is fine; the point is not to factor blind.)
Creating helpers just in case (of premature paving). What happens: someone, on starting the suite, writes a battery of helpers "that I will surely need". Why it happens: it feels productive and forward-thinking. How to detect it: if you have helpers no test uses yet, or used once, you paved before the trail. How to fix it: do not write a helper until the pattern exists in the code —until you see the desire path—. A helper with no real users is dead weight that also probably got the shape wrong. Let the real repetition dictate what to factor and when.
Putting in a helper what is a fixture or builder (of wrong tool, again). What happens: someone factors "assemble the service" or "manufacture a booking with such price" into helpers.py. Why it happens: "it is code that repeats, it goes in a helper" —without distinguishing what kind of code—. How to detect it: if your helper assembles the scenario (setup) or builds varied domain data (manufacturing), it is in the wrong layer. How to fix it: the setup goes in a fixture (invisible, injected — module 2); the manufacturing of varied data goes in a builder (module 7 and the doubles guide). The helper of this library is for named computation (hours_before) and verification (assert_refund). Each repetition to its layer; not everything that repeats is a helper.
Exercises
Exercise 1 — Apply the three criteria. For each Reservo candidate, say whether you would write the helper (passes the three "yes" criteria) or not (fails one of the "no"), and which criterion decides. (a) assert_refund, the refund check used in three tests. (b) double_it(n): return n * 2, used in a test to compute a "double" price. (c) at(y, m, d, h) that builds a datetime, used in eight tests to write readable dates. (d) setup_full_suite() that assembles service, calendar and seeds five bookings, written before having tests that use it.
See solution
- (a) Yes. Real repetition (three tests), names a domain concept (the expected refund, with its message), and is honest (receives the expected, only compares). The three "yes" criteria. It is the canonical case.
- (b) No.
double_itdoes not raise the abstraction: it renames a multiplication without adding domain meaning —n * 2is already as clear asdouble_it(n), and the name does not say why it doubles—. It fails the second criterion. Leave the* 2in plain sight. - (c) Yes (nuanced). It repeats in eight tests (real and stable), and
at(2026, 3, 2, 9)reads better thandatetime(2026, 3, 2, 9)when it appears everywhere —it raises the domain readability a bit (dates)—. It passes. (It is a legitimate computation/readability helper; the nuance is that its gain is modest, so the repetition threshold to justify it is higher —with eight uses, it justifies it—.) - (d) No. Two "no" criteria: premature (written before having users) and another tool's job (assembling service + seeding bookings is setup/manufacturing → fixture or builder, not a helper). Double failure. Do not write it.
Exercise 2 — Decide with the rule of three. You are writing the second test that needs "the instant N hours after the start". The computation (start + timedelta(hours=n)) appeared twice. (a) Do you factor it now or wait? (b) What information would a third use give you that two do not? (c) Does your answer change if the two appearances are identical and the domain is stable?
See solution
- (a) In general, you wait for the third appearance. With two uses, the pattern is not yet demonstrated and you could be anticipating. You leave the
start + timedelta(hours=n)in plain sight in the two tests for now. - (b) A third use confirms the pattern is real (not a coincidence of two) and shows you its true shape: seeing three examples you see what varies (the hours, the
start) and what stays, so you design the correct signature (duration_hours(start, n)) at once, instead of guessing it with two. - (c) Yes, it can change. The rule of three is a heuristic against blind factoring, not a law. If the two appearances are identical, the pattern is obvious (adding hours to a start) and the domain stable, factoring on the second is reasonable —you already know the shape, you are not guessing—. The point of the rule is not "count to three always", but "do not factor without knowing the shape of the pattern". When the shape is clear with two, you can get ahead.
Exercise 3 — Prune an inflated library. A team has a tests/helpers.py with these functions. For each one, say whether you keep it or prune it, and why. (a) hours_before(start, n) — used in 6 tests. (b) make_service() — assembles the BookingService, used in 12 tests. (c) assert_refund(...) — used in 4 tests. (d) format_error_for_slack(msg) — used in 0 tests, "in case someday we report to Slack". (e) build_pro_booking(price) — builds a pro Booking with the given price, used in 5 tests.
See solution
- (a) Keep.
hours_beforeis a named computation helper, real repetition (6 uses), raises the abstraction. It earns its place. Inhelpers.py, fine. - (b) Prune from
helpers.py→ move to fixture.make_service()assembles the scenario: it is setup, not computation. Its home is a fixture (env/booking_service), injected invisible, not a helper the test calls. It repeats a lot (12), so factoring it is fine —but in the correct layer—. Wrong tool, not wrong function. - (c) Keep.
assert_refundis an honest assertion, real repetition (4), domain message. Although, for precision, its canonical home istests/asserts.py, nothelpers.py—but keep it, yes—. - (d) Prune.
format_error_for_slackhas zero uses —premature paving, "in case someday"—. It is dead weight to understand and maintain with no benefit. Delete it; if someday they report to Slack, they will write it then, with the real case in plain sight. - (e) Prune from
helpers.py→ move to builder.build_pro_bookingmanufactures domain data (a proBookingwith a variable price). That is a builder (module 7 and the doubles guide), not a computation helper. It is used 5 times, so factoring the manufacturing is fine —but in the builders layer, not inhelpers.py—.
The pruned library: helpers.py keeps hours_before; assert_refund goes to asserts.py; the setup (make_service) goes to fixtures; the manufacturing (build_pro_booking) goes to builders; and format_error_for_slack is deleted. Each repetition to its layer, and out with the premature paving.
Summary and next step
In this lesson you got the criterion to decide whether a helper should exist: do not write it by reflex, because each one costs —a name to learn, a function to open, a piece to maintain—, so it has to earn its place. You saw the garden analogy: pave the desire path (where people already walk, the demonstrated repetition), not the just-in-case path (where you think they might walk, the anticipation). The three criteria for yes —real and stable repetition (the rule of three), names a domain concept (raises the abstraction), stays visible and honest (lesson 6)—, and the criteria for no —one-off, hides the intent, is premature, or is another tool's job (fixture for the setup, builder for the data)—.
And you saw it applied: hours_before, assert_refund and the harness pass the three criteria and hold Reservo's suite green; a single-use build_weird_scenario, or a premature setup_full_suite, do not pass —and their place, if any, is a fixture or a builder, not helpers.py—. The library at its right size: neither copy-paste that gets misaligned, nor a zoo of utilities that gets in the way.
Before closing the module you should be able to: decide with the three criteria whether a candidate deserves to be a helper; use the rule of three without turning it into dogma; and prune an inflated library, sending each repetition to its layer (helper, fixture or builder) and deleting the premature paving.
What comes next is putting it all together. In the mini-project (lesson 8) you are going to build, with your hands, Reservo's complete utilities library: tests/helpers.py with hours_before, tests/asserts.py with assert_refund (domain message and __tracebackhide__), and tests/harness.py with reservo_env, plus a suite that uses them and runs green. And the star deliverable: the demonstration of __tracebackhide__ —the same failure pointing at the test line with the shield and at the guts of the helper without it, with the real output of both— applying every criterion you learned in the module.
Resources
- pytest — Good Integration Practices — organization practices that support keeping the utilities in their layer (helpers, fixtures, builders) and not inflating a single module; context for the "no" criteria.
- pytest — How to use fixtures — the home of the setup, one of the "other tools" a candidate misplaced in
helpers.pyshould move to. - pytest — Writing well integrated assertion helpers — the pattern of the assertion that does earn its place (real repetition, domain, honest), versus the candidates that do not pass the criteria.