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.
- 1Encounter
- 2Understand
- 3Worked
- 4Predict
- 5Apply
- 6Stretch
The problem we are solving
Here is a function that writes a CSV file, and a test for it:
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"$ pytest -q
. [100%]
1 passed in 0.08s
$ ls
report.csv test_report.pyThe 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 whencapfdis needed instead - Test what a program logs with
caplog, including messages belowWARNING - Use
request.node.nameto 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:
def test_first(tmp_path):
print(tmp_path)
print(type(tmp_path))
def test_second(tmp_path):
print(tmp_path)$ 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_first0andtest_second0never meet, so both can write a file calledreport.csv. - Every run gets a new base folder:
pytest-0, thenpytest-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:
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()$ pytest -q
... [100%]
3 passed in 0.07s
$ ls
test_files.pyNothing 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:
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$ pytest -q -s
built /tmp/pytest-of-you/pytest-6/data0/catalogue.csv
..
2 passed in 0.07sbuilt 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:
def greet(name):
print(f"hello, {name}")
def test_greet():
assert greet("Rina") == "hello, Rina"$ 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.09sprint 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:
def greet(name):
print(f"hello, {name}")
def test_greet(capsys):
greet("Rina")
assert capsys.readouterr().out == "hello, Rina\n"$ pytest -q
. [100%]
1 passed in 0.07sCall 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:
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$ pytest -q
... [100%]
3 passed in 0.08scapsys.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:
printadds 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, andtest_usage_goes_to_stderrchecks 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:
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"$ pytest -q
. [100%]
1 passed in 0.07sThat 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:
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"$ pytest -q
.. [100%]
2 passed in 0.07sSame 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:
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$ pytest -q -s
."WARNING importer:test_importer.py:11 skipped 'bag': bad quantity\n"
.
2 passed in 0.07scaplog gives you the same log three ways:
caplog.records— a list oflogging.LogRecordobjects, one per message. Each has.levelname("WARNING"),.name(the logger,"importer"), and.getMessage(), which fills the%rin with its value.caplog.messages— just the finished message strings, as a list.caplog.text— everything as one formatted string. Good for a quickincheck; 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:
def test_summary_is_logged(caplog):
parse_quantities(["pen,12"])
assert caplog.messages == ["parsed 1 of 1 lines"]$ 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.08sNothing 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:
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"]$ pytest -q
.. [100%]
2 passed in 0.07sCall 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:
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"))$ pytest -q -s
test_pens.csv
.test_bulk[1].csv
.test_bulk[50].csv
.shop -1
.
4 passed in 0.07sA 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:
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 targettest_export.py checks all three of its outputs — the file, the log and the terminal:
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"$ 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.pyThree 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.
Step 4 of 6 — Predict
Check your understanding
After the last line, what exactly is in captured.out?
def test_twice(capsys):
print("first")
capsys.readouterr()
print("second")
print("third")
captured = capsys.readouterr()- A'first\nsecond\nthird\n'
- B'third\n'
- C'second\nthird\n'
- D''
restock does log, yet the test fails with assert [] == ['restocked pen']. Why?
import logging
logger = logging.getLogger("shop")
def restock(item):
logger.info("restocked %s", item)
def test_restock_is_logged(caplog):
restock("pen")
assert caplog.messages == ["restocked pen"]- AThe logger is named `"shop"`; `caplog` only listens to the root logger
- B`INFO` is below the default `WARNING` level, so nothing was captured; call `caplog.set_level(logging.INFO)` first
- C`caplog.messages` includes the level name, so compare with `'INFO restocked pen'`
- DThe `%s` is never filled in, so the message is `'restocked %s'`
A scope="session" fixture wants to build a large sample file once. Where should the file go?
- AIn `tmp_path` — just request it in the fixture's parameters
- BIn `tmpdir` — it is the session version of `tmp_path`
- CIn a `data/` folder inside the project, so it survives to the next run
- DIn the folder returned by `tmp_path_factory.mktemp("data")`
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
Your turn
Write a module notes.py with a function export_notes(notes, out_dir). It takes a list of dictionaries such as {"title": "Buy pens", "done": False} and:
- Creates
out_dirif it does not exist, and writes the notes toout_dir / "notes.json" - Logs a
WARNINGfor any note whose title is empty, and leaves it out of the file - Logs an
INFOmessage"exported N notes" - Prints
saved notes.jsonwhen it finishes
Then write test_notes.py that checks, using only built-in fixtures:
- The JSON file reads back as exactly the notes that were valid
- A missing subfolder such as
tmp_path / "a" / "b"is created - The warning is logged with
levelname == "WARNING", and theINFOmessage is captured too (what do you need to call first?) - Exactly
saved notes.json\nis printed, and nothing goes to standard error
Run pytest -q, then run ls (or dir on Windows). If any notes.json appears in your project folder, one of your tests — or your function — is writing to a real path. Finding that and fixing it is the habit this chapter is about.
Solution
First the plan, in the shape of the table from "Before you write the test":
| Case | Input | Expected | |---|---|---| | Valid notes kept | three notes, one with title " " | file holds the other two, unchanged | | Missing folder | tmp_path / "a" / "b" | both folders created, file inside | | Empty title | the same three notes | one WARNING, then INFO exported 2 notes | | Output | the same three notes | saved notes.json\n on stdout, nothing on stderr | | Nothing to export | [] | file holds [] |
notes.py:
import json
import logging
logger = logging.getLogger("notes")
def export_notes(notes, out_dir):
"""Write notes with a title to out_dir/notes.json and return its path."""
out_dir.mkdir(parents=True, exist_ok=True)
valid = []
for note in notes:
if not note["title"].strip():
logger.warning("skipped a note with an empty title")
continue
valid.append(note)
target = out_dir / "notes.json"
target.write_text(json.dumps(valid, indent=2))
logger.info("exported %d notes", len(valid))
print("saved notes.json")
return targettest_notes.py:
import json
import logging
from notes import export_notes
NOTES = [
{"title": "Buy pens", "done": False},
{"title": " ", "done": True},
{"title": "Call the printer", "done": True},
]
def test_file_holds_only_valid_notes(tmp_path):
target = export_notes(NOTES, tmp_path)
assert target == tmp_path / "notes.json"
assert json.loads(target.read_text()) == [NOTES[0], NOTES[2]]
def test_creates_missing_folders(tmp_path):
out_dir = tmp_path / "a" / "b"
target = export_notes(NOTES, out_dir)
assert target.parent == out_dir
assert target.exists()
def test_empty_title_is_warned_about(tmp_path, caplog):
caplog.set_level(logging.INFO, logger="notes")
export_notes(NOTES, tmp_path)
assert [(r.levelname, r.getMessage()) for r in caplog.records] == [
("WARNING", "skipped a note with an empty title"),
("INFO", "exported 2 notes"),
]
def test_prints_one_line_and_no_errors(tmp_path, capsys):
export_notes(NOTES, tmp_path)
out, err = capsys.readouterr()
assert out == "saved notes.json\n"
assert err == ""
def test_no_notes_still_writes_an_empty_list(tmp_path):
target = export_notes([], tmp_path)
assert json.loads(target.read_text()) == []$ pytest -q
..... [100%]
5 passed in 0.10s
$ ls
notes.py test_notes.pyWhy each decision:
out_diris a parameter. That is what lets every test passtmp_path; the finallsshows nonotes.jsonin the project.- The JSON test reads the file back with
json.loadsrather than comparing text. The promise is "these notes, as data"; the exact indentation isjson.dumps's business, not ours. Comparing withNOTES[0]andNOTES[2]also proves the valid notes were written unchanged. - The title
" "is spaces, not"". A title of only spaces is the boundary case — it looks non-empty toif not note["title"]and only.strip()catches it. The test pins that decision down. caplog.set_levelcomes first, and targets"notes". Without it theINFOline is never captured. Comparing(levelname, message)pairs checks the level, the wording and the order in one assert — without touchingcaplog.text, whose file names and line numbers would break the test on every edit.err == ""is asserted on purpose. "Nothing went wrong" is part of the contract, and a stray debugprintto standard error would otherwise slip through.- The empty list gets its own test. It is the edge case a reader of the file will hit first on a new account, and writing
[]instead of nothing at all is a decision worth keeping.
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Sign in to take the quiz