Testing Your Code

The Course
January 11, 2026
5 min read

A test is a small program that runs your program and complains if the answer is wrong. The reason to write them is not correctness today — it is the freedom to change things tomorrow.

assert

The simplest test is a claim that must hold.

A claim about your code Python
def with_tax(amount, rate=0.2):
    return round(amount * (1 + rate), 2)

assert with_tax(100) == 120.0
assert with_tax(100, 0.1) == 110.0
assert with_tax(0) == 0.0

print("all assertions passed")

assert with_tax(19.99) == 24.0, f"got {with_tax(19.99)} instead"

An assert that holds does nothing at all; one that fails raises AssertionError with your message. That last line fails on purpose — and the message tells you the actual value, which a bare assertion would not.

assert is for tests, not for validation

Python removes every assert when run with the -O flag, so never use one to check user input or enforce security. For those, raise ValueError — Day 5.

Writing tests as functions

Group related assertions into named functions, so a failure says what broke.

A hand-rolled test runner Python
def with_tax(amount, rate=0.2):
    return round(amount * (1 + rate), 2)

def test_default_rate():
    assert with_tax(100) == 120.0

def test_custom_rate():
    assert with_tax(100, 0.1) == 110.0

def test_zero():
    assert with_tax(0) == 0.0

def test_rounds_to_cents():
    assert with_tax(19.99) == 23.99

tests = [test_default_rate, test_custom_rate, test_zero, test_rounds_to_cents]

passed = 0
for test in tests:
    try:
        test()
        passed += 1
        print(f"  PASS {test.__name__}")
    except AssertionError as error:
        print(f"  FAIL {test.__name__}: {error or 'assertion failed'}")

print(f"{passed}/{len(tests)} passed")

That loop is, in miniature, what a test framework does.

unittest

unittest ships with Python, so it is always available.

The standard library's runner Python
import unittest

def with_tax(amount, rate=0.2):
    if amount < 0:
        raise ValueError("amount cannot be negative")
    return round(amount * (1 + rate), 2)

class TestWithTax(unittest.TestCase):
    def test_default_rate(self):
        self.assertEqual(with_tax(100), 120.0)

    def test_custom_rate(self):
        self.assertEqual(with_tax(100, 0.1), 110.0)

    def test_rounds_to_cents(self):
        self.assertAlmostEqual(with_tax(19.99), 23.99)

    def test_rejects_negative(self):
        with self.assertRaises(ValueError):
            with_tax(-1)

# In a file you would finish with unittest.main(). This page runs inside a
# browser runtime with no script module for it to inspect, so the suite is
# loaded and run explicitly instead.
suite = unittest.TestLoader().loadTestsFromTestCase(TestWithTax)
unittest.TextTestRunner(verbosity=2).run(suite)

Useful assertions: assertEqual, assertTrue, assertIn, assertIsNone, assertAlmostEqual (for floats — remember Day 1), and assertRaises as a context manager for expected errors.

setUp

setUp runs before every test method, so each test starts from a clean state.

Fresh fixtures per test Python
import unittest

class Cart:
    def __init__(self):
        self.items = []

    def add(self, name, price):
        self.items.append((name, price))

    def total(self):
        return round(sum(price for _, price in self.items), 2)

class TestCart(unittest.TestCase):
    def setUp(self):
        self.cart = Cart()          # a brand new cart for every test

    def test_starts_empty(self):
        self.assertEqual(self.cart.total(), 0)
        self.assertEqual(len(self.cart.items), 0)

    def test_adds_items(self):
        self.cart.add("keyboard", 49.99)
        self.cart.add("mouse", 25.50)
        self.assertEqual(self.cart.total(), 75.49)

    def test_isolated_from_other_tests(self):
        self.assertEqual(len(self.cart.items), 0)

suite = unittest.TestLoader().loadTestsFromTestCase(TestCart)
unittest.TextTestRunner(verbosity=2).run(suite)

Tests must not depend on each other or on their order. setUp is how you guarantee that.

pytest

Outside the standard library, almost everyone uses pytest. There is no class and no special assertion methods — plain functions and plain assert:

# test_tax.py
import pytest
from tax import with_tax

def test_default_rate():
    assert with_tax(100) == 120.0

def test_rejects_negative():
    with pytest.raises(ValueError):
        with_tax(-1)

@pytest.mark.parametrize("amount,expected", [
    (0, 0.0),
    (100, 120.0),
    (19.99, 23.99),
])
def test_various_amounts(amount, expected):
    assert with_tax(amount) == expected

Install it with pip install pytest, then run pytest in your project folder; it finds files named test_*.py and functions named test_* by itself.

What to test

Test the edges, not the middle. For a function, that usually means:

  • A typical case that should obviously work.
  • Zero, empty, one — the boundaries.
  • Something invalid, and the error it should raise.
  • Any bug you have ever fixed, so it cannot come back.
Write the test when you fix the bug

The moment a bug is fresh is the cheapest moment to capture it. A test written then is worth three written later from memory.

Exercise

Test a function you did not write

initials("ada lovelace") should return "A.L.". Write tests covering a normal name, a single name, an empty string, and extra whitespace — then fix the function so they all pass.

def initials(full_name):
    parts = full_name.split(" ")
    return ".".join(part[0].upper() for part in parts) + "."

def check(description, actual, expected):
    status = "PASS" if actual == expected else f"FAIL (got {actual!r})"
    print(f"  {status}  {description}")

check("normal name", initials("ada lovelace"), "A.L.")
# Add your checks here
Show one solution
def initials(full_name):
    """Return dotted initials, e.g. 'ada lovelace' -> 'A.L.'."""
    parts = full_name.split()          # split() with no argument handles
    if not parts:                      # runs of whitespace and empty input
        return ""
    return ".".join(part[0].upper() for part in parts) + "."

def check(description, actual, expected):
    status = "PASS" if actual == expected else f"FAIL (got {actual!r})"
    print(f"  {status}  {description}")

check("normal name", initials("ada lovelace"), "A.L.")
check("single name", initials("grace"), "G.")
check("empty string", initials(""), "")
check("extra whitespace", initials("  ada   lovelace  "), "A.L.")
check("three names", initials("ada byron lovelace"), "A.B.L.")

Two bugs surfaced. split(" ") on " ada " produces empty strings, and part[0] on an empty string raises IndexError; bare split() handles both. And the empty input needed a decision — returning "" rather than "." — which is exactly the kind of question writing tests forces you to answer.

What you learned

  • assert claim, message is the smallest possible test.
  • Never use assert for validating real input — it can be optimised away.
  • unittest gives you test classes, setUp, and assertions like assertRaises.
  • pytest does the same with plain functions and plain assert.
  • Test the boundaries, the errors, and every bug you fix.
Last updated on January 11, 2026

Was this article helpful?

Your response is saved on this device.