Testing Files, Output, and Logs with Pytest
Use tmp_path for real files that clean themselves up, capsys to assert on printed output, and caplog to test the log lines your code emits.
Some code has no return value worth asserting on. It writes a file, prints a summary, or logs a warning. pytest has a fixture for each of those effects.
The code under test
# report.py
import csv
import logging
from pathlib import Path
log = logging.getLogger(__name__)
def write_report(rows, dest: Path) -> Path:
dest.parent.mkdir(parents=True, exist_ok=True)
written = 0
with dest.open("w", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["sku", "qty"])
for sku, qty in rows:
if qty == 0:
log.warning("skipping %s: zero quantity", sku)
continue
writer.writerow([sku, qty])
written += 1
log.info("wrote %d rows to %s", written, dest)
print(f"{written} rows -> {dest.name}")
return dest
Three side effects in fifteen lines: a file, a log record, a printed line. Each gets its own fixture.
tmp_path
# test_report.py
from report import write_report
def test_writes_a_header_and_rows(tmp_path):
dest = write_report([("A1", 2), ("B7", 1)], tmp_path / "orders.csv")
assert dest.exists()
assert dest.read_text().splitlines() == ["sku,qty", "A1,2", "B7,1"]
$ pytest -q
. [100%]
1 passed in 0.02s
tmp_path is a pathlib.Path to an empty directory created for this test alone. No
tearDown, no try/finally, and no chance of two tests fighting over /tmp/test.csv.
Print it to see where it went:
def test_where_does_it_go(tmp_path):
print(tmp_path)
$ pytest -s -q test_report.py::test_where_does_it_go
/tmp/pytest-of-you/pytest-12/test_where_does_it_go0
.
1 passed in 0.01s
The directory name is derived from the test name, and pytest keeps the three most recent
runs before deleting them. That matters after a failure: the file the test wrote is still on
disk, and you can open it. Point the base somewhere else with --basetemp=/var/tmp/mytests
if you need it outside /tmp.
Directories work the same way:
def test_creates_missing_parents(tmp_path):
dest = tmp_path / "2026" / "03" / "orders.csv"
write_report([("A1", 2)], dest)
assert dest.parent.is_dir()
assert (tmp_path / "2026").exists()
$ pytest -q
. [100%]
1 passed in 0.02s
That is a genuine test of mkdir(parents=True) against a real filesystem — cheaper to write
than mocking Path.mkdir, and it actually proves the behaviour.
Sharing a directory across tests
A function-scoped fixture cannot be used by a session-scoped one. tmp_path_factory is the
session-scoped counterpart:
# conftest.py
import pytest
@pytest.fixture(scope="session")
def sample_catalogue(tmp_path_factory):
"""Built once; every test reads it."""
path = tmp_path_factory.mktemp("catalogue") / "skus.csv"
path.write_text("sku,name\nA1,Widget\nB7,Gadget\n")
return path
def test_catalogue_has_two_rows(sample_catalogue):
assert len(sample_catalogue.read_text().splitlines()) == 3
def test_catalogue_is_the_same_file(sample_catalogue):
assert sample_catalogue.name == "skus.csv"
$ pytest -q
.. [100%]
2 passed in 0.02s
mktemp("catalogue") returns a fresh directory named catalogue0, catalogue1, and so on.
Keep session-scoped files read-only — one test that writes to a shared file makes every later
test depend on the run order.
capsys
def test_prints_a_summary(tmp_path, capsys):
write_report([("A1", 2), ("B7", 0)], tmp_path / "orders.csv")
out, err = capsys.readouterr()
assert out == "1 rows -> orders.csv\n"
assert err == ""
$ pytest -q
. [100%]
1 passed in 0.02s
readouterr() returns everything captured since the last call and resets the buffer, so you
can assert in stages:
def test_capture_resets_between_reads(capsys):
print("first")
assert capsys.readouterr().out == "first\n"
print("second")
assert capsys.readouterr().out == "second\n"
$ pytest -q
. [100%]
1 passed in 0.01s
Forgetting the reset is the usual surprise — the second assertion fails with both lines if you only read once at the end.
capsys hooks sys.stdout, so it cannot see output from a subprocess or a C extension
writing to file descriptor 1. capfd captures at that lower level:
import subprocess
def test_subprocess_output(capfd):
subprocess.run(["echo", "hello from a child"], check=True)
assert "hello from a child" in capfd.readouterr().out
$ pytest -q
. [100%]
1 passed in 0.03s
Swap capfd for capsys in that test and it fails with an empty string. Same API, different
layer.
caplog
import logging
def test_zero_quantity_is_warned(tmp_path, caplog):
write_report([("A1", 0), ("B7", 3)], tmp_path / "orders.csv")
assert "skipping A1: zero quantity" in caplog.text
assert caplog.records[0].levelname == "WARNING"
$ pytest -q
. [100%]
1 passed in 0.02s
caplog.text is the formatted output; caplog.records holds the actual LogRecord objects,
which is what you want for anything precise:
def test_log_record_details(tmp_path, caplog):
write_report([("A1", 0)], tmp_path / "orders.csv")
record = caplog.records[0]
assert record.levelno == logging.WARNING
assert record.getMessage() == "skipping A1: zero quantity"
assert record.name == "report"
$ pytest -q
. [100%]
1 passed in 0.02s
Asserting on record.args or getMessage() rather than the formatted string keeps the test
alive when someone reformats the message.
The level trap
The log.info("wrote %d rows…") call is invisible by default:
def test_logs_the_row_count(tmp_path, caplog):
write_report([("A1", 2)], tmp_path / "orders.csv")
assert "wrote 1 rows" in caplog.text
$ pytest -q
> assert "wrote 1 rows" in caplog.text
E AssertionError: assert 'wrote 1 rows' in ''
1 failed in 0.02s
Empty, because capture starts at WARNING. Raise it for the block you care about:
def test_logs_the_row_count(tmp_path, caplog):
with caplog.at_level(logging.INFO):
write_report([("A1", 2)], tmp_path / "orders.csv")
assert "wrote 1 rows" in caplog.text
$ pytest -q
. [100%]
1 passed in 0.02s
caplog.set_level(logging.INFO) does the same for the rest of the test; the context manager
is better because it narrows the window. Restrict it to one logger with
caplog.at_level(logging.DEBUG, logger="report") when a chatty library floods the capture.
If caplog is still empty, check whether the application configured that logger with
propagate = False — pytest attaches its handler to the root logger, and a non-propagating
logger never reaches it.
Captured output on failure
You do not have to assert on any of it to benefit. Break a test and pytest prints what was captured:
_________________________ test_writes_a_header_and_rows _________________________
> assert dest.read_text().splitlines() == ["sku,qty", "A1,2", "B7,1"]
E AssertionError: assert ['sku,qty', 'A1,2'] == ['sku,qty', 'A1,2', 'B7,1']
E Right contains one more item: 'B7,1'
--------------------------- Captured stdout call --------------------------------
1 rows -> orders.csv
----------------------------- Captured log call ---------------------------------
WARNING report:report.py:18 skipping B7: zero quantity
INFO report:report.py:23 wrote 1 rows to /tmp/pytest-of-you/pytest-12/…/orders.csv
The log line explains the assertion. This is the strongest argument for logging inside the
code rather than sprinkling print while debugging: the information is there when a CI run
fails at 3am, and hidden the rest of the time.
Warnings
pytest.warns is the pytest.raises of the warnings system:
import pytest
def test_old_alias_warns(tmp_path):
with pytest.warns(DeprecationWarning, match="use write_report"):
report.write_csv([("A1", 2)], tmp_path / "orders.csv")
$ pytest -q
. [100%]
1 passed in 0.01s
And if nothing warns:
E Failed: DID NOT WARN. No warnings of type (<class 'DeprecationWarning'>,) were emitted.
Pair it with filterwarnings = ["error::DeprecationWarning"] in your config: warnings you
have not explicitly asserted on become failures, so deprecations get dealt with while there
is still time.
Practice
1. Write a test that asserts a report file is not created when the row list is empty.
def test_empty_input_still_writes_a_header(tmp_path):
dest = write_report([], tmp_path / "orders.csv")
assert dest.read_text() == "sku,qty\r\n"
. [100%]
1 passed in 0.02s
The \r\n is csv.writer’s default line terminator, which a test against a real file
catches immediately and a mocked file object hides.
2. Call capsys.readouterr() twice without printing in between.
> assert capsys.readouterr().out == "first\n"
E AssertionError: assert '' == 'first\n'
The buffer was drained by the first call. Capture is a stream, not a running total.
3. Assert on an INFO log without raising the level.
E AssertionError: assert 'wrote 1 rows' in ''
An empty caplog.text almost always means the level, not a missing log call. Wrap the call
in caplog.at_level(logging.INFO).
4. Run a failing test and find its tmp_path directory on disk afterwards.
$ ls /tmp/pytest-of-you/pytest-12/test_writes_a_header_and_r0/
orders.csv
$ cat /tmp/pytest-of-you/pytest-12/test_writes_a_header_and_r0/orders.csv
sku,qty
A1,2
The artefact survives the run. Reading the file the test actually produced usually beats adding print statements and running again.
Next: measuring which lines your tests never reach.