Testing Your Code

Tests are small programs that check your code does what you expect. They catch bugs before users do, let you change code with confidence, and document how functions are meant to behave. Python has testing built in, and the third-party pytest library makes it pleasant.

assert: the simplest test

PY assert_test.py
def slugify(title):
    return "-".join(title.lower().split())

assert slugify("Hello World") == "hello-world"
assert slugify("  Many   Spaces  ") == "many-spaces"
assert slugify("") == ""
print("all checks passed")
Output
all checks passed

An assert does nothing if its condition is true and raises AssertionError if it's false. Test frameworks build on exactly this idea, adding discovery, reporting and helpers.

pytest

Install it in your project's virtual environment (lesson 47) with pip install pytest. Put code in one file and tests in another:

PY pricing.py
def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return round(price * (1 - percent / 100), 2)
PY test_pricing.py
import pytest
from pricing import apply_discount

def test_takes_percentage_off():
    assert apply_discount(80, 25) == 60

def test_rounds_to_cents():
    assert apply_discount(9.99, 15) == 8.49

def test_zero_discount_keeps_price():
    assert apply_discount(50, 0) == 50

def test_rejects_invalid_percent():
    with pytest.raises(ValueError, match="between 0 and 100"):
        apply_discount(10, 120)
$ terminal
pytest
Output
============================= test session starts ==============================
collected 4 items

test_pricing.py ....                                                     [100%]

============================== 4 passed in 0.01s ===============================

pytest finds files named test_*.py and runs every function whose name starts with test_. Plain assert statements are enough — when one fails, pytest shows exactly which values differed. pytest.raises checks that an exception is raised.

What a failure looks like

pytest output (failure)
    def test_rounds_to_cents():
>       assert apply_discount(9.99, 15) == 8.50
E       assert 8.49 == 8.5
E        +  where 8.49 = apply_discount(9.99, 15)

test_pricing.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_rounds_to_cents - assert 8.49 == 8.5

The report shows the failing line, the actual value and the expected value.

Parametrised tests

Test many inputs without copying the test function:

PY test_param.py
import pytest
from pricing import apply_discount

@pytest.mark.parametrize(
    "price, percent, expected",
    [(100, 10, 90), (100, 0, 100), (100, 100, 0), (19.99, 50, 10.0)],
)
def test_apply_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected

Fixtures

Fixtures provide reusable setup data or objects. pytest passes them into any test that names them as a parameter:

PY test_cart.py
import pytest
from shop import Cart

@pytest.fixture
def cart():
    c = Cart()
    c.add("pen", 1.50, qty=4)
    return c

def test_total(cart):
    assert cart.total == 6.0

def test_remove(cart):
    cart.remove("pen")
    assert cart.total == 0

def test_writes_file(tmp_path):
    report = tmp_path / "report.txt"
    report.write_text("ok")
    assert report.read_text() == "ok"

Each test gets a fresh fixture, so tests don't affect each other. Built-in fixtures like tmp_path (a temporary folder) and monkeypatch (temporarily replace functions or environment variables) cover common needs.

unittest: the built-in framework

The standard library includes unittest, which uses classes. You'll see it in many existing projects — and pytest can run these tests too:

PY unittest_demo.py
import io
import unittest

def is_palindrome(text):
    cleaned = "".join(ch.lower() for ch in text if ch.isalnum())
    return cleaned == cleaned[::-1]

class TestPalindrome(unittest.TestCase):
    def test_simple(self):
        self.assertTrue(is_palindrome("racecar"))

    def test_ignores_case_and_punctuation(self):
        self.assertTrue(is_palindrome("A man, a plan, a canal: Panama!"))

    def test_not_palindrome(self):
        self.assertFalse(is_palindrome("python"))

suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestPalindrome)
result = unittest.TextTestRunner(verbosity=0, stream=io.StringIO()).run(suite)
print(f"ran {result.testsRun} tests, {len(result.failures)} failures")
Output
ran 3 tests, 0 failures

In a real script you'd simply put unittest.main() at the bottom, or run python -m unittest; the explicit loader above does the same job in a way that also works in the in-page editor.

doctest: examples in docstrings

PY doctest_demo.py
def word_count(text):
    """Count the words in a string.

    >>> word_count("hello big world")
    3
    >>> word_count("")
    0
    """
    return len(text.split())

import doctest
runner = doctest.DocTestRunner()
for test in doctest.DocTestFinder().find(word_count, globs={"word_count": word_count}):
    runner.run(test)
print(runner.summarize(verbose=False))
Output
TestResults(failed=0, attempted=2)

In a normal script, the one-liner doctest.testmod() (or python -m doctest file.py) does this for the whole module. doctest runs the >>> examples in docstrings and checks the output — keeping documentation honest.

What makes a good test

  • Test behaviour, not implementation. Check inputs and outputs, so you can refactor freely.
  • Cover edge cases: empty inputs, zero, negative numbers, very large values, invalid data.
  • One idea per test, with a descriptive name — a failing test's name should tell you what broke.
  • Fast and independent: no reliance on test order, network access or shared state.
  • Write a test for every bug you fix, so it can't come back.

Tools worth knowing: pytest-cov measures which lines your tests exercise, and running tests automatically on every push (continuous integration, for example with GitHub Actions) keeps the main branch healthy.

Try it yourself

Try it yourself
Console output