Course outline · 0% complete

0/29 lessons0%

Course overview →

Stubs by Hand: Dependency Injection

lesson 5-2 · ~11 min · 14/29

Pass the dependency in

Swapping a real dependency for a double is easiest with dependency injection, which is a formal name for something you already know from Advanced Python. Functions are values, so the dependency can be a parameter.

def temperature_label(fetch_temp, city):
    t = fetch_temp(city)
    ...

In production, callers hand in the real network function. In tests, you hand in a stub that returns a canned number instantly. The unit cannot tell the difference, so the test becomes fast and deterministic.

The alternative, where temperature_label reaches out and calls the network function itself, is what makes code untestable. There is no seam to substitute at, so the only ways in are patching the module at runtime or running the real call, and both are worse than the parameter.

This is why testable code and well-designed code are usually the same code. A function that receives its dependencies is also easier to reuse, easier to reason about in isolation, and honest about what it needs, since the signature lists it.

A stub clock for the weather

stub_fetch answers from a hard-coded dictionary, so the three tests run instantly with zero network and always get the same temperatures.

def temperature_label(fetch_temp, city):
    t = fetch_temp(city)
    if t >= 30:
        return f"{city}: hot ({t}°C)"
    if t >= 15:
        return f"{city}: mild ({t}°C)"
    return f"{city}: cold ({t}°C)"

def stub_fetch(city):
    return {"Cairo": 35, "Lima": 18, "Oslo": 2}[city]

assert temperature_label(stub_fetch, "Cairo") == "Cairo: hot (35°C)"
assert temperature_label(stub_fetch, "Lima") == "Lima: mild (18°C)"
assert temperature_label(stub_fetch, "Oslo") == "Oslo: cold (2°C)"
print("3 tests passed without any network")

Output

3 tests passed without any network

The stub keeps the real function's shape, taking a city and returning a number, which is what lets the unit run unchanged. A double that does not match the signature it replaces fails in the test rather than telling you anything about your logic.

The three cities were chosen to hit the three branches, meaning 35 for hot, 18 for mild, and 2 for cold. The dictionary is the whole test plan, just as the cases list was in lesson 3-3, and adding a fourth branch means adding one entry and one assert.

Nothing here is being tested about the network. That is deliberate: temperature_label's job is the labeling rule, and whether the real API is reachable is a separate concern that belongs to the integration tests in unit 6.

Why not test against the real weather API

Two reasons, and the deeper one is nondeterminism. The real temperature changes constantly, so there is no fixed value to assert, and separately the network can fail even when your logic is perfect.

Assert against live weather and the test fails every time the weather changes, which makes it useless as a signal. You could weaken the assertion to something like a plausible range, and then it stops checking the labeling rule that is actually under test.

Add network flakiness on top and a red run stops meaning your code is broken, which is the trust problem from lesson 5-1. Two independent defects, one about determinism and one about reliability, both point at the same fix.

There is a cost worth naming. With a stub, nothing verifies that the real API returns Celsius, or that its field is named temp, or that it is reachable at all. That gap is real and it is covered by a small number of integration tests rather than by making every unit test hit the network.

Stubbing the clock

greeting reads the hour from an injected now_hour function, so a stub clock is just a function returning a fixed hour. Five injections cover the three branches plus the boundary probes at exactly 12 and 18 that lesson 3-2 calls for.

def greeting(now_hour):
    h = now_hour()
    if h < 12:
        return "Good morning"
    if h < 18:
        return "Good afternoon"
    return "Good evening"

assert greeting(lambda: 9) == "Good morning"
assert greeting(lambda: 14) == "Good afternoon"
assert greeting(lambda: 21) == "Good evening"
assert greeting(lambda: 12) == "Good afternoon"
assert greeting(lambda: 18) == "Good evening"
print("5 tests passed without waiting for the clock")

Output

5 tests passed without waiting for the clock

lambda: 9 is the smallest possible stub, being a function of no arguments returning a fixed value, which matches how greeting calls now_hour().

The boundary probes are the reason this suite has five asserts rather than three. h < 12 is false when h is exactly 12, so noon is afternoon, and 18 is evening for the same reason. A mistaken h <= 12 would pass the 9, 14, and 21 asserts and fail only on the boundary.

The alternative is a test that reads the real clock, which can only assert whatever branch happens to match the current hour. Such a test passes all morning, fails at noon, and is impossible to debug at 4pm, which is the nondeterminism problem in its purest form.

One more injection, on a database lookup

shipping_label asks an injected get_country function where an order ships. Two lambda stubs, one returning "US" and one returning "PE", cover both branches.

def shipping_label(get_country, order_id):
    country = get_country(order_id)
    if country == "US":
        return f"{order_id}: domestic"
    return f"{order_id}: international"

assert shipping_label(lambda oid: "US", 7) == "7: domestic"
assert shipping_label(lambda oid: "PE", 8) == "8: international"
print("2 tests passed without a database")

Output

2 tests passed without a database

The stub has to accept the order_id argument even though it ignores it, hence lambda oid: "US" rather than lambda: "US". Matching the call signature is the one hard requirement on any double, and a mismatch here raises a TypeError that looks like a bug in your code.

In production this function receives a real database lookup, and the unit cannot tell the difference. That is the whole trick of injection, and it is why the same function serves both a live query and a one-line lambda.

Two asserts cover the two branches, which is the minimum honest coverage for an if and else pair. A single assert on the "US" path would pass against a function that ignored country entirely and always returned domestic.

Worth noticing how little the test knows about databases. There is no connection, no fixture rows, and no cleanup, so it runs in microseconds and cannot fail for any reason other than the labeling logic being wrong.