Pytest · lesson 7 of 10
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.
By SkillByExample · Published
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.