Chapter 21 of 24 · Python for Beginners — Complete Free Tutorial
Testing with pytest
In the Python for Beginners series you have been checking your code by running it and reading the screen — which means code you never test is code you do not really know. Chapter 21 of this 24-chapter course introduces pytest, the de facto standard test runner for Python: how tests are discovered, why its failure messages are the best in the business, and how fixtures, parametrised cases and mocks keep a suite fast and hermetic.
Why tests matter
A test pins behaviour. When a refactor breaks something you had forgotten about, the suite says so in seconds instead of production saying so later. The habit compounds: a run that takes a tenth of a second is a run you do after every change, and a suite you trust is one you dare to refactor with. Python’s ecosystem agrees — almost every serious package ships tests, and the same arrange-act-assert discipline appears across the site, including in Testing with TypeScript.
Installing pytest and writing tests
Inside your virtual environment (chapter 14), install pytest and confirm the version: python3 -m pip install pytest, then pytest --version prints something like pytest 9.1.1. Everything you are about to test lives in one tiny module:
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ZeroDivisionError("cannot divide by zero")
return a / b
def slugify(text):
return "-".join(text.lower().split())
pytest collects every file named test_*.py and every test_* function inside it — no classes or special methods required. Plain assert statements are the whole API, and pytest.raises documents the exceptions you expect:
import pytest
from calc import add, divide, slugify
def test_add():
assert add(2, 3) == 5
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(1, 0)
@pytest.mark.parametrize("text,expected", [("Hello World", "hello-world"), (" Python ", "python")])
def test_slugify(text, expected):
assert slugify(text) == expected
class TestDivision:
@pytest.fixture
def numbers(self):
return [10.0, 2.0]
def test_divides_cleanly(self, numbers):
assert divide(numbers[0], numbers[1]) == 5.0
Three structures appear here. Arrange, act, assert — set up, do one thing, check one thing — keeps every test readable. A class TestDivision groups related tests (their names still start with test_), and the @pytest.fixture method named numbers becomes setup injected into any test that asks for it, torn down afterwards. Run python3 -m pytest test_calc.py -v and every test above should come back green with a PASSED mark.
Failures that explain themselves
To see how pytest fails, write the wrong answer on purpose in test_fail_demo.py. Note there is no print anywhere:
from calc import add
def test_add_is_wrong():
assert add(2, 3) == 6
Run python3 -m pytest test_fail_demo.py. The > marks the failing line, and pytest’s assertion rewriting inspects your assert to print the intermediate values it actually found:
def test_add_is_wrong():
> assert add(2, 3) == 6
E assert 5 == 6
E + where 5 = add(2, 3)
test_fail_demo.py:4: AssertionError
You never printed 5 — pytest did, by rewriting the assertion before running it. Three switches shape the report: -v lists every test name instead of dots, -k slugify runs only the tests whose names match (collected 5 items / 3 deselected / 2 selected), and --tb=short trims the traceback to the failing frame and the assert. Delete this deliberately broken file before your first full run.
Isolating tests: tmp_path, monkeypatch and mocks
Good tests do not depend on your machine, your configuration or the network. pytest supplies a fresh tmp_path directory per test, monkeypatch swaps attributes and restores them automatically, and unittest.mock replaces whole objects with scripted stand-ins:
from unittest.mock import MagicMock
import greeting
from service import course_title
def test_writes_report(tmp_path):
report = tmp_path / "report.txt"
report.write_text("done", encoding="utf-8")
assert report.read_text(encoding="utf-8") == "done"
def test_greet_uses_prefix(monkeypatch):
monkeypatch.setattr(greeting, "PREFIX", "Hi")
assert greeting.greet("Ada") == "Hi, Ada!"
def test_course_title_uses_client():
client = MagicMock()
client.fetch.return_value = {"title": "Python"}
assert course_title(client) == "PYTHON"
client.fetch.assert_called_once_with("/courses/1")
The three tests rely on two tiny modules: greeting.py holds PREFIX = "Hello" and def greet(name): return f"{PREFIX}, {name}!", while service.py holds def course_title(client): return client.fetch("/courses/1")["title"].upper(). The mock stands in for an HTTP client, returns a canned payload, and then proves exactly how it was called.
Running the suite and reading the report
Delete test_fail_demo.py, then run python3 -m pytest -v. After a short header naming its version and root directory, pytest prints every test and a summary you can trust:
collected 8 items
test_calc.py::test_add PASSED [ 12%]
test_calc.py::test_divide_by_zero PASSED [ 25%]
test_calc.py::test_slugify[Hello World-hello-world] PASSED [ 37%]
test_calc.py::test_slugify[ Python -python] PASSED [ 50%]
test_calc.py::TestDivision::test_divides_cleanly PASSED [ 62%]
test_features.py::test_writes_report PASSED [ 75%]
test_features.py::test_greet_uses_prefix PASSED [ 87%]
test_features.py::test_course_title_uses_client PASSED [100%]
============================== 8 passed in 0.05s ===============================
Percentages show progress, the bracketed keys after test_slugify identify which parametrised case ran, and the summary line fails the command (exit status 1) whenever anything broke — which is what lets CI block a bad commit. Timings vary per machine; the counts and names are what matter.
Common mistakes
- Testing implementation instead of behaviour — asserting that a private method was called twice breaks on every harmless refactor. Assert the outcome a caller can see.
- Tests that depend on each other — running one test alone must work; shared mutable state and leftover files break the moment pytest reorders or skips cases.
- Forgetting teardown — never open files or network connections by hand in a test; fixtures like
tmp_pathandmonkeypatchrestore everything for you. - Catching every exception to pass a test —
pytest.raises(ZeroDivisionError)states the expected failure; a bareexceptwould happily hide the wrong ones too. - Running only the happy path — empty inputs, zero divisions and missing keys are where bugs live; parametrize the awkward cases alongside the nice ones.
Practice checklist
- Run the suite above verbatim and confirm the eight green lines appear.
- Add a
slugifycase with mixed capitals and tabs, and watch parametrize add a row. - Break one assertion on purpose, read the rewritten failure output, then fix it.
- Write a test using
tmp_paththat writes and re-reads a JSON file withjson.dump. - Use
python3 -m pytest -k divideto run only the division tests, then--tb=shorton a deliberate failure.
What's next?
Your code is now annotated and tested — the foundations of a serious Python project. Next comes a different way to run it: Async Python with asyncio explains how one thread can juggle many waiting tasks.
Frequently Asked Questions
Python is a general-purpose language first released in 1991, designed around one idea: code is read far more often than it is written. English keywords and indentation instead of braces make it readable, the standard library covers files, JSON, dates and testing, and pip adds thousands more packages. The same language handles scripts, web APIs, data work and machine learning.
None at all. Python is widely considered the friendliest first language: no declaration keywords, no braces, no build step, and error messages that name the problem. This series begins at your first print() call. If you already read JavaScript or TypeScript you will adjust quickly, but no previous language is assumed anywhere in these 24 chapters.
Python 3 only, any release from 3.12 onwards. Python 2 reached end of life in 2021 and its print "hi" statements and xrange() no longer run. One historical quirk remains: installers ship the interpreter as python3 on macOS and Linux, while Windows usually offers python or the py launcher, and every command in this series works with that one-word swap.
More than you might expect. Everyday automation such as renaming files, cleaning spreadsheets or scraping pages; web backends with Django, Flask or FastAPI; data analysis and machine learning with pandas, NumPy and PyTorch; DevOps tooling, desktop apps and games. Python is also the classic glue language, calling C, Rust or Fortran libraries from short readable scripts.
Pure Python can run 10 to 100 times slower than C, yet that rarely blocks you: heavy numeric work lives in C extensions such as NumPy, which Python coordinates. Dynamic typing does push errors to runtime, surfacing as a TypeError on the line that finally uses the bad value. Type hints checked by mypy move those mistakes back into your editor.
Only the interpreter. Install CPython from python.org, version 3.12 or newer, confirm it with python3 --version, and open a plain text editor; VS Code with the Python extension is the recommendation here. The early chapters use no third-party packages, so there is nothing else to set up until virtual environments and pip arrive.
A dict is a real runtime mapping: keys may be any hashable value rather than only strings, insertion order has been guaranteed since Python 3.7, and a missing key raises KeyError unless you ask with get(). JavaScript objects coerce every key to a string and carry a prototype chain. A TypeScript interface is erased at compile time and describes shape only; TypedDict is Python's checked equivalent.
Reach for a list when items will be added, removed or reordered: it is ordered, mutable and rich with methods. Pick a tuple for fixed records and function returns, because it is immutable and hashable, so it can be a dictionary key. Choose a set for unique values and fast membership checks; it has no order and de-duplicates on creation.
Yes, gradually. Annotate public function signatures first; the interpreter ignores hints at runtime, so a checker such as mypy is what gives them teeth. Add tests the same way: pytest collects any test_*.py file and plain assert statements, and a suite that finishes in a tenth of a second is one you will run after every change. Both habits cost little now and much more later.
JavaScript and TypeScript keep the browser, so anything rendered to the user stays theirs. Python takes the server: Django for full-featured applications, Flask for small ones, FastAPI for typed async APIs where your annotations become automatic documentation. Your TypeScript habits transfer directly, and asyncio mirrors the event loop you already know, just with different keywords.