Skip to main content
Pytest intermediate 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.

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.

Frequently Asked Questions

What is the difference between tmp_path and tmp_path_factory?
tmp_path is function-scoped and gives each test its own empty directory. tmp_path_factory is session-scoped, so it can be requested by a session or module fixture that needs to build an expensive directory once and share it.
Where do pytest temporary directories actually go?
Under the system temp directory, in a numbered folder such as /tmp/pytest-of-you/pytest-12/. pytest keeps the three most recent runs so you can inspect files from a failure, and deletes older ones. Override the location with --basetemp.
What is the difference between capsys and capfd?
capsys captures writes to Python's sys.stdout and sys.stderr objects. capfd captures at the file-descriptor level, so it also catches output from C extensions and subprocesses. Use capsys unless the output comes from outside Python.
Why is caplog empty when my code clearly logged something?
Usually the level. caplog captures at WARNING by default, so INFO and DEBUG records are dropped unless you raise the level with caplog.set_level or the at_level context manager. A logger configured with propagate = False also hides its records from caplog.