Testing your code
Automated tests that prove code works and keep it working.
Create a free account Try part of it here
Why it matters
Every exercise in this academy is checked by tests you can't see, and that's how professional code works too. Tests let you change code without fear: if you break something, a test says so in seconds. They're also the main tool for checking AI-written code. When an agent says "done", a test suite is how you know it's true. Teams that test move faster, not slower.
What you'll be able to do
- Write automated tests with assert and with unittest, in the arrange, act, assert shape
- Choose test cases that catch bugs: edges, boundaries and bad input
- Replace slow or unpredictable parts (networks, clocks, models) with fakes
- Explain test-driven development and the test pyramid
What it covers
- A test is code that checks code
- A test framework does the bookkeeping
- What to test: where bugs hide
- Testing code that calls a model
- Test-driven development and the pyramid
- What good tests look like
A test is code that checks code
A car maker doesn't ask a driver whether the brakes feel fine. A machine tests them the same way, every time. Checking code by hand ("run it, look at the output, seems fine") gets skipped when you're busy, and it misses things you forgot to look at. A test is that machine: a small function that runs your code with known inputs and checks the result automatically.
Python's assert statement is the simplest check. It does nothing when its condition is true, and raises AssertionError when it's false:
def ctr(clicks, impressions): return 0.0 if impressions == 0 else clicks / impressions def test_ctr_basic(): # Arrange: set up inputs. Act: call the code. Assert: check the result. result = ctr(25, 1000) assert result == 0.025 def test_ctr_no_impressions(): assert ctr(0, 0) == 0.0 for test in [test_ctr_basic, test_ctr_no_impressions]: test() print("passed:", test.__name__)
Each test checks one behavior and is named for it. That arrange, act, assert shape keeps tests short and readable: set things up, do the one thing, check what happened. When one fails, its name tells you what broke.
def add(a, b): return a + b print(add(2, 2) == 4 and add(-1, 1) == 0)
Show the answer
True
add(2, 2) is 4 and add(-1, 1) is 0, so both checks hold and it prints True. A test is only code that calls your function and checks the answer.
A test framework does the bookkeeping
Real projects have hundreds of tests. A test framework is like a marker with a stack of exam papers: it finds them, works through them all, keeps going when one fails, and reports a summary. Python ships with unittest:
import unittest, io 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) class TestDiscount(unittest.TestCase): def test_twenty_percent(self): self.assertEqual(apply_discount(50, 20), 40.0) def test_rounds_to_cents(self): self.assertEqual(apply_discount(19.99, 15), 16.99) def test_rejects_bad_percent(self): with self.assertRaises(ValueError): apply_discount(10, 150) report = io.StringIO() suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDiscount) result = unittest.TextTestRunner(stream=report, verbosity=2).run(suite) print(report.getvalue())
Most Python teams use pytest instead. Its tests are plain functions with plain assert, and you run them all from the terminal:
pip install pytest pytest # finds every test_*.py file and every test_* function in them
Note: The academy can't run pytest's command line, so lessons use
unittestor plain functions. The ideas are identical.
Show the answer
with self.assertRaises(ValueError): apply_discount(10, 150)
assertRaises passes only if the code inside raises that exception. The others either expect a value or check nothing useful.
What to test: where bugs hide
A building inspector goes straight to the corners, the joins and the wet rooms. Bugs cluster in the same places too, and good testers aim there on purpose:
- Empty and tiny inputs: an empty list, an empty string, zero, one item.
- Boundaries: if 0 to 100 is allowed, test 0, 100, -1 and 101. Off-by-one mistakes (
<where you meant<=) live exactly on the line. - Bad input: the wrong type,
None, negative amounts, text where a number was expected. - Unusual but real data: accents and emoji in names, very large numbers, duplicates.
- The normal case, once or twice, to prove the main path works.
One test per behavior is better than one giant test. When a big test fails, you still have to work out which part broke.
Fakes: test without the network
A crash test uses a dummy, because you can't use a real person every time. Code that calls an API, reads the clock or asks a model is hard to test. It's slow, it costs money, and the answer changes. The fix is to pass those parts in instead of hard-coding them. Then a test can hand in a fake: a small stand-in that returns fixed answers.
In plain words: if your code doesn't build its own connections, a test can give it pretend ones.
Testing code that calls a model
summarize takes the function that calls the model as an argument. Production passes the real call; the test passes a fake that records what it was asked.
def summarize(text, call_model): if not text.strip(): return "" reply = call_model(f"Summarize in one sentence:\n{text}") return reply.strip() def test_summarize_sends_text_and_trims(): asked = [] def fake_model(prompt): asked.append(prompt) return " A short summary. " assert summarize("Long report...", fake_model) == "A short summary." assert "Long report..." in asked[0] def test_summarize_skips_empty_text(): def fake_model(prompt): raise AssertionError("the model shouldn't be called for empty text") assert summarize(" ", fake_model) == "" test_summarize_sends_text_and_trims() test_summarize_skips_empty_text() print("both tests passed, with no network and no cost")
The tests check your logic: what gets sent, how the reply is cleaned, when the model is skipped. They never check what a real model would say, because that changes. This pattern of handing in dependencies is called dependency injection. It's also how the academy's own exercises test code that calls Claude.
Show the answer
Pass in the send function, and give the test a fake that records the message
A fake makes the test fast, free and repeatable, and lets you check exactly what would have been sent.
Test-driven development and the pyramid
You'd agree what "clean" means before asking someone to clean a room. Test-driven development (TDD) brings that idea to code, by flipping the usual order:
- Red: write a test for the next small behavior. Run it and watch it fail.
- Green: write the least code that makes it pass.
- Refactor: tidy the code while the tests stay green.
Writing the test first forces you to decide what "working" means before you start. It's also a strong way to direct an AI coding agent: give it a failing test, and "done" means "the test passes".
Tests come in sizes, often drawn as a test pyramid:
- Unit tests at the bottom: many, tiny, fast, one function at a time, with fakes.
- Integration tests in the middle: fewer, checking parts working together, like your code and a real database.
- End-to-end tests at the top: a few, driving the whole app the way a user would. They're slow and they break easily, so keep them for the most important paths.
Code coverage tools report which lines your tests ran. Low coverage shows untested code. High coverage doesn't prove much, though. You can walk through every room in a house without checking a single smoke alarm, and a test can run a line without checking its result. And a flaky test, one that passes and fails at random, is worse than none, because people learn to ignore it. Fix it or delete it.
Many unit tests, some integration tests, and a few end-to-end tests: that's the pyramid.
Show the answer
Fast unit tests of small pieces
Unit tests are fast and pinpoint failures, so they form the wide base. A few end-to-end tests check that the pieces fit together.
What good tests look like
Coverage isn't quality. A coverage report says which lines ran, not whether anything checked the result. A test with no assert can reach 100% coverage and catch nothing. Coverage is useful for finding code that no test touches at all. It can't tell you that the tested code is right.
Test the unhappy paths. Bugs live where people didn't think: empty lists, zero, negative numbers, missing fields, and two things at once. Most of the value of a test suite lies in those cases, not in the one example that was bound to work.
Keep unit tests away from real networks. A test that calls a real API can fail when the API is slow, costs money each run, and can't easily trigger the errors you want to check. Use a fake, and keep one or two end-to-end tests for confidence that the real thing still works.
Fix or delete flaky tests. A test that fails at random teaches everyone to ignore red. Then the day a real failure shows up, nobody looks. A flaky test is often a real timing bug in disguise, so it's worth finding out why it fails before you delete it.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What are the three steps of a well-shaped test?