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.
Try It Yourself
13 minTake any function you have, put it in its own file, and write a test_*.py beside it with 4 test functions. Run them.
Break the code under test. Confirm your runner reports the failing test by name and keeps running the others.
Add the inspect-based auto-discovery so you never hand-list tests. Confirm a new test_* function runs without editing run_all.
Mini-Challenge · 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.
Homework
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.