Capture¶
Every test's stdout, stderr and log records are captured by default, and shown in the report only if that test fails:
FAILED tests/test_delivery.py::test_retries_are_logged
...
--- captured stdout ---
retrying after connection reset
--- captured log records ---
WARNING relay:client.py:82 attempt 1/3 failed: connection reset
--capture no (or -s) turns this off for a live pass-through instead, each line prefixed with
the test id it came from — several tests can be printing at once.
Reading captured output from inside a test¶
voci.capture hands back a live view of the current test's stdout/stderr, for asserting on
output directly rather than waiting for a failure to show it:
async def test_warns_on_missing_config(
c: Annotated[voci.Capture, Depends(voci.capture)],
) -> None:
load_config(path=None)
assert "no config path given" in c.err
.out/.err re-read the capture buffer on every access, so text written after the fixture was
injected is visible immediately — nothing about it needs to be re-fetched.
Reading captured log records¶
voci.log_records is the same idea for logging, with .records as raw logging.LogRecords,
.messages as their formatted text, and .record_tuples as (logger name, level, message) for
assertion comparison:
async def test_retries_are_logged(
r: Annotated[Relay, Depends(flaky_relay)],
logs: Annotated[voci.LogRecords, Depends(voci.log_records)],
) -> None:
with logs.set_level(logging.WARNING, logger="relay"):
await r.deliver("https://hooks.test/v1", b"retry-me")
warnings = [rec for rec in logs.records if rec.levelno == logging.WARNING]
assert len(warnings) == 2
assert "attempt 1/3" in logs.messages[0]
set_level raises or lowers a logger's level for the block and restores it after, None
targeting the root logger. A logger's level is process state, shared by every test — raising it
inside set_level lets a concurrently-running test's own logging through too, and lowering one
can leave a neighbour's own set_level block emptier than it expected.
Both capture and log_records are attributed by an asyncio ContextVar, not by swapping a
global stream or handler: many tests logging or printing at once each see only their own output,
including anything written from inside asyncio.to_thread, which inherits the context that set
it up.
Temporary directories¶
voci.tmp_path is a pathlib.Path unique to the running test, built from its own id, so nothing
about naming it needs a scan-and-retry:
async def test_stats_can_be_dumped(
c: Annotated[TTLCache, Depends(cache)],
tmp: Annotated[Path, Depends(voci.tmp_path)],
) -> None:
c.put("a", b"1")
target = tmp / "stats.json"
target.write_text(json.dumps({"hits": c.hits}))
assert json.loads(target.read_text()) == {"hits": 1}
Every tmp_path lives under one root per run, a fresh numbered directory in the platform temp
directory; --basetemp moves that root, and the last three runs' directories are kept alongside
it for a post-mortem. voci.tmp_path_factory is the session-scoped fixture behind it, for a
fixture that needs its own directory rather than the one the test itself gets.