Testing Fundamentals

Advanced · 16 min read · ▶ live playground · ✦ checkpoint

A bug does not need permission to come back. You fix a coupon bug today, then a tiny change next week quietly breaks it again. A test is a small check that compares what your code actually does with what you expect, so you do not have to trust memory, hope, or a long manual ritual.

Why tests make you faster

Testing can feel like extra work when the program is tiny. That feeling fades the first time you change one line and wonder, "Did I break anything else?"

Think of tests as a pre-flight checklist. The checklist does not fly the plane for you. It catches the boring, repeatable mistakes before they become expensive. In code, that means you can change a function, run the checks, and get evidence in seconds.

A regression is a bug that returns after it was already fixed. Tests are especially good at catching regressions because they preserve the exact example that used to fail.

Here is a small price rule. If the order reaches 100 dollars, the customer gets free shipping:

def shipping_label(order_total):
    if order_total >= 100:
        return "free shipping"
    return "standard shipping"
 
 
print(shipping_label(120))
print(shipping_label(75))

free shipping
standard shipping

That output looks fine. But one example is not enough. What about exactly 100? A boundary case is input exactly at an edge where behavior changes, and bugs like to hide there.

Manual testing

Manual testing means you run the program yourself and inspect the result with your eyes. It is useful when you are exploring a new idea, checking a screen, or making sure the whole experience feels right.

Manual checks are also fragile. You forget one case. You skim an output. You run the easy case and miss the edge.

You can make a manual check clearer by printing both the expected result and the actual result:

def shipping_label(order_total):
    if order_total >= 100:
        return "free shipping"
    return "standard shipping"
 
 
examples = [
    (120, "free shipping"),
    (100, "free shipping"),
    (75, "standard shipping"),
]
 
for total, expected in examples:
    actual = shipping_label(total)
    print(f"total={total} expected={expected} actual={actual}")

total=120 expected=free shipping actual=free shipping
total=100 expected=free shipping actual=free shipping
total=75 expected=standard shipping actual=standard shipping

This is already better than clicking around randomly. It names the cases. It keeps the boundary case. But you still have to read every line and notice a mismatch.

Automated testing

Automated testing means code checks your code and reports whether the expected behavior still holds. Instead of reading every line yourself, you write claims that Python can verify.

The simplest tool is assert, which you met as defensive programming in Errors and Exceptions. An assertion is a claim that must be true at that point in the program; if it is false, Python raises AssertionError.

def shipping_label(order_total):
    if order_total >= 100:
        return "free shipping"
    return "standard shipping"
 
 
assert shipping_label(120) == "free shipping"
assert shipping_label(100) == "free shipping"
assert shipping_label(75) == "standard shipping"
 
print("shipping checks passed")

shipping checks passed

No news is good news with assert: if every claim is true, the program continues. If a future edit changes >= to >, the second assertion catches the exact case you protected.

That is the speed gain. You are not writing tests because you love ceremony. You are turning old examples into a reusable safety check.

Try it - turn examples into checks

Run this code once. Then change the rule for a gold badge from >= 90 to > 90 and run it again. The printed manual lines help you see the story; the assertions stop the program the moment the rule breaks.

python — playgroundlive
⌘/Ctrl + Enter to run

Three shapes of tests

Not every test looks at the same amount of the program. Use these names to talk clearly about the size of the check.

A unit test checks one small piece of code, usually one function or method, away from the rest of the system. Unit tests are like checking one recipe card before you plan the whole dinner.

def coupon_discount(subtotal, coupon_code):
    if coupon_code == "SAVE10":
        return subtotal * 0.10
    return 0
 
 
assert coupon_discount(80, "SAVE10") == 8
assert coupon_discount(80, "NOPE") == 0
 
print("unit checks passed")

unit checks passed

An integration test checks whether several pieces work together. The point is not one recipe card anymore; it is whether the kitchen stations pass the plate correctly.

def coupon_discount(subtotal, coupon_code):
    if coupon_code == "SAVE10":
        return subtotal * 0.10
    return 0
 
 
def order_total(prices, coupon_code):
    subtotal = sum(prices)
    discount = coupon_discount(subtotal, coupon_code)
    return subtotal - discount
 
 
assert order_total([20, 30, 50], "SAVE10") == 90
assert order_total([20, 30, 50], "NOPE") == 100
 
print("integration checks passed")

integration checks passed

An end-to-end test checks a whole user-shaped flow from start to finish. In a real app, that might mean opening a page, filling a form, and confirming that the saved result appears. In this browser Python lesson, we can still model the idea with a small function that takes user-like input and returns a final message.

def coupon_discount(subtotal, coupon_code):
    if coupon_code == "SAVE10":
        return subtotal * 0.10
    return 0
 
 
def order_total(prices, coupon_code):
    subtotal = sum(prices)
    discount = coupon_discount(subtotal, coupon_code)
    return subtotal - discount
 
 
def checkout_message(player, prices, coupon_code):
    total = order_total(prices, coupon_code)
    return f"{player}, your total is ${total:.2f}"
 
 
assert checkout_message("Mina", [20, 30, 50], "SAVE10") == "Mina, your total is $90.00"
 
print("end-to-end check passed")

end-to-end check passed

The categories overlap in real projects, and teams argue about names. Keep the practical difference: small piece, pieces together, full flow.

What to test first

Start with behavior that would embarrass you if it broke:

  • Boundaries, like exactly 100 dollars or exactly 90 points.
  • Past bugs, because regressions are common.
  • Money, permissions, saved data, and anything users depend on.
  • Small pure functions, because they are easy to check without setup.

Do not test every line. Test promises. If a function promises "a score of 90 earns gold," write that check. If the implementation changes later but the promise still holds, the test should still pass.

Checkpoint

Answer all three to mark this lesson complete

You now have the reason for testing and the basic map of test sizes. Next, you turn these raw assert checks into real Python test cases with the standard library tool teams can run as a suite.

+50 XP on completion