Course outline · 0% complete

0/29 lessons0%

Course overview →

The Refactoring Safety Net

lesson 1-2 · ~10 min · 2/29

Why tests exist: fearless change

Two more definitions, then we practice.

Refactoring means changing how code is written without changing what it does, for example rewriting a loop as a comprehension (you met comprehensions in Advanced Python). A regression is when a change accidentally breaks something that used to work.

Here is the problem tests solve. Without tests, every refactor is a leap of faith: you think the new version behaves the same, but the only way to know is to try every input by hand. With tests, you refactor, run the suite, and get an instant verdict. The tests are a safety net stretched under every change you will ever make.

you change codetests rungreen: ship itred: fix firstthe loop runs in seconds, so you change code without fear
The safety-net loop: every change passes through the tests before you trust it.

Three tests pinning down a loop

count_vowels is written with a plain loop, and three asserts fix its behavior in place before anything gets rewritten.

def count_vowels(text):
    count = 0
    for ch in text:
        if ch in "aeiou":
            count += 1
    return count

assert count_vowels("testing") == 2
assert count_vowels("") == 0
assert count_vowels("aeiou") == 5
print("3 tests passed")

Output

3 tests passed

The three cases cover a normal string, the empty string, and a string of nothing but vowels. That last one is a useful shape to include, because a test where the answer equals the input length catches an off-by-one that a count of 2 might not.

The empty string is the case people forget, and it is doing real work here. It proves the function returns 0 rather than crashing or returning None, which pins down behavior that the loop provides only by accident.

These asserts describe what count_vowels does and say nothing about how. That distinction is what makes them a safety net for the rewrite in the next block, since a test tied to the loop's internals would have to be rewritten too and would prove nothing.

Rewriting the body, keeping the tests

The loop collapses into a single line with sum and a generator expression, like the ones from Advanced Python. The three asserts are untouched.

def count_vowels(text):
    return sum(1 for ch in text if ch in "aeiou")

assert count_vowels("testing") == 2
assert count_vowels("") == 0
assert count_vowels("aeiou") == 5
print("3 tests passed")

Output

3 tests passed

The pattern is sum(1 for ch in text if ...), where each matching character contributes a 1 to the total. The condition is copied over unchanged as ch in "aeiou", which is the part that must not drift during a refactor.

Green tests are the whole point of this exercise. The body is now four lines shorter and structurally different, and the evidence that it still behaves identically took no manual checking at all.

Note what the tests do not tell you. They confirm the behavior matches on those three inputs, not on every possible input, so a refactor that broke uppercase handling would slip through because no assert covers it. A safety net is only as wide as the cases in it, which is why unit 3 is about finding the cases you forgot.

The same move on a different function

total_price gets the same treatment, with a hand-written accumulation loop replaced by the built-in sum.

def total_price(prices):
    return sum(prices)

assert total_price([1.5, 2.5]) == 4.0
assert total_price([]) == 0
assert total_price([10]) == 10
print("3 tests passed")

Output

3 tests passed

sum(prices) does exactly what the loop did, adding every element starting from 0, so the whole body becomes one line. Reaching for a built-in instead of a loop is one of the most common refactors in Python, and it is safe here precisely because the tests were written first.

The empty-list assert still passes because sum([]) is 0, which matches the loop's behavior. That agreement is luck worth noticing rather than a guarantee, since a refactor to prices[0] + sum(prices[1:]) would also look reasonable and would crash on the empty list. The test is what tells the two apart.

One honest caveat about the first assert. Comparing floats with == works here because 1.5 and 2.5 are exactly representable, but a test like total_price([0.1, 0.2]) == 0.3 would fail for reasons that have nothing to do with the function. Float comparisons in tests normally use a tolerance.

When a refactor turns a test red

If a test goes red after a refactor, the most likely meaning is that the refactor changed the function's behavior, which is exactly what a refactor must never do.

A refactor changes how, never what. So when a behavior-checking test goes red, the new code does something different from the old code, and the net just caught you before anyone else noticed.

The tempting responses are the wrong ones. Deleting the test, or editing its expected value to match the new output, throws away the warning you built the test for and converts a caught bug into a shipped one.

The right move is to read the failure, decide which version is correct, and fix the code. Occasionally the answer is that the test itself encoded a bug as expected behavior, in which case changing it is legitimate, but that is a deliberate decision made with a reason, not a reflex to get back to green.