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
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")
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:
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)
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)
pytest
============================= 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
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:
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:
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:
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")
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
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))
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.