10 - Tests¶
What this session is¶
About an hour. You'll learn how to write tests for your Python code with pytest - the standard test framework. By the end you can verify your own code works, watch it fail when you break it, and read the tests in any Python OSS project to understand what the code is supposed to do.
Why tests¶
When you change code, you might break something that used to work. The change you made looks fine. The thing that broke is in a file you haven't opened in three weeks. Without tests, you find out when a user does.
A test is a small program that calls your code with known inputs and checks the outputs match expectations. You run them after every change. If they pass, you keep going. If one fails, you know what broke.
This sounds obvious. Beginner programmers skip it for years because it feels like extra work. It isn't. It's the work that prevents three hours of debugging next week.
Install pytest¶
Python ships with a built-in unittest framework, but the wider community uses pytest - friendlier syntax, better error messages, more flexibility. Install in your active venv:
Verify:
Your first test¶
Create a folder:
Create a mathutils.py:
Create test_mathutils.py (note the test_ prefix - pytest auto-discovers these):
from mathutils import add, is_even
def test_add():
assert add(2, 3) == 5
def test_is_even():
assert is_even(4)
assert not is_even(7)
Run:
You should see:
============================ test session starts ============================
collected 2 items
test_mathutils.py .. [100%]
============================= 2 passed in 0.01s =============================
Each . is a passing test. [100%] means all of them passed.
The mechanics¶
- File naming: files must start with
test_(or end with_test). pytest finds them automatically. - Function naming: test functions must start with
test_. - Assertions: plain Python
assert. No special API. pytest rewrites failedassertstatements to give you rich error messages.
That's it. No setUp, no test classes, no special inheritance. The simplest possible thing that works.
Watching a test fail (do this)¶
Open mathutils.py. Change add to return a - b. Save. Run pytest.
You should see:
============================== FAILURES ==============================
______________________________ test_add ______________________________
def test_add():
> assert add(2, 3) == 5
E assert -1 == 5
E + where -1 = add(2, 3)
test_mathutils.py:4: AssertionError
=================== short test summary info ==========================
FAILED test_mathutils.py::test_add - assert -1 == 5
Notice how informative: it shows the line that failed, the actual value (-1), the expected value (5), and which call produced what. Pytest's assert rewriting is what gives you this.
Change add back. Re-run. Green again.
Parametrize: many cases, one function¶
When you have many cases for the same function, don't write test_x_1, test_x_2. Parametrize:
import pytest
from mathutils import is_even
@pytest.mark.parametrize("n, expected", [
(0, True),
(1, False),
(2, True),
(-4, True),
(-7, False),
(1000, True),
])
def test_is_even(n, expected):
assert is_even(n) == expected
What's happening:
@pytest.mark.parametrizeruns the same test multiple times with different inputs.- First argument: a string naming the parameters.
- Second argument: a list of tuples - one tuple per case.
- pytest generates one test per case, each with a distinct name like
test_is_even[2-True].
This is the idiomatic Python testing shape. You'll see it in 80% of test files.
Run with pytest -v to see each case named individually.
Fixtures: shared setup¶
Many tests need the same setup - a temporary file, a fresh database connection, a particular object. Pytest has fixtures for this:
import pytest
@pytest.fixture
def sample_data():
return {"name": "Alice", "age": 30}
def test_name(sample_data):
assert sample_data["name"] == "Alice"
def test_age(sample_data):
assert sample_data["age"] == 30
Fixtures are functions decorated with @pytest.fixture. Tests "request" them by listing them as parameters. Pytest runs the fixture, passes the return value to the test.
Built-in fixtures you'll meet often:
- tmp_path - a unique temp directory (pathlib.Path). Cleaned up after the test.
- monkeypatch - modify env vars, attributes, dict items; auto-undone after the test.
- capsys - capture print output for assertion.
Example: testing a function that reads a file.
def test_read_file(tmp_path):
p = tmp_path / "data.txt"
p.write_text("hello")
assert read_file(p) == "hello"
tmp_path gives you a fresh, isolated directory; you write a file there, run your code against it, the directory disappears after. No cleanup boilerplate, no global state.
Testing exceptions¶
Use pytest.raises:
import pytest
from mathutils import divide
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
The test passes if the call inside the with block raises ZeroDivisionError. It fails if no exception is raised, or a different type is raised.
You can also assert on the message:
def test_divide_by_zero_message():
with pytest.raises(ZeroDivisionError, match="division by zero"):
divide(10, 0)
match is a regex applied to the error message.
Useful pytest commands¶
| Command | What it does |
|---|---|
pytest |
Run all tests in the current directory and subdirectories. |
pytest -v |
Verbose - show each test by name. |
pytest -x |
Stop at the first failure. |
pytest -k pattern |
Run only tests whose name matches the pattern. |
pytest path/to/test_file.py |
Run one file. |
pytest test_x.py::test_func |
Run one function. |
pytest --tb=short |
Compact tracebacks. |
pytest --pdb |
Drop into the Python debugger on failure. |
pytest -q |
Quiet - minimal output. |
pytest --collect-only |
List what would run without running it. |
pytest -v is the most useful during development.
Running tests as you change code¶
Install pytest-watch and let it re-run tests every time you save a file:
Or simpler: run pytest -v after every save. The instant feedback loop is the productive way to work.
A note on coverage¶
pytest-cov shows what percentage of your code your tests touch:
100% coverage is a misleading goal - you can hit it with tests that don't actually catch bugs. A better target: "every code path has at least one test, and every bug fix gets a regression test." Coverage gives you a floor, not a ceiling.
Exercise¶
Set up and test a small package.
-
Make a folder
~/code/python-learning/wordtoolsandcdin. -
Create
wordtools.py: -
Create
test_wordtools.py. Write parameterized tests for both: word_count:""→ 0,"hello"→ 1,"hello world"→ 2," many spaces here "→ 3.-
is_palindrome:""→ True,"a"→ True,"racecar"→ True,"hello"→ False,"Racecar"→ True (lowercase first). -
Run
pytest -v. All tests should pass. -
Break each function on purpose, watch the relevant test fail, fix it, watch it pass.
-
Stretch: add a function
most_common_word(s: str) -> str(returns the word appearing most). Usecollections.Counter. Write a parametrized test for it, including a tie-breaking case.
What you might wonder¶
"Where do tests live in real projects?"
Three common layouts:
- Next to the code (mathutils.py, test_mathutils.py in the same folder). Common for small projects.
- In a tests/ directory at the top level, mirroring the source layout. Common for medium-to-large projects.
- In src/<package>/ + tests/ (the "src layout"). The modern best-practice. Avoids a class of import bugs.
All three are valid. The README or pytest configuration tells you which a project uses.
"What about unittest, the stdlib framework?"
Older. More boilerplate (test classes, self.assertEqual). Some projects (especially within Python itself) use it. Recognize it; prefer pytest for new code.
"Should I write the test first or the code first?" Either works. Any tests are infinitely better than no tests. Start by writing the code, then writing a test. After a few months, try writing the test first sometimes; see which feels better.
"How much testing is enough?" A useful heuristic: every bug you fix gets a test that would have caught it. Every important code path has at least one test. Don't chase 100% coverage; chase confidence.
"What about mocking?"
Mocking means replacing real dependencies (databases, APIs) with fake ones during a test. The stdlib unittest.mock is the standard tool; pytest has pytest-mock as a nicer wrapper. Use sparingly - overuse leads to tests that pass on broken code.
Done¶
You can now:
- Install pytest and write tests in test_*.py files.
- Use assert with rich pytest error messages.
- Parametrize tests with @pytest.mark.parametrize.
- Share setup via fixtures (including tmp_path, monkeypatch, capsys).
- Test that exceptions are raised with pytest.raises.
- Drive pytest from the command line for fast iteration.
You can now verify your own code. More importantly, you can read the test files in any real Python project and understand what they're checking - that's most of what makes a real codebase legible.
Next page: how Python projects are organized into modules and packages, and how to use code other people wrote.
Next: Modules, packages, pip, venv →