Learning Goals
3 min- Use
yieldin a fixture for setup + teardown in one place. - Control how often a fixture runs with
scope. - Use built-in fixtures:
tmp_path,monkeypatch,capsys. - Choose the right scope for speed vs isolation.
Warm-Up · Setup, then Teardown
5 minimport pytest @pytest.fixture def db(): con = sqlite3.connect(":memory:") # SETUP (before the test) init_schema(con) yield con # ← the test runs here, gets 'con' con.close() # TEARDOWN (after the test)
Everything before yield is setup; the value yielded is what the test receives; everything after yield is teardown — and it runs even if the test fails. One fixture, both halves, no separate tearDown.
New Concept · yield, scope, built-ins
14 minyield = setup + teardown
@pytest.fixture def temp_file(): f = open("scratch.txt", "w") yield f # test uses f f.close() os.remove("scratch.txt") # always cleans up
scope — how often does it run?
scope="function" (default) fresh for EVERY test → max isolation scope="class" once per test class scope="module" once per .py file scope="session" once for the whole test run → max speed
@pytest.fixture(scope="session") def big_dataset(): return load_expensive_data() # loaded ONCE for the whole run
Wider scope = faster (built once) but riskier (shared). Use session/module only for read-only or expensive things; keep mutable state at function scope.
Built-in fixture: tmp_path
def test_write_log(tmp_path): # tmp_path is a fresh temp directory, auto-deleted after the test log = tmp_path / "app.log" write_log(log, "hello") assert log.read_text() == "hello\n"
No manual temp-file management — pytest creates a unique dir per test and cleans it up.
Built-in fixture: monkeypatch
def test_uses_env(monkeypatch): monkeypatch.setenv("API_KEY", "test-key") # set an env var monkeypatch.setattr("mymod.time.time", lambda: 1000) # patch a function assert get_config()["key"] == "test-key" # everything is automatically restored after the test
Built-in fixture: capsys
def test_prints_greeting(capsys): greet("Aisyah") captured = capsys.readouterr() assert "Hi Aisyah" in captured.out # check what was printed
Worked Example · Scoped DB + tmp_path
12 min# tests/conftest.py import pytest, sqlite3 # expensive, read-only reference data → build once per session @pytest.fixture(scope="session") def country_codes(): return load_country_codes() # pretend this is slow # the DB must be fresh per test → function scope (default), with teardown @pytest.fixture def db(): con = sqlite3.connect(":memory:") init_schema(con) yield con con.close()
# tests/test_export.py def test_export_creates_file(db, tmp_path): add_sample_rows(db) out = tmp_path / "export.csv" export_to_csv(db, out) assert out.exists() assert "Aisyah" in out.read_text() def test_country_lookup(country_codes): assert country_codes["MY"] == "Malaysia" # uses the session fixture
$ pytest tests/ -v test_export_creates_file PASSED # fresh db + temp dir, both cleaned up test_country_lookup PASSED # reuses the session-scoped data 2 passed
Read the diff
Two scopes working together: country_codes loads once (session) because it's read-only and slow; db is fresh per test (function) because tests mutate it. tmp_path gives a throwaway directory with zero cleanup code. This is the real-world fixture toolkit — fast where safe, isolated where it matters.
Try It Yourself
13 minWrite a fixture that opens a resource, yields it, then closes it. Make a test fail and confirm teardown still ran.
Test a function that writes a file, using tmp_path. Confirm no file is left behind after the run.
Test a function that depends on datetime.now() by monkeypatching it to a fixed time, so the test is deterministic.
Hint
def test_greeting_by_time(monkeypatch): import mymod monkeypatch.setattr(mymod, "current_hour", lambda: 9) assert mymod.greeting() == "Good morning"
Mini-Challenge · Scope Experiment
8 minWrite a fixture that prints when it runs. Make 3 tests use it. Run with scope="function" then scope="module" (use -s to see prints). Count how many times it ran in each. Explain the isolation trade-off.
Show the experiment
import pytest @pytest.fixture(scope="function") # try "module" too def counter(): print("\n>> fixture ran") return {"n": 0} def test_a(counter): counter["n"] += 1; assert counter["n"] == 1 def test_b(counter): counter["n"] += 1; assert counter["n"] == 1 # ! def test_c(counter): counter["n"] += 1; assert counter["n"] == 1
function scope: fixture runs 3× (each test sees n==1 → all pass). module scope: runs 1× (shared dict, n grows → b and c FAIL). That failure IS the isolation lesson.
Recap
3 minyield splits a fixture into setup (before) and teardown (after, even on failure). scope controls frequency: function (fresh, isolated) up to session (once, fast). Built-ins save work: tmp_path for temp dirs, monkeypatch for env/attrs, capsys for captured output. Keep mutable state function-scoped. Next: parametrize for table-driven tests.
Vocabulary Card
- yield fixture
- A fixture with teardown code after a
yield. - scope
- How often a fixture is created: function / class / module / session.
- tmp_path
- Built-in fixture giving a unique temp directory per test.
- monkeypatch
- Built-in fixture to safely replace attrs/env during a test, auto-restored.
Homework
4 minWrite a suite using: a yield fixture with teardown, one wider-scoped read-only fixture, tmp_path for a file test, and monkeypatch to make a time- or env-dependent function deterministic. All tests green and leaving no temp files behind.
Combine the conftest yield-db fixture, a session-scoped reference fixture, tmp_path, and a monkeypatch clock test from the exercises.