Tests are executable specifications: small programs that invoke your code with known inputs and verify the results. They are the fastest feedback loop you get while changing a codebase. Python ships unittest in the standard library, but the de-facto choice in modern projects is pytest, which makes the same tests shorter and the failure messages clearer.
A pytest test is just a function whose name starts with test_ and that uses the plain assert statement. Place tests in files named test_*.py and run pytest in your project root; it discovers everything automatically. The output tells you which tests passed, which failed, and the exact comparison that broke.
Aim for three kinds of tests. Unit tests exercise a single function or class with mocked boundaries; they run in milliseconds. Integration tests wire a few components together against a real local resource (a temporary file, a test database). End-to-end tests drive the whole program from the CLI or HTTP layer. The pyramid principle: many unit tests, fewer integration tests, a handful of end-to-end.
Good tests are fast, deterministic, and focused on behavior rather than implementation. They call the public API, not private helpers. They use tmp_path and fixtures for side effects instead of touching the real filesystem. When they fail, the failure message should point at the mismatch, not just “AssertionError”.
Writing a first test
Create test_math.py, define def test_add_positive_numbers():, and inside write assert add(1, 2) == 3. Run pytest. Each failing assertion shows the expected and actual values side-by-side.
Group related tests in a class (class TestAdd: def test_positive(self): ...). pytest discovers those too, without any inheritance requirement.
Parametrize, fixtures, and mocks
@pytest.mark.parametrize("a,b,expected", [...]) turns one test into many. Use it to cover boundary conditions without copy-pasting the body.
Fixtures are functions decorated with @pytest.fixture that produce a value (or context) for tests to consume. tmp_path (built-in) gives each test its own temp directory; monkeypatch lets you replace attributes temporarily.
Essential testing tools.
| Tool | Purpose |
|---|---|
pytesttool | Modern test runner and assertion library. |
unittestmodule | Standard-library test framework. |
unittest.mockmodule | Mocking and patching helpers. |
@pytest.mark.parametrizedecorator | Run the same test with many inputs. |
@pytest.fixturedecorator | Inject setup/teardown into tests. |
tmp_pathfixture | Unique temp directory per test. |
hypothesislibrary | Property-based testing with auto-generated inputs. |
coverage.pytool | Measure line coverage from tests. |
Verifying Code with Tests code example
The script uses unittest from the standard library (works without installing pytest) to showcase the same patterns.
# Lesson: Verifying Code with Tests
import unittest
def add(a: int, b: int) -> int:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("b cannot be zero")
return a / b
class TestAdd(unittest.TestCase):
def test_positive(self):
self.assertEqual(add(1, 2), 3)
def test_negative(self):
self.assertEqual(add(-5, 2), -3)
def test_is_commutative(self):
self.assertEqual(add(1, 7), add(7, 1))
class TestDivide(unittest.TestCase):
def test_normal(self):
self.assertAlmostEqual(divide(10, 4), 2.5)
def test_zero_raises(self):
with self.assertRaises(ZeroDivisionError):
divide(1, 0)
def test_table(self):
cases = [(10, 2, 5), (9, 3, 3), (7, 1, 7)]
for a, b, expected in cases:
with self.subTest(a=a, b=b):
self.assertEqual(divide(a, b), expected)
if __name__ == "__main__":
# Run in-process so the lesson prints a clean summary
loader = unittest.TestLoader()
suite = loader.loadTestsFromModule(__import__("__main__"))
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)
Things to spot:
1) Each test is small and names one behavior.
2) `assertRaises` encapsulates 'calling this should raise'.
3) `subTest` parametrizes a loop so failures report the specific input.
4) Tests belong with the code they exercise, so the rest of the project can trust it.
Write a pytest equivalent (requires pytest).
# test_add.py — run with: pytest -q
import pytest
def add(a, b):
return a + b
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(-5, 2, -3),
(0, 0, 0),
])
def test_add(a, b, expected):
assert add(a, b) == expected
def test_add_is_commutative():
assert add(1, 7) == add(7, 1)
Small invariants that double as tests.
def abs_diff(a, b):
return abs(a - b)
assert abs_diff(5, 3) == 2
assert abs_diff(3, 5) == 2
assert abs_diff(0, 0) == 0
Running the unittest demo prints:
test_is_commutative (__main__.TestAdd.test_is_commutative) ... ok
test_negative (__main__.TestAdd.test_negative) ... ok
test_positive (__main__.TestAdd.test_positive) ... ok
test_normal (__main__.TestDivide.test_normal) ... ok
test_table (__main__.TestDivide.test_table) ... ok
test_zero_raises (__main__.TestDivide.test_zero_raises) ... ok
----------------------------------------------------------------------
Ran 6 tests in 0.002s
OK