Module 6 · Advanced Data Handling ⏱ 19 min

Writing Basic Tests with assert

By the end of this lesson you will be able to:
  • State what an assert statement checks and what it raises when the check fails
  • Group related checks into named test functions and run them
  • Write arrange-act-assert tests that cover the happy path and the edge cases

Why we test

Change one line deep in your program and three distant callers can quietly break, and nothing tells you, until a user does. Re-reading the code by eye does not scale, and "it worked when I wrote it" is a promise the code cannot keep on its own. A test is a machine-checked claim that a function still behaves the way you intend: it runs your expectations automatically, every time, and shouts the moment one is violated.

Tests are not bureaucracy bolted on at the end. Written alongside the code, they are the fastest way to know, within seconds, whether a change was safe. This lesson uses plain Python's assert, with no frameworks, so you can see exactly what a test is.

flowchart LR
  W["write a function"] --> T["write asserts"]
  T --> R["run them"]
  R -->|"all pass"| OK["ship / move on"]
  R -->|"one fails"| F["fix the code"]
  F --> R
  style OK fill:#15803d,color:#fff
  style F fill:#b45309,color:#fff
The test loop: write a function, write asserts, run them; a failure sends you back to fix the code.

The assert statement

An assert is a single line that says this must be true. Write assert condition, and Python checks it: if the condition is truthy nothing happens and the test passed; if it is falsy Python raises AssertionError and stops. Add a comma and a message, assert n > 0, "n must be positive", and that message appears in the failure, which is far more useful than a bare error.

assert square(4) == 16
assert square(4) == 16, "square of 4 should be 16"

Read an assert as a tiny specification: "I claim square(4) equals 16, and I want the program to argue if it does not." A file full of passing asserts is a file full of claims the machine has confirmed.

Define a function, then assert its behaviour on a few inputs. Passing asserts print nothing until you do.
def square(n):
    return n * n

assert square(4) == 16
assert square(0) == 0
assert square(-3) == 9
print("square: all tests passed")

Bundling asserts into test functions

Scattered asserts are hard to run and hard to read. The smallest organising step is to group the asserts for one function under a named test_* function, then call it. The name documents what is being checked; grouping keeps related checks together; and when one fails, the function name in the traceback tells you which area broke.

def test_add():
    assert add(2, 3) == 5
    assert add(0, 0) == 0

test_add()

This is the seed of every test suite in the wild. Frameworks like pytest automate the discovery and running, but underneath they are calling functions exactly like this one and collecting their asserts.

Group a function's checks into a test_* function and call it. One failure names the culprit.
def add(a, b):
    return a + b

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

test_add()
print("add: all tests passed")
flowchart TD
  A["assert f(x) == expected"] --> B{"f(x) == expected?"}
  B -->|"yes"| P["silent: the test passes"]
  B -->|"no"| E["AssertionError names the line and message"]
  E --> DBG["you know exactly what broke"]
  style P fill:#15803d,color:#fff
  style E fill:#b91c1c,color:#fff
A passing assert is silent; a failing one raises AssertionError that points at the exact line and its message.

Reading a failure

When an assert fails, the traceback points straight at the guilty line and prints your message. That is the whole point of testing made concrete: instead of debugging from a wrong final output, you start from a precise statement of which expectation broke and why. A failing test is not a nuisance; it is a free, specific bug report.

Cultivate the habit of reading the message before changing anything. Was the input unusual? Is the expected value itself wrong? Sometimes the test is the thing that is incorrect, and fixing the code to match a bad expectation would only embed the bug. Trust the test only once you trust its claim.

What makes a test worth writing

A strong test follows a small rhythm: arrange the input, act by calling the function, assert about the result. Test one behaviour per assert so a failure points at a single broken expectation. Most importantly, cover the edges, the empty list, the single element, the negative number, the boundary value, because those are where bugs hide. A test that only checks the happy path lulls you into false confidence.

Keep tests independent, too. Each should set up its own data and not rely on an earlier test having run. A suite where tests can run in any order is one you can trust, and one where a single failure does not cascade into a wall of misleading errors.

Edge-case tests, including the empty list, catch bugs the happy path misses.
def first_or_default(items, default=None):
    if items:
        return items[0]
    return default

def test_first():
    assert first_or_default([1, 2, 3]) == 1
    assert first_or_default([], "empty") == "empty"
    assert first_or_default([], None) is None

test_first()
print("first_or_default: edge cases pass")

Tests catch regressions

The payoff of having tests arrives the day you refactor. You restructure a function for speed or clarity, run the suite, and the tests confirm the behaviour never changed. Without them every refactor is an act of faith; with them it is a measured change you can make fearlessly.

A test that fails after your edit has just saved you from shipping a bug, which is exactly the scenario that justifies the time spent writing it. New bugs become new tests, so the same mistake can never recur unnoticed, and your suite grows a little stronger every time something goes wrong.

Exercise

Write clamp(n, lo, hi) that returns n pinned inside the range [lo, hi]: below lo it returns lo, above hi it returns hi, otherwise n. The tests below lock in all three cases, including the boundaries.

def clamp(n, lo, hi):
    # below lo -> lo; above hi -> hi; otherwise n
    pass
Exercise

What does this print? The asserts pass silently, then the computed value is used.

def add(a, b):
    return a + b

assert add(2, 3) == 5
total = add(10, 5)
assert total == 15
print(total)
Exercise

A test suite for max_value(nums) checks [1, 5, 3] and [7] and both pass, but it never checks an all-negative list. What is the weakness?

Exercise

The tests below catch a real bug: max_value([-5, -2, -9]) returns 0 instead of -2, because the running maximum is wrongly initialised to 0. Fix the initial value so every test, including the all-negative case, passes.

def max_value(nums):
    best = 0
    for n in nums:
        if n > best:
            best = n
    return best
Exercise

Write remove_duplicates(items) that returns a new list with duplicates dropped, keeping the first occurrence of each item in order. The tests include the edge cases (empty, all-same, no duplicates) that your function must handle.

def remove_duplicates(items):
    seen = set()
    result = []
    # for each item: if not seen before, remember it and append
    return result

Recap

  • An assert checks a claim: a truthy condition passes silently, a falsy one raises AssertionError. Add a message so failures explain themselves.
  • Group related asserts into test_* functions and call them; this is the germ of every real test suite.
  • Write arrange-act-assert tests, one behaviour each, and always cover the edge cases (empty, single, boundary).
  • assert can be stripped by python -O, so use it for tests and internal checks only, never for validation or permissions.

That closes the course's core skill set: you can now read, write, organise, and verify real Python. The project that follows asks you to combine all of it.

Checkpoint quiz

What happens when an assert's condition evaluates to a falsy value?

Why should you never use assert user.is_admin to protect a sensitive action?

Go deeper — technical resources