Chapter 09

Built-in fixtures — tmp_path, capsys and caplog

The fixtures pytest ships for the awkward parts of testing: files, printed output and logs. Test code that writes CSV and JSON without touching real paths, and check what it prints and logs.

45 minPython 3.12
  1. 1Encounter
  2. 2Understand
  3. 3Worked
  4. 4Predict
  5. 5Apply
  6. 6Stretch

The problem we are solving

Here is a function that writes a CSV file, and a test for it:

python
import csv


def write_csv(path, rows):
    with open(path, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["product", "quantity"])
        writer.writerows(rows)


def test_write_csv():
    write_csv("report.csv", [("pen", 12), ("bag", 2)])

    with open("report.csv") as f:
        assert f.read().splitlines()[1] == "pen,12"
text
$ pytest -q
.                                                                        [100%]
1 passed in 0.08s
$ ls
report.csv  test_report.py

The test passed, and it left something behind. report.csv now sits in your project, next to your code. Run pytest from a different folder and the file lands there instead, because "report.csv" is relative to wherever you happened to be standing. Two tests that both write report.csv read each other's leftovers. And if your project already had a real report.csv, the test just overwrote it.

A test should leave the world exactly as it found it. Files are the obvious case, but printed output and log messages raise the same question from the other side: the function sends something out, and the test needs to catch it.

The last two chapters showed you how to write fixtures. This one is about the fixtures pytest already ships for these awkward parts — you only have to ask for them by name.

By the end of this chapter you can

  • Give every test its own empty folder with tmp_path, and test functions that write CSV or JSON
  • Share one temporary folder across a session with tmp_path_factory
  • Test what a program prints with capsys, and know when capfd is needed instead
  • Test what a program logs with caplog, including messages below WARNING
  • Use request.node.name to find out which test is running
  • Read the errors these fixtures produce when they are misused

Prerequisites: fixture scope and conftest.py.


Before you write the test

Code that touches files, the terminal or the log has side effects: its real result is something it leaves in the world, not only what it returns. So before any test code, write down where each effect goes.

1. Name every output. For a function that exports stock rows, there are up to four:

  • the return value (the path it wrote)
  • the file's contents
  • what it prints, split into standard output and standard error
  • what it logs, and at which level

Each one is a separate promise, and each needs a way to be observed. The return value you get for free. For the other three, pytest has a fixture: tmp_path for files, capsys for printed text, caplog for logs.

2. Make sure the code lets a test choose where files go. This is the question to ask first, because if the answer is no, no fixture can save you. A function that builds its own path — open("/var/reports/stock.csv", "w") — can only be tested by writing to that real place. A function that takes the folder or path as a parameter can be pointed at tmp_path. If you own the code, change it to take the path. (When you cannot change it, the next chapter's monkeypatch is the escape hatch.)

3. Check your setup. Nothing to install — every fixture in this chapter ships with pytest. You need the usual: a virtual environment with pytest in it, and the code importable from the test file. Know your logger's name (logging.getLogger("export")) because caplog.set_level can target it.

4. Plan the cases. For export_stock(rows, out_dir), which writes valid rows to stock.csv, warns about negative quantities and prints a summary:

| Case | Input | Expected | |---|---|---| | Happy path | [("pen", 12), ("eraser", 30)], tmp_path | file has header + 2 rows; prints wrote 2 rows to stock.csv | | Invalid row | one row with quantity -1 | row left out; one WARNING naming it | | Missing folder | tmp_path / "exports" / "2026" | folders created; file inside | | Nothing valid | [("bag", -1)] | header only; nothing exported on standard error | | Clean output | any valid rows | nothing on standard error |

5. Decide what not to test. Do not test that Python's csv module quotes commas correctly, or that json.dumps produces valid JSON — those are someone else's promises, already tested. Do not assert the full caplog.text, which includes file names and line numbers that change whenever you edit the code. And do not test the exact location of tmp_path; it is different on every machine. Test your decisions: which rows are kept, what goes where, what the messages say.

The rest of the chapter gives you the tools for each column of that table, and the complete example implements it.


tmp_path — a fresh folder for every test

Ask for tmp_path and pytest hands your test a directory that did not exist a moment ago:

python
def test_first(tmp_path):
    print(tmp_path)
    print(type(tmp_path))


def test_second(tmp_path):
    print(tmp_path)
text
$ pytest -q -s
/tmp/pytest-of-you/pytest-0/test_first0
<class 'pathlib.PosixPath'>
./tmp/pytest-of-you/pytest-0/test_second0
.
2 passed in 0.07s

(-s turns off pytest's own output capture so the print calls reach the terminal. On Windows the path starts somewhere under your user's Temp folder and the type is WindowsPath.)

Three things to read in that output:

  • It is a pathlib.Path, not a string. You join with /, read with .read_text(), and check with .exists().
  • Each test gets its own folder, named after the test. test_first0 and test_second0 never meet, so both can write a file called report.csv.
  • Every run gets a new base folder: pytest-0, then pytest-1, and so on. pytest keeps the folders of the last three runs so you can look inside after a failure, and deletes older ones itself. You never clean up.

Testing a function that writes a file

Now the CSV test again, done properly — plus a pair of JSON functions, since saving settings is the other file-writing job nearly every program has:

python
import csv
import json


def write_csv(path, rows):
    with open(path, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["product", "quantity"])
        writer.writerows(rows)


def save_settings(path, settings):
    path.write_text(json.dumps(settings, indent=2))


def load_settings(path):
    return json.loads(path.read_text())


def test_write_csv(tmp_path):
    target = tmp_path / "report.csv"

    write_csv(target, [("pen", 12), ("bag", 2)])

    assert target.exists()
    assert target.read_text().splitlines() == [
        "product,quantity",
        "pen,12",
        "bag,2",
    ]


def test_csv_reads_back(tmp_path):
    target = tmp_path / "report.csv"
    write_csv(target, [("pen", 12)])

    with target.open(newline="") as f:
        rows = list(csv.DictReader(f))

    assert rows == [{"product": "pen", "quantity": "12"}]


def test_settings_round_trip(tmp_path):
    target = tmp_path / "settings.json"
    settings = {"currency": "BDT", "tax": 0.15}

    save_settings(target, settings)

    assert load_settings(target) == settings
    assert '"currency": "BDT"' in target.read_text()
text
$ pytest -q
...                                                                      [100%]
3 passed in 0.07s
$ ls
test_files.py

Nothing left behind this time. The pattern is always the same: build a path inside tmp_path, hand it to the code under test, then read the file back and check it.

There are two ways to check, and the tests above use both. test_write_csv checks the exact text — the strictest test, and the right one when another program will read the file. test_csv_reads_back and test_settings_round_trip read the file back the way a real reader would and compare the data. Notice that DictReader gave back "12", a string: CSV has no numbers, only text. A round-trip test is where that kind of surprise turns up.

tmp_path starts empty. If your code expects a subfolder to exist, either create it in the test with (tmp_path / "exports").mkdir() or — better — make the code create it. You will see what happens when nobody does in "When it breaks".

tmp_path_factory — one folder for the whole session

tmp_path is function-scoped: a new folder for every test. Sometimes that is wasteful. Suppose several tests read a large sample file that takes a while to build. Last chapter you would reach for scope="session" — but a session fixture cannot use tmp_path, because a session-long fixture cannot depend on something that is thrown away after each test.

For that there is tmp_path_factory, which is itself session-scoped. You ask it for folders with mktemp:

python
import pytest


@pytest.fixture(scope="session")
def big_catalogue(tmp_path_factory):
    folder = tmp_path_factory.mktemp("data")
    target = folder / "catalogue.csv"
    lines = ["product,quantity"]
    lines += [f"item{i},{i}" for i in range(10_000)]
    target.write_text("\n".join(lines))
    print("built", target)
    return target


def test_has_header(big_catalogue):
    assert big_catalogue.read_text().startswith("product,quantity")


def test_has_every_row(big_catalogue):
    assert len(big_catalogue.read_text().splitlines()) == 10_001
text
$ pytest -q -s
built /tmp/pytest-of-you/pytest-6/data0/catalogue.csv
..
2 passed in 0.07s

built appears once, for two tests. mktemp("data") adds a number to the name — data0, and data1 if you call it again — so two calls never collide.

The trade-off is the one from last chapter: both tests now share one file. That is fine here because they only read it. A test that changes a shared file needs its own copy in tmp_path.

capsys — testing what a program prints

Start with the mistake almost everyone makes once. A function prints a greeting; the test checks its result:

python
def greet(name):
    print(f"hello, {name}")


def test_greet():
    assert greet("Rina") == "hello, Rina"
text
$ pytest -q
F                                                                        [100%]
=================================== FAILURES ===================================
__________________________________ test_greet __________________________________

    def test_greet():
>       assert greet("Rina") == "hello, Rina"
E       AssertionError: assert None == 'hello, Rina'
E        +  where None = greet('Rina')

test_wrong.py:6: AssertionError
----------------------------- Captured stdout call -----------------------------
hello, Rina
=========================== short test summary info ============================
FAILED test_wrong.py::test_greet - AssertionError: assert None == 'hello, Rina'
1 failed in 0.09s

print sends text; it does not return it, so greet returns None. Look at the bottom of the report, though: Captured stdout call — pytest already caught the text. It does that for every test, to keep the terminal tidy. capsys is how you get your hands on it:

python
def greet(name):
    print(f"hello, {name}")


def test_greet(capsys):
    greet("Rina")

    assert capsys.readouterr().out == "hello, Rina\n"
text
$ pytest -q
.                                                                        [100%]
1 passed in 0.07s

Call the function first, then read what it printed. Real command-line programs print more than one line, to two streams, and return an exit code. Here is one, with tests:

python
import sys


def main(argv):
    if not argv:
        print("usage: stock ITEM QUANTITY", file=sys.stderr)
        return 2
    item, quantity = argv[0], int(argv[1])
    print(f"{item}: {quantity} in stock")
    if quantity < 5:
        print("low stock")
    return 0


def test_prints_the_stock(capsys):
    code = main(["pen", "12"])

    captured = capsys.readouterr()
    assert code == 0
    assert captured.out == "pen: 12 in stock\n"
    assert captured.err == ""


def test_warns_when_low(capsys):
    main(["bag", "2"])

    out = capsys.readouterr().out
    assert out.splitlines() == ["bag: 2 in stock", "low stock"]


def test_usage_goes_to_stderr(capsys):
    code = main([])

    captured = capsys.readouterr()
    assert code == 2
    assert captured.out == ""
    assert "usage:" in captured.err
text
$ pytest -q
...                                                                      [100%]
3 passed in 0.08s

capsys.readouterr() returns everything written to standard output and standard error since the last time you asked, as an object with two string fields, .out and .err. It is a named tuple, so out, err = capsys.readouterr() works too.

Two details matter in practice:

  • print adds a newline. The captured text is "pen: 12 in stock\n", not "pen: 12 in stock". For several lines, .splitlines() gives a clean list to compare.
  • Errors go to err. A usage message belongs on standard error, and test_usage_goes_to_stderr checks both that it went there and that nothing went to standard output.

"Since the last time you asked" is meant literally — reading empties the buffer:

python
def test_read_twice(capsys):
    print("first")
    one = capsys.readouterr()
    print("second")
    two = capsys.readouterr()

    assert one.out == "first\n"
    assert two.out == "second\n"
text
$ pytest -q
.                                                                        [100%]
1 passed in 0.07s

That is useful: you can check output step by step. It is also the reason a second readouterr() sometimes comes back empty when you did not expect it.

capfd — when the output does not go through Python

capsys replaces Python's sys.stdout and sys.stderr. Anything that writes to the operating system's output directly — a child process, a C extension — goes around it. capfd captures one level lower, at the file descriptor, and catches that too:

python
import os


def test_with_capsys(capsys):
    os.system("echo from the shell")
    assert capsys.readouterr().out == ""


def test_with_capfd(capfd):
    os.system("echo from the shell")
    assert capfd.readouterr().out == "from the shell\n"
text
$ pytest -q
..                                                                       [100%]
2 passed in 0.07s

Same readouterr(), same .out and .err. Use capsys by default and switch to capfd only when output from outside Python is missing.

caplog — testing what a program logs

Real programs log instead of printing. A warning in the log is behaviour like any other, so it deserves a test:

python
import logging

logger = logging.getLogger("importer")


def parse_quantities(lines):
    result = {}
    for line in lines:
        item, _, raw = line.partition(",")
        if not raw.strip().isdigit():
            logger.warning("skipped %r: bad quantity", item)
            continue
        result[item] = int(raw)
    logger.info("parsed %d of %d lines", len(result), len(lines))
    return result


def test_bad_row_is_logged(caplog):
    result = parse_quantities(["pen,12", "bag,two"])

    assert result == {"pen": 12}
    assert len(caplog.records) == 1
    record = caplog.records[0]
    assert record.levelname == "WARNING"
    assert record.getMessage() == "skipped 'bag': bad quantity"


def test_text_is_one_string(caplog):
    parse_quantities(["bag,two"])

    print(repr(caplog.text))
    assert "bad quantity" in caplog.text
text
$ pytest -q -s
."WARNING  importer:test_importer.py:11 skipped 'bag': bad quantity\n"
.
2 passed in 0.07s

caplog gives you the same log three ways:

  • caplog.records — a list of logging.LogRecord objects, one per message. Each has .levelname ("WARNING"), .name (the logger, "importer"), and .getMessage(), which fills the %r in with its value.
  • caplog.messages — just the finished message strings, as a list.
  • caplog.text — everything as one formatted string. Good for a quick in check; too loose for anything exact.

Prefer records when the level matters and messages when only the wording does.

Messages below WARNING are not captured by default

parse_quantities also logs an INFO summary. Add a test for it:

python
def test_summary_is_logged(caplog):
    parse_quantities(["pen,12"])

    assert caplog.messages == ["parsed 1 of 1 lines"]
text
$ pytest -q -k summary
F                                                                        [100%]
=================================== FAILURES ===================================
____________________________ test_summary_is_logged ____________________________

caplog = <_pytest.logging.LogCaptureFixture object at 0x70495b4f43e0>

    def test_summary_is_logged(caplog):
        parse_quantities(["pen,12"])
    
>       assert caplog.messages == ["parsed 1 of 1 lines"]
E       AssertionError: assert [] == ['parsed 1 of 1 lines']
E         
E         Right contains one more item: 'parsed 1 of 1 lines'
E         Use -v to get more diff

test_importer.py:38: AssertionError
=========================== short test summary info ============================
FAILED test_importer.py::test_summary_is_logged - AssertionError: assert [] =...
1 failed, 2 deselected in 0.08s

Nothing was captured. The function did log — but Python's logging starts at WARNING, so the INFO message was dropped before caplog ever saw it. Lower the level for the test with caplog.set_level:

python
import logging


def test_summary_is_logged(caplog):
    caplog.set_level(logging.INFO)

    parse_quantities(["pen,12"])

    assert caplog.messages == ["parsed 1 of 1 lines"]


def test_only_the_importer_logger(caplog):
    caplog.set_level(logging.INFO, logger="importer")

    parse_quantities(["pen,12", "bag,two"])

    assert [r.levelname for r in caplog.records] == ["WARNING", "INFO"]
text
$ pytest -q
..                                                                       [100%]
2 passed in 0.07s

Call set_level before the code that logs. The second form, with logger="importer", lowers the level for that one logger only, so chatty libraries stay quiet. Either way pytest puts the old level back when the test ends.

request and pytestconfig

One more built-in shows up inside other fixtures. request describes the test that asked for the fixture; request.node.name is that test's name. That is handy for giving files readable, unique names:

python
import pytest


@pytest.fixture
def export_file(tmp_path, request):
    return tmp_path / f"{request.node.name}.csv"


def test_pens(export_file):
    print(export_file.name)


@pytest.mark.parametrize("quantity", [1, 50])
def test_bulk(export_file, quantity):
    print(export_file.name)


def test_config(pytestconfig):
    print(pytestconfig.rootpath.name, pytestconfig.getoption("verbose"))
text
$ pytest -q -s
test_pens.csv
.test_bulk[1].csv
.test_bulk[50].csv
.shop -1
.
4 passed in 0.07s

A parametrized test's name includes its parameters in square brackets. And the last test shows pytestconfig: the run's configuration object, with the project's root folder and every command-line option (-q sets verbosity to -1). You will meet it again in the chapters on configuration and plugins.


A complete example

export.py writes valid stock rows to a CSV, logs a warning for each row it skips, and prints a summary:

python
import csv
import logging
import sys

logger = logging.getLogger("export")


def export_stock(rows, out_dir):
    """Write the valid rows to out_dir/stock.csv and return its path."""
    out_dir.mkdir(parents=True, exist_ok=True)
    target = out_dir / "stock.csv"
    written = 0
    with target.open("w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["product", "quantity"])
        for product, quantity in rows:
            if quantity < 0:
                logger.warning("skipped %s: negative quantity %d", product, quantity)
                continue
            writer.writerow([product, quantity])
            written += 1
    print(f"wrote {written} rows to {target.name}")
    if written == 0:
        print("nothing exported", file=sys.stderr)
    return target

test_export.py checks all three of its outputs — the file, the log and the terminal:

python
import csv

from export import export_stock

ROWS = [("pen", 12), ("bag", -1), ("eraser", 30)]


def read_rows(path):
    with path.open(newline="") as f:
        return list(csv.reader(f))


def test_writes_only_valid_rows(tmp_path):
    target = export_stock(ROWS, tmp_path)

    assert target == tmp_path / "stock.csv"
    assert read_rows(target) == [
        ["product", "quantity"],
        ["pen", "12"],
        ["eraser", "30"],
    ]


def test_creates_missing_folders(tmp_path):
    out_dir = tmp_path / "exports" / "2026"

    target = export_stock(ROWS, out_dir)

    assert target.parent == out_dir
    assert target.exists()


def test_skipped_row_is_logged(tmp_path, caplog):
    export_stock(ROWS, tmp_path)

    assert [r.levelname for r in caplog.records] == ["WARNING"]
    assert caplog.messages == ["skipped bag: negative quantity -1"]


def test_prints_a_summary(tmp_path, capsys):
    export_stock(ROWS, tmp_path)

    captured = capsys.readouterr()
    assert captured.out == "wrote 2 rows to stock.csv\n"
    assert captured.err == ""


def test_empty_export_warns_on_stderr(tmp_path, capsys):
    export_stock([("bag", -1)], tmp_path)

    out, err = capsys.readouterr()
    assert out == "wrote 0 rows to stock.csv\n"
    assert err == "nothing exported\n"
text
$ pytest -v
collecting ... collected 5 items

test_export.py::test_writes_only_valid_rows PASSED                       [ 20%]
test_export.py::test_creates_missing_folders PASSED                      [ 40%]
test_export.py::test_skipped_row_is_logged PASSED                        [ 60%]
test_export.py::test_prints_a_summary PASSED                             [ 80%]
test_export.py::test_empty_export_warns_on_stderr PASSED                 [100%]

============================== 5 passed in 0.09s ===============================
$ ls
export.py  test_export.py

Three things are worth noticing.

First, export_stock takes the folder as a parameter. That one decision is what makes it testable: the program passes a real folder, the tests pass tmp_path. A function that hard-codes "/var/reports/stock.csv" inside itself can only be tested by writing to that real path — which is exactly what a test must never do.

Second, each test asks for only the fixtures it checks. Two of them combine tmp_path with capsys or caplog; built-in fixtures mix freely with each other and with your own.

Third, the working directory is unchanged after five tests that all wrote files.


When it breaks

fixture 'tmp_dir' not found A typo in the parameter name. pytest matches fixtures by exact name, and the error goes on to print available fixtures: with every one it knows — tmp_path, tmp_path_factory, capsys, capfd, caplog and the rest. Copy the correct name from that list. (You will also see tmpdir there; it is the older version that returns a py.path object instead of a pathlib.Path. Use tmp_path in new code.)

ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. A fixture with scope="session" (or "module") asked for tmp_path. A long-lived fixture cannot depend on a per-test one. Use tmp_path_factory.mktemp("name") instead.

FileNotFoundError: [Errno 2] No such file or directory: '/tmp/pytest-of-you/pytest-3/test_nested0/exports/report.csv' tmp_path exists, but the exports folder inside it does not — nothing creates folders for you. Call .mkdir(parents=True, exist_ok=True) on the parent first, ideally inside the code under test, as export_stock does.

A file appears at /tmp/pytest-of-you/pytest-5/test_str0report.csv No error at all — the path was built with strings: str(tmp_path) + "report.csv". The separator went missing, so the file landed next to the test's folder instead of inside it. Join with / on the Path itself: tmp_path / "report.csv".

AssertionError: assert [] == ['parsed 1 of 1 lines'] with caplog The message was logged below WARNING and never captured. Call caplog.set_level(logging.INFO) — or logging.DEBUG — before the code runs.

capsys.readouterr().out is "" when you expected text Either output was already consumed by an earlier readouterr() in the same test, or it did not go through Python's sys.stdout — a subprocess or os.system call. For the second case, switch to capfd.

cannot use capfd and capsys at the same time A test asked for both. They capture the same streams at different levels; pick one.