RizTech Academy logo
RizTech Academy
Testing and Code QualityLesson 1 of 520 min

Why tests save you time, with a worked example

Module 5 ended by extracting describe_guess from the guessing game and checking it with two assert statements, saying that was impossible before and pointing here. This module is that, done properly.

The honest case

Tests are not about proving code correct. You cannot, and anyone claiming otherwise is selling something. Tests are about changing code without fear.

Here is the situation they prevent. You have a working program. A requirement changes, you edit a function, and now you must check that nothing else broke. Without tests that means running the program by hand, trying inputs you remember, and hoping the ones you forgot were not important. So you do less of it than you should, and eventually you stop changing things that ought to change because the risk feels too high.

That is the real cost of no tests: not bugs, but code nobody dares touch.

A worked example

def apply_discount(amount: float, percent: float) -> float:
    """Reduce an amount by a percentage."""
    return round(amount * (1 - percent / 100), 2)

Checking it by hand means running something, typing values, reading output. Three inputs and you are bored. Ten and you stop.

assert apply_discount(1000, 10) == 900.0
assert apply_discount(1000, 0) == 1000.0
assert apply_discount(1000, 100) == 0.0
assert apply_discount(99.99, 50) == 50.0

Four lines, instant, repeatable, and they run every time from now on. assert raises AssertionError when false and does nothing when true — so silence means everything passed.

Now change the function:

def apply_discount(amount: float, percent: float) -> float:
    if not 0 <= percent <= 100:
        raise ValueError(f"Percent must be 0-100, got {percent}")
    return round(amount * (1 - percent / 100), 2)

Re-run the asserts. Still silent, so the change broke nothing. That took one second and no thought, and it is the entire value proposition.

Module 6 warned that assert is removed under -O and must not validate user input. Tests are the exception — they are exactly what it is for.

What tests actually buy you

Confidence to change things. The main one.

Faster feedback. A test runs in milliseconds. Starting your program, navigating to the right screen and typing input takes a minute, and you will do it far less often.

They catch what you forgot. Not what you were thinking about — you tested that by hand. The test you wrote three weeks ago catches the thing today's change broke.

They document behaviour. test_discount_of_100_percent_gives_zero states an intention no comment would have recorded.

They force better design. Code that is hard to test is usually badly structured. A function reading a file, calculating, and printing is awkward to test; splitting it makes it easy — and better anyway. This is why module 5's refactor produced something testable almost by accident.

The cost, stated fairly

Tests are code. They take time to write, they need maintaining, and a badly written test suite is a liability — slow, flaky, failing for reasons nobody understands until people start ignoring it.

They are not free, and the answer is not to test everything. It is to test the things where being wrong matters, which the strategy lesson covers.

What makes something testable

def calculate_and_print_total(filename):
    with open(filename) as f:
        rows = f.readlines()
    total = sum(float(r.split(",")[1]) for r in rows[1:])
    print(f"Total: {total}")

To test that you need a real file on disk, and you have to capture printed output to check the answer. Both are possible and both are irritating enough that you will not bother.

def parse_rows(text: str) -> list[float]:
    return [float(r.split(",")[1]) for r in text.splitlines()[1:]]


def total(values: list[float]) -> float:
    return sum(values)
assert parse_rows("name,amount\na,10\nb,20") == [10.0, 20.0]
assert total([10.0, 20.0]) == 30.0

No file, no printing, no setup. The difference is that the second version separates deciding from doing — the split module 5 recommended, now with a concrete payoff.

Pure functions are easy to test. Same input, same output, no reaching outside. Push logic into pure functions and keep the file reading and printing in a thin layer around them, and testing stops being a chore.

When not to bother

Being realistic:

  • A ten-line script you will run once
  • Code you are exploring with and will throw away
  • Trivial code with no logic — a __repr__, a plain getter
  • Something genuinely about to be deleted

The judgement is whether the code will be changed later by someone who is not you today. That includes you in three months, who will not remember any of it.

Check your work

The four assertions.

assert apply_discount(1000, 10) == 900.0
assert apply_discount(1000, 0) == 1000.0
assert apply_discount(1000, 100) == 0.0
assert apply_discount(99.99, 50) == 50.0

Silence means all four passed. assert raises AssertionError when false and does nothing when true.

Breaking it — + instead of - — gives AssertionError with no detail, which is exactly the limitation pytest removes in the next lesson.

Adding validation and re-running takes one second and no thought. That is the entire value proposition: not proving the code correct, but changing it without fear.

The negative percent. assert apply_discount(1000, -10) == ... — there is no right answer until you decide one. Raising ValueError is the better choice, and writing the test first is what forces the decision.

Splitting the file function.

def parse_rows(text: str) -> list[float]:
    return [float(r.split(",")[1]) for r in text.splitlines()[1:]]

def total(values: list[float]) -> float:
    return sum(values)
assert parse_rows("name,amount\na,10\nb,20") == [10.0, 20.0]
assert total([10.0, 20.0]) == 30.0

No file, no captured output, no setup. The difference is separating deciding from doing — and the second version is better structured anyway, which is why hard-to-test code is usually a design signal.

Reversing < and > in describe_guess fails immediately. Without the tests you would only find it by playing the game and noticing the hints were backwards.

Practice

  1. Write apply_discount and check it with four assert statements. Run them.
  2. Break the function deliberately — use + instead of -. Confirm an assert fails and read the AssertionError.
  3. Add validation and re-run the asserts to confirm nothing broke.
  4. Write an assert for a case you have not handled — a negative percent — and watch it fail. Then make it pass.
  5. Take calculate_and_print_total and split it into pure functions. Write asserts for each.
  6. Go back to your guessing game. Write asserts for describe_guess. Then deliberately reverse < and > and confirm the tests catch it.
  7. Pick a function you wrote in an earlier module and list what would need to exist before you could test it. If the answer is "a file" or "user input", it wants splitting.

Next: pytest, which turns these asserts into a proper test suite.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship