Unit tests
Lesson 1 of 3
1Unit tests
The big pictureA test is code that checks your code works, and keeps checking every time you change something. Without it every edit is a gamble: you fix something here and break something there without knowing.
What the picture shows: Every test is three steps: prepare the inputs, call the function, check the result. Green means it passed; red means something changed.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
Like a pilot's pre-flight checklist: the same questions every time, and the answer must be the same. If one answer changes you know at once where the problem is, before you take off.
A unit test is fast (milliseconds), isolated (no network, database or real clock), deterministic, and checks behaviour, not implementation details. pytest discovers test_* functions and shows assert differences clearly, with pytest.mark.parametrize for a table of cases, pytest.raises for errors and fixtures for shared setup. Mocks (unittest.mock) replace an external dependency, but overusing them makes the test check the mock, not the code. Coverage points at what is untested; it is not a goal in itself.
Before You Start
🐍Functions and exceptions
You write a function and raise an error.
The Core Idea
1A test in three steps
Here is a discount function and its tests. We run them with unittest and print a stable summary (pytest discovers them by the same names):
import io
import unittest
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError("percent must be 0-100")
return round(price * (100 - percent) / 100, 2)
class DiscountTests(unittest.TestCase):
def test_regular_discount(self):
price, percent = 50, 20 # arrange
result = apply_discount(price, percent) # act
self.assertEqual(result, 40) # assert
def test_zero_and_full(self):
self.assertEqual(apply_discount(50, 0), 50)
self.assertEqual(apply_discount(50, 100), 0)
def test_rejects_bad_percent(self):
with self.assertRaises(ValueError):
apply_discount(50, 120)
suite = unittest.defaultTestLoader.loadTestsFromTestCase(DiscountTests)
result = unittest.TextTestRunner(stream=io.StringIO()).run(suite)
print("ran:", result.testsRun, "failures:", len(result.failures), "ok:", result.wasSuccessful())The same idea in pytest, shorter:
import pytest
from shop import apply_discount
@pytest.mark.parametrize("price, percent, expected", [
(50, 20, 40), (50, 0, 50), (50, 100, 0), (19.99, 15, 16.99),
])
def test_apply_discount(price, percent, expected):
assert apply_discount(price, percent) == expected
def test_rejects_bad_percent():
with pytest.raises(ValueError):
apply_discount(50, 120)2What do you test?
The normal case, then the edges: zero, empty, the largest value, negative, None, and Arabic text. Then the expected errors. And every bug you fix gets a test named after it so it never comes back.
assert. Try breaking the code on purpose: if the test stays green it does not protect you.3Mocks
A function that sends an SMS must not send a real message on every test run. We replace the sender with a stand-in that records what it was asked:
from unittest.mock import Mock
def notify_ready(order, sms):
if order["status"] != "ready":
return False
sms.send(order["phone"], f"Order {order['id']} is ready")
return True
sms = Mock()
print(notify_ready({"id": 7, "status": "ready", "phone": "0500000000"}, sms))
sms.send.assert_called_once_with("0500000000", "Order 7 is ready")
print(notify_ready({"id": 8, "status": "new", "phone": "0500000000"}, sms))
print("send calls:", sms.send.call_count)Worked example
Problem: a report says "an invoice with 15% off 19.99 gave 16.991". Test first: assert apply_discount(19.99, 15) == 16.99 fails. Fix: round(..., 2). Result: the test passes and stays in the file to guard against this bug forever.
Myth vs Reality
| Myth | Reality |
|---|---|
| Tests slow development down. | They slow the first hour and speed up every change after, because you know at once if you broke something. |
| 100% coverage means no bugs. | It means every line ran, not that every case was checked. |
Test your understanding
What makes a test a unit test?
Isolation makes it fast and stable.
How do you know your test really protects you?
A test that never turns red checks nothing.
When do you use a mock?
The stand-in prevents real side effects and keeps the test fast.
Quick review cards
Check Your Understanding
Why pass the dependency as an argument?
So it is easy to swap for a stand-in in tests without changing the code.
What do you not test?
Internal implementation details and other people's libraries. Test the behaviour the caller sees.
How many tests per function?
One per behaviour: the normal case, each important edge, and each expected error.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs2
Done with this lesson?
Sign in to save your progress.