The standard library ships with unittest, and there are still serious projects that use it. But in 2026, if you git clone a random Python repo and look in tests/, you will find pytest. Every major framework, every cloud SDK, every internal tool I’ve touched in the last five years — pytest. The reason is simple: pytest lets you write a test as a plain function with a plain assert, and gives you a fixture system that handles setup without inheritance hierarchies.
This lesson is the start of Module 4. We’re going to write tests that other humans (and your future self) can actually read.
Why not unittest
unittest is modeled on Java’s JUnit. It came to Python in the early 2000s, and it shows. A unittest test is a class that inherits from TestCase, with methods named test_*, special setUp / tearDown hooks, and a custom assertion API:
import unittest
class TestMath(unittest.TestCase):
def setUp(self):
self.numbers = [1, 2, 3]
def test_sum(self):
self.assertEqual(sum(self.numbers), 6)
def test_max(self):
self.assertGreater(max(self.numbers), 2)
def tearDown(self):
self.numbers = None
Same thing in pytest:
def test_sum():
assert sum([1, 2, 3]) == 6
def test_max():
assert max([1, 2, 3]) > 2
No class, no inheritance, no self.assertEqual. Just functions and assert. When an assertion fails, pytest rewrites the assert statement at import time so that the failure message shows the actual values without you having to write a custom message:
> assert sum([1, 2, 3]) == 7
E assert 6 == 7
E + where 6 = sum([1, 2, 3])
That introspection — built into the assert rewriter — is the single biggest quality-of-life win over unittest.
Installing and finding tests
Add it as a dev dependency:
uv add --dev pytest
By convention tests live in a top-level tests/ directory:
my_project/
pyproject.toml
src/
my_package/
__init__.py
core.py
tests/
test_core.py
test_helpers.py
pytest discovers tests by walking the working directory looking for files named test_*.py or *_test.py, then collecting functions named test_* (and methods of classes named Test* that don’t have an __init__). You can override every part of that in pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-ra --strict-markers"
-ra shows a short summary of all non-passing outcomes at the end. --strict-markers errors out if you use a marker you haven’t declared — useful for catching typos.
Running tests
The full suite:
pytest
A single file:
pytest tests/test_core.py
A single test function (the :: syntax is pytest’s “node id”):
pytest tests/test_core.py::test_parse_url
The hugely useful -k flag, which selects tests by substring match against the test name:
pytest -k "url and not slow"
Stop at the first failure when you’re debugging:
pytest -x
Show prints from your code (pytest captures stdout by default):
pytest -s
Verbose mode shows each test name as it runs:
pytest -v
Combine these freely. My day-to-day is pytest -x -v while iterating, then pytest for a clean full run before committing.
Writing a useful assertion
assert is just assert. Anything that’s truthy passes; anything falsy fails. Because the assert rewriter unpacks the expression, you almost never need to write a custom message:
def test_user_creation():
user = User(name="Ada", age=36)
assert user.name == "Ada"
assert user.age == 36
assert user.is_adult
For floats, use pytest.approx:
import pytest
def test_compound_interest():
assert compound(100, 0.05, 10) == pytest.approx(162.89, rel=1e-3)
For exceptions, pytest.raises:
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_invalid_email():
with pytest.raises(ValueError, match="invalid email"):
validate_email("not-an-email")
The match= argument runs re.search against the exception message, which lets you assert which ValueError you got without writing brittle string equality.
For warnings, the parallel pattern is pytest.warns(DeprecationWarning).
Fixtures: the dependency injection trick
Most real tests need something: a database connection, a temp directory, a fake HTTP client, a parsed config. In unittest you wrote setUp. In pytest you write a fixture and any test that wants it lists it as a parameter.
import pytest
@pytest.fixture
def sample_user():
return User(name="Ada", age=36)
def test_user_name(sample_user):
assert sample_user.name == "Ada"
def test_user_is_adult(sample_user):
assert sample_user.is_adult
When pytest sees sample_user in a test’s parameter list, it looks up the fixture of that name, calls it, and passes the return value in. That’s it. The mechanism scales from one fixture to fifty.
A fixture can do setup and teardown using yield:
@pytest.fixture
def db_connection():
conn = connect("postgresql://localhost/test")
conn.execute("BEGIN")
yield conn
conn.execute("ROLLBACK")
conn.close()
Everything before yield runs before the test. Everything after runs when the test finishes — even if it failed. This is the cleanest replacement for setUp / tearDown you’ll find.
Fixture scope
By default a fixture is recreated for every test that uses it. For expensive setup that’s wasteful. Scope controls reuse:
@pytest.fixture(scope="session")
def docker_postgres():
container = start_postgres()
yield container
container.stop()
@pytest.fixture(scope="function")
def db(docker_postgres):
"""A clean transaction per test, on a single shared container."""
conn = docker_postgres.connect()
conn.execute("BEGIN")
yield conn
conn.execute("ROLLBACK")
The four scopes are function (default), class, module, and session. The combination above — session-scoped infrastructure with function-scoped transactions — is the standard pattern for Postgres tests. The container starts once for the whole test run; each individual test gets a clean rolled-back transaction.
Composing fixtures
Fixtures can request other fixtures. pytest builds a dependency graph and instantiates them in the right order:
@pytest.fixture
def settings():
return Settings(debug=True, db_url="sqlite:///:memory:")
@pytest.fixture
def app(settings):
return create_app(settings)
@pytest.fixture
def client(app):
return TestClient(app)
def test_root(client):
assert client.get("/").status_code == 200
The test asks for client. pytest sees client needs app, app needs settings. It builds them bottom-up and passes client to the test. You only ever ask for what you actually need.
Built-in fixtures worth knowing
pytest ships with a handful you’ll use constantly. Two are essential.
tmp_path gives you a fresh pathlib.Path directory unique to the test:
def test_writes_file(tmp_path):
target = tmp_path / "out.txt"
write_report(target, data=[1, 2, 3])
assert target.read_text().startswith("Report")
The directory is cleaned up automatically. No more dance with tempfile.TemporaryDirectory() and try/finally.
monkeypatch patches attributes, environment variables, and sys.path for the duration of a single test:
def test_uses_env_var(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-123")
assert load_config().api_key == "test-key-123"
def test_swap_function(monkeypatch):
monkeypatch.setattr("my_package.core.fetch", lambda url: {"ok": True})
assert run_pipeline() == [{"ok": True}]
It undoes everything when the test finishes, so you can’t accidentally leak state into the next test.
Other good ones: capsys (capture stdout/stderr), caplog (capture log records), request (introspect the test that’s currently running).
Skipping and expected failures
Sometimes a test only makes sense under certain conditions:
import sys
import pytest
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only")
def test_unix_socket():
...
@pytest.mark.skipif(
sys.version_info < (3, 13),
reason="needs PEP 695 type aliases",
)
def test_new_syntax():
...
xfail says “this is expected to fail right now, don’t make CI red, but tell me when it starts passing”:
@pytest.mark.xfail(reason="upstream bug, fix tracked in #1234")
def test_known_bug():
assert call_buggy_thing() == 42
When the underlying bug gets fixed, the test starts passing, pytest reports it as XPASSED, and you remove the marker. It’s a built-in TODO list.
Where to go next
You now know enough to write meaningful tests for most code. Lesson 20 covers the three patterns that turn the rest of testing from “impossible” to “one paragraph”: parametrize for tables of cases, mocks for external systems, and conftest.py for sharing fixtures across files.
The pytest docs at docs.pytest.org are unusually good — when you hit something this lesson didn’t cover, check there first.
Citations: pytest documentation, retrieval 2026-05-01.