Use explicit pytest fixtures, fresh state, reliable teardown, temporary paths, fixture factories, captured streams, and captured logs to make failures reproducible and independent.
You will learn: Construct an explicit fresh test world, compose and tear down fixtures, isolate file work with tmp_path, and assert terminal or log evidence without leaking state.
Practice in: A local pytest project where fixture setup, teardown, temporary paths, capture, and logs can be observed
Meteor Watch can now classify observations. The next behavior writes an alert report. Testing it requires a directory, sample observations, and perhaps a captured message. If every test creates those resources by hand, important arrangement is repeated. If one global object is reused, mutations can make the suite order-dependent.
Pytest fixtures provide requested dependencies. A test names what it needs in its arguments; pytest constructs that test world, passes values in, and manages cleanup. Fixtures should make dependencies visible and isolation reliable—not hide the scenario that explains an expected result.
Keep these questions in view:
Which setup belongs in a reusable fixture and which values should stay in the test?
When does a fixture return a value, and when does it need teardown after yield?
How do fixture dependencies determine setup and reverse teardown order?
How do tmp_path, capsys, and caplog expose real boundaries safely?
Which signs reveal shared state, an overly broad scope, or a fixture that owns too much behavior?
1. Request a fresh value by naming the fixture
Begin with a small dataclass and report function:
from dataclasses import dataclassfrom pathlib import Path@dataclassclass Observation: station: str wind_kph: float risk: strdef write_alert_report(path, observations):"""Write one pipe-delimited alert per line and return the line count.""" lines = [f"{item.station}|{item.wind_kph:g}|{item.risk}"for item in observations ] path.write_text("\n".join(lines) +"\n", encoding="utf-8")returnlen(lines)
def test_observations_fixture_supplies_two_records(observations):assert [item.station for item in observations] == ["ridge-7","lake-2", ]
Pytest sees the argument, finds the fixture, calls it before the test, and passes its returned list. The fixture is not called manually. Calling observations() from a test bypasses pytest’s dependency and lifecycle management.
The default scope is function: pytest calls the fixture separately for each test. This matters for mutable values:
Both tests pass in either order because each receives a newly constructed list. If observations were a module-level list returned repeatedly from a module-scoped fixture, the first mutation could leak.
Keep behavior-defining values in the test
This fixture hides the exact wind and expected label:
Use fixtures for environmental setup, costly construction, or shared shape. Keep the values that explain a test’s expected behavior near the assertion.
2. Read the fixture dependency graph
Fixtures can request other fixtures:
@pytest.fixturedef red_observation():return Observation("ridge-7", 72, "red")@pytest.fixturedef report_path(tmp_path):return tmp_path /"alerts.txt"@pytest.fixturedef written_report(report_path, red_observation): write_alert_report(report_path, [red_observation])return report_pathdef test_written_report_contains_station(written_report): text = written_report.read_text(encoding="utf-8")assert"ridge-7"in text
The test asks only for written_report. Pytest follows the dependency graph to report_path, tmp_path, and red_observation. Names describe resources; pytest determines a valid setup order from dependencies, scope, and request order rules. Do not rely on incidental definition order.
The requested fixture makes its dependencies visible. Teardown later proceeds in the reverse direction for fixtures that yielded resources.
flowchart TD
A["test_written_report_contains_station"] --> B["written_report"]
B --> C["report_path"]
C --> D["tmp_path"]
B --> E["red_observation"]
D --> F["temporary directory"]
E --> G["fresh Observation"]
Some resources need an action after the test. Code before yield is setup; the yielded value goes to the test; code after yield is teardown:
import osimport pytest@pytest.fixturedef temporary_working_directory(tmp_path): original = Path.cwd() os.chdir(tmp_path)yield tmp_path os.chdir(original)
This example is educational, but pytest’s monkeypatch.chdir is safer and will be introduced in Lesson 5. It automatically restores the original directory. The important structure is that a fixture which changes process state also owns its restoration.
Teardown runs even if the test assertion fails after the fixture yields. It cannot undo setup that failed before yield, so keep risky state-changing actions small and pair each with the nearest cleanup. A context manager is often even clearer when the resource already supports one.
Prove teardown rather than assume it
Create an event list that a second fixture can inspect:
To assert after teardown, a fixture or hook with a broader observation point is needed. For ordinary course code, --setup-show plus resource state after the test process is often enough. Do not contort production behavior merely to assert pytest’s own tested lifecycle.
Teardown belongs to the fixture that performed setup and happens after the test call, even when the call is red.
flowchart LR
A["fixture setup"] --> B["yield resource"] --> C["test call"]
C -->|"pass"| D["fixture teardown"]
C -->|"failure"| D
D --> E["resource restored"]
4. Choose the narrowest useful fixture scope
Fixtures support function, class, module, package, and session scopes. Broader scope can reduce expensive repeated setup, but it also expands shared state and the distance between cause and failure.
An immutable catalog is a reasonable session value. A mutable list of current alerts is not: one test can change what every later test sees. Start with function scope. Widen only after measurement shows setup cost matters and the resource has an explicit sharing/isolation contract.
Scope dependencies also move in one direction: a broader-scoped fixture cannot depend on a narrower function-scoped value whose lifetime ends sooner. If pytest reports ScopeMismatch, redesign the resource lifetimes rather than randomly changing scope strings.
5. Put shared fixtures near their consumers
Pytest discovers fixtures in test modules, parent conftest.py files, and plugins. A fixture used by tests in one file can stay in that file. A fixture shared by one test package can move to the nearest tests/conftest.py:
Avoid a giant root conftest.py with unrelated application behavior. Hidden fixture availability makes it difficult to know where a value came from. Search should lead from the requesting test to the nearest owner.
6. Use tmp_path for a real isolated filesystem boundary
tmp_path is a built-in function-scoped fixture that supplies a unique pathlib.Path directory for each test. It lets production file code use real filesystem operations without writing into the repository or sharing output names.
The test owns the directory, chooses the filename, uses the production writer, and reads actual UTF-8 bytes through Path. This is an integration with the local filesystem, but it remains fast and deterministic.
Prove paths are different
Do not assert an exact temporary directory name; it varies. Assert properties that matter:
A later test receives another path. Pytest retains several recent temporary roots to aid debugging, so tests should not assume the directory is deleted at the exact end of the call. Isolation—not immediate deletion—is the public fixture promise relevant here.
Use tmp_path_factory when a genuinely expensive immutable file should be created once for a broader scope. Most beginner file tests should start with tmp_path because its per-test isolation is easier to trust.
7. Capture stdout and stderr with capsys
Suppose the application prints a short completion message:
readouterr() returns the output captured so far and resets the buffers for the next interval:
def test_two_announcements_can_be_checked_separately(capsys): announce_report(1) first = capsys.readouterr() announce_report(2) second = capsys.readouterr()assert first.out =="Wrote 1 alert\n"assert second.out =="Wrote 2 alerts\n"
capsys captures Python-level text streams. capfd captures operating-system file descriptors and can observe output from some subprocesses or extension code. Choose the narrowest fixture that matches the actual output boundary.
Pytest captures output by default and displays it with a failure. capsys is for asserting that output as public behavior or separating phases—not for printing more debug noise and hoping it appears.
8. Capture logging records with caplog
Logs are structured diagnostic records, not merely stderr text. Test their level and message through caplog:
Avoid asserting timestamps or default renderer punctuation unless the formatted log line itself is the external contract. Records expose stable fields. If the application removes or replaces handlers during a test, caplog may no longer see records; treat logging configuration as application-boundary behavior from Unit 12.
Order-dependent failures often appear only in the full suite. Look for:
a module or class variable mutated by a test;
a fixture whose scope is broader than its mutable value;
a temporary path stored globally and reused;
a working-directory or environment change without restoration;
a fixture that returns the same object repeatedly;
a cached production function whose cache is not part of the arrangement; or
a test that calls another test instead of arranging its own state.
Use --setup-show to inspect lifecycle and run each failed node alone. If a node passes alone but fails after another node, run the two in that order and inspect what the first changes. Do not add sleeps or depend on a preferred order.
Change it to default function scope, or return an immutable value and let each test create its own list. The correct choice follows the application contract: if shared history is not what you are testing, do not share it.
10. Give every alert a clean workspace
Create a final lab around write_alert_report, announce_report, and log_rejected_station. Your suite must:
define an observation_factory fixture with useful defaults;
prove two tests receive fresh mutable observation lists;
write and read a UTF-8 report through tmp_path;
prove two separately requested temporary paths are not assumed to match;
capture exact singular and plural stdout without asserting unrelated output;
capture one warning’s logger name, level, and message;
include one yield fixture for a small resource that records open/close events;
deliberately create the module-scoped list leak, reproduce it, and repair it by correcting ownership or scope; and
retain one --setup-show excerpt that explains setup and teardown order.
Run the report file alone, then the complete suite in a new process. A passing preferred order is not sufficient evidence.
Hint 1: let the factory vary only meaningful fields
Return an inner build function with defaults for station, wind, and risk. Each call should construct a new Observation; do not keep one instance in the outer fixture.
Hint 2: read captured streams immediately after the action
Call captured = capsys.readouterr() after each announcement. Assert captured.out and captured.err separately.
Hint 3: pair state-changing setup with its teardown
Append "open" before yield and "close" after it. Use --setup-show or a broader event observer to verify the sequence without making a later test depend on shared mutable history.
Show the central file and capture checks