Learning Goals
3 min- Separate code-under-test from test code in two files.
- Organise tests as named functions.
- Run all tests, catch failures, and print a clean summary.
- Appreciate the boilerplate a framework removes.
Warm-Up · Two Files
5 minmyproject/ ├─ mathtools.py # the code we're testing └─ test_mathtools.py # the tests (imports mathtools)
Keep tests in a separate file that imports your code. This mirrors how every real project works: foo.py + test_foo.py. The test file proves foo.py works — and re-proves it every time you change anything.
New Concept · A Hand-Built Test File
14 minThe code under test
# mathtools.py def is_prime(n): if n < 2: return False for i in range(2, int(n ** 0.5) + 1): if n % i == 0: return False return True def factorial(n): if n < 0: raise ValueError("factorial of negative number") result = 1 for i in range(2, n + 1): result *= i return result
The test file
# test_mathtools.py from mathtools import is_prime, factorial def test_is_prime_basics(): assert is_prime(2) is True assert is_prime(17) is True assert is_prime(4) is False def test_is_prime_edges(): assert is_prime(0) is False assert is_prime(1) is False assert is_prime(-7) is False def test_factorial(): assert factorial(0) == 1 assert factorial(5) == 120 def test_factorial_negative_raises(): try: factorial(-1) assert False, "expected ValueError" except ValueError: pass def run_all(): tests = [test_is_prime_basics, test_is_prime_edges, test_factorial, test_factorial_negative_raises] passed = failed = 0 for t in tests: try: t() print(f" PASS {t.__name__}") passed += 1 except AssertionError as e: print(f" FAIL {t.__name__}: {e}") failed += 1 print(f"\n{passed} passed, {failed} failed") if __name__ == "__main__": run_all()
Run it
$ python test_mathtools.py PASS test_is_prime_basics PASS test_is_prime_edges PASS test_factorial PASS test_factorial_negative_raises 4 passed, 0 failed
What's annoying here?
- You must manually list every test in
run_all— forget one and it silently never runs. - You hand-wrote the pass/fail counting and try/except.
- Checking an exception took 4 clunky lines.
- A failure shows your message but not the actual vs expected values.
Every one of these annoyances is exactly what unittest (next lesson) and pytest fix for free.
Worked Example · Auto-Discover the Tests
12 minThe biggest annoyance — hand-listing tests — we can fix ourselves. Find every function named test_* automatically:
# at the bottom of test_mathtools.py import sys, inspect def run_all(): # auto-collect every test_* function in this module module = sys.modules[__name__] tests = [fn for name, fn in inspect.getmembers(module, inspect.isfunction) if name.startswith("test_")] passed = failed = 0 for t in tests: try: t(); print(f" PASS {t.__name__}"); passed += 1 except AssertionError as e: print(f" FAIL {t.__name__}: {e}"); failed += 1 except Exception as e: print(f" ERROR {t.__name__}: {type(e).__name__}: {e}"); failed += 1 print(f"\n{passed} passed, {failed} failed") sys.exit(1 if failed else 0) # exit code matters for CI (L6-46) if __name__ == "__main__": run_all()
Read the diff
Now you never forget to register a test — naming it test_* is enough. We also catch unexpected exceptions separately (ERROR vs FAIL) and set a non-zero exit code on failure, which is how CI systems know the build failed. Congratulations: you've just re-invented the core of pytest. From the next lesson, you'll use the real thing.
Basic
5 minTake any function you have, put it in its own file, and write a test_*.py beside it with 4 test functions. Run them.
Challenge 1
4 minBreak the code under test. Confirm your runner reports the failing test by name and keeps running the others.
Challenge 2
4 minAdd the inspect-based auto-discovery so you never hand-list tests. Confirm a new test_* function runs without editing run_all.
Challenge 3 · A Reusable Mini-Runner
8 minPull your runner into its own file minitest.py that any test file can import: from minitest import run_all; run_all(globals()). It should discover test_* functions in the caller's globals, run them, and report. A reusable micro-framework.
Show one possible solution
# minitest.py import sys def run_all(namespace): tests = {n: f for n, f in namespace.items() if n.startswith("test_") and callable(f)} passed = failed = 0 for name, fn in sorted(tests.items()): try: fn(); print(f" PASS {name}"); passed += 1 except AssertionError as e: print(f" FAIL {name}: {e}"); failed += 1 except Exception as e: print(f" ERROR {name}: {type(e).__name__}: {e}"); failed += 1 print(f"\n{passed} passed, {failed} failed") return failed == 0 # in any test file: # from minitest import run_all # def test_x(): assert 1 + 1 == 2 # if __name__ == "__main__": run_all(globals())
Non-negotiables: discovery from the caller's namespace, FAIL vs ERROR distinction, a summary. This is genuinely a tiny framework.
Recap
3 minA real test file lives beside the code (test_foo.py imports foo.py), organises checks as test_* functions, and a runner executes them all, catching failures and printing a summary. Doing it by hand exposes the boilerplate — manual registration, pass/fail counting, clunky exception checks — that frameworks remove. You even built auto-discovery and exit codes. Next: the real built-in framework, unittest.
Vocabulary Card
- code under test
- The actual program code your tests exercise.
- test file
- A separate file (
test_*.py) holding tests that import the code. - test runner
- Code that finds and executes tests and reports results.
- exit code
- 0 = success, non-zero = failure; how CI tools detect a broken build.
Extra Mission
4 minBuild minitest.py and use it to test a small module of your own (≥ 3 functions, ≥ 8 tests including edge and error cases). Make the suite green, then break one function and confirm the runner reports exactly what failed. Keep this — you'll compare it to pytest next week.
Reuse minitest.py from the mini-challenge. The point of the exercise is to feel the boilerplate so you appreciate pytest — which does all of this and far more, for free.