QA How-To
pytest caplog and capsys Examples: Assert Logs and Output
Pytest caplog and capsys examples for asserting log levels, messages, stdout, and stderr. Build runnable tests that separate logging from printed output.
18 min read | 3,429 words
TL;DR
Use `caplog` to inspect Python logging records and `capsys` to inspect text written to stdout and stderr. Set the log level explicitly for INFO events, call `readouterr()` at intentional boundaries, and use `capfd` for direct file-descriptor writes.
Key Takeaways
- Use caplog for logger identity, severity, and structured LogRecord assertions.
- Use capsys.readouterr() to assert Python stdout and stderr as separate strings.
- Set the capture level before testing INFO events and clear records only at a deliberate boundary.
- Request caplog and capsys together when one action has both operator and CLI contracts.
- Switch to capfd for direct file-descriptor output rather than forcing capsys to see it.
- Run each example independently and then run the nine-test suite from a clean environment.
Pytest caplog and capsys examples let you assert two different kinds of evidence: structured Python logging records and text written to stdout or stderr. Use caplog when severity, logger name, or message content matters; use capsys when a command prints a user-facing line. In this tutorial, you will build a tiny order queue and test each channel independently before asserting them together.
The distinction matters in real QA suites. A service may log a warning for operators while printing a concise rejection for a CLI user. A passing test that checks only the return value can miss either contract. The examples below use ordinary Python logging and pytest's built-in fixtures, so no capture plugin or custom handler is required.
TL;DR
| Evidence you need | Fixture | Assert against | Boundary to remember |
|---|---|---|---|
Logger name, level, message, or LogRecord fields |
caplog |
record_tuples, records, or text |
INFO often needs an explicit capture level |
Python print() and writes to sys.stderr |
capsys |
readouterr().out and .err |
Each read drains the current buffer |
| File-descriptor writes or uncaptured child output | capfd |
readouterr().out and .err |
It operates below Python's stream objects |
If a test needs both logs and printed text, request both fixtures in the test function. Do not assume a logging call will appear in capsys.readouterr().err: pytest captures logging separately. The official pytest logging guide and stdout/stderr capture guide document the underlying behavior.
What You Will Build
- A deterministic
audit_demo.pymodule that returns a result, emits a log record, and prints a short status line. - A focused log test for the logger name, severity, formatted message, and record fields.
- A scope test showing when to use
caplog.at_level()andcaplog.clear(). - Stream tests that distinguish stdout from stderr and prove that
readouterr()starts a new capture window. - A combined test for the same business action, plus a file-descriptor example for output that
capsyscannot see. - A compact parameterized regression table that covers accepted and rejected orders.
Each step creates one file or a small test file. Run its verification command before moving to the next step. Keeping the examples separate makes it easier to see which fixture answers which question.
Prerequisites
The worked setup uses Python 3.12.7 and pytest 9.1.1, the versions checked for this article. Pytest's Python support policy says pytest 9.0 and newer supports Python 3.10 and newer. If your team already pins Python or pytest, use its approved installed versions and verify the APIs with the same commands. Do not copy an unrelated package pin into a production environment without matching your lockfile.
From a fresh directory, create and activate a virtual environment. On macOS or Linux, run:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install pytest
python --version
python -m pytest --version
On Windows PowerShell, activate with .venv\Scripts\Activate.ps1; the remaining python -m ... commands are the same. The two version commands tell you exactly what interpreter and pytest the environment will run. If your command reports an unsupported Python version, install a compatible interpreter first rather than trying to fix a capture assertion with plugin changes. Create a tests directory with mkdir tests before saving the test files below. For a broader introduction to discovery, assertions, and fixtures, see the pytest tutorial for beginners.
Step 1: Create one observable order operation
Save this as audit_demo.py in the project root. The function has two explicit outcomes. It sends operator evidence through a named logger and human-facing status through a stream. The native write helper is reserved for Step 6; it represents a library that writes to an operating-system file descriptor rather than calling print().
# audit_demo.py
import logging
import os
import sys
LOGGER = logging.getLogger("audit_demo")
def queue_order(order_id: str, *, valid: bool = True) -> bool:
if not valid:
LOGGER.warning("Rejected order %s: invalid input", order_id)
print(f"REJECTED {order_id}", file=sys.stderr)
return False
LOGGER.info("Queued order %s", order_id)
print(f"QUEUED {order_id}")
return True
def native_notice() -> None:
os.write(1, b"native ready\n")
Keep the logger named rather than configuring the root logger inside the module. caplog installs a capture handler for tests; replacing root handlers in application code can remove that handler and make a good test appear silent. The successful branch logs at INFO, which is below pytest's default captured report level. That is deliberate: the test must opt into INFO with caplog.set_level().
Verify Step 1: Run python -c "from audit_demo import queue_order; assert queue_order('A-101')". Expect QUEUED A-101 and exit code zero. Then run python -c "from audit_demo import queue_order; assert queue_order('B-202', valid=False) is False". Expect REJECTED B-202 on stderr and a warning log in the terminal. These checks establish the behavior before pytest becomes part of the diagnosis. If an import fails, run from the directory containing audit_demo.py.
The return value is intentionally independent of the output channels. A future refactor could preserve True or False while removing a log or changing a CLI line. That gives the tests a genuine regression to detect. Use this structure for a real adapter too: choose a stable business action, identify its logging contract, and identify the text contract separately.
Step 2: Pytest caplog and capsys examples: inspect structured logs first
Create tests/test_caplog_records.py. Request caplog by name and set the capture threshold for the audit_demo logger. caplog.record_tuples is useful when the exact logger, integer level, and formatted message form the contract. The records list gives access to full logging.LogRecord objects when you need structured attributes.
# tests/test_caplog_records.py
import logging
from audit_demo import queue_order
def test_success_log_has_expected_identity(caplog):
caplog.set_level(logging.INFO, logger="audit_demo")
assert queue_order("A-101") is True
assert caplog.record_tuples == [
("audit_demo", logging.INFO, "Queued order A-101")
]
record = caplog.records[0]
assert record.name == "audit_demo"
assert record.levelno == logging.INFO
assert record.getMessage() == "Queued order A-101"
assert record.args == ("A-101",)
getMessage() resolves the logging format string with its arguments. record.args confirms that the application used a parameterized logging call. This is a stronger assertion than searching a formatted report for A-101: it ties the line to the expected logger and severity. In an API service with many libraries logging simultaneously, filter caplog.records by record.name rather than asserting that the entire list has exactly one item. Exact-list assertions fit this deliberately isolated demo; broad integration tests often need targeted predicates.
Verify Step 2: Run python -m pytest -q tests/test_caplog_records.py. Expect 1 passed. To inspect why INFO capture matters, temporarily remove the set_level call and rerun. Depending on logger configuration, the INFO event can be filtered out before it reaches pytest's handler. Restore the call immediately. The test should configure the threshold it needs rather than depend on a command-line flag or a developer's local pytest settings.
A log record is diagnostic output, not the same thing as stderr text. Pytest reports captured logging in its own failure section. If your team is building a broader logging convention for tests, the test-framework logging guide provides context for deciding which fields belong in assertions and which belong only in diagnostics.
Step 3: Limit log capture to one action and reset prior records
Some tests perform setup that logs before the action under review. Use caplog.clear() to discard earlier captured records and caplog.at_level() to raise or lower capture only around the operation whose behavior matters. This file makes the boundary visible without assuming any suite-wide logging configuration.
# tests/test_caplog_scope.py
import logging
from audit_demo import queue_order
def test_rejection_is_the_only_log_after_reset(caplog):
with caplog.at_level(logging.INFO, logger="audit_demo"):
queue_order("SETUP-1")
assert any(r.getMessage() == "Queued order SETUP-1" for r in caplog.records)
caplog.clear()
result = queue_order("B-202", valid=False)
assert result is False
assert caplog.record_tuples == [
("audit_demo", logging.WARNING, "Rejected order B-202: invalid input")
]
assert "SETUP-1" not in caplog.text
The at_level context restores the previous logging threshold when its block exits. The record collection remains available for assertions after the block. clear() resets both captured records and formatted text, so the assertion outside the block refers only to the rejection. Do not call clear() after the action you want to inspect, or the test will prove nothing. Also avoid matching only "invalid" in caplog.text if several operations can use that word. The tuple names the precise action and severity.
Verify Step 3: Run python -m pytest -q tests/test_caplog_scope.py; expect 1 passed. Run both log files with python -m pytest -q tests/test_caplog_records.py tests/test_caplog_scope.py; expect 2 passed. Their result should not depend on order. If the second file passes only when run alone, inspect shared logger configuration and global handlers before adding test-order workarounds.
For a larger suite, caplog.get_records("setup"), caplog.get_records("call"), and caplog.get_records("teardown") can separate pytest phases. The regular caplog.records property covers the current phase, which is enough inside these test functions. The official logging reference explains phase access and warns that replacing root logger handlers can break capture.
Step 4: Assert stdout and stderr with capsys
Save tests/test_capsys_streams.py. Unlike caplog, capsys sees Python-level writes to sys.stdout and sys.stderr. The return value from readouterr() has .out and .err string fields. Exact strings are appropriate here because the status lines are a CLI contract, including their trailing newlines.
# tests/test_capsys_streams.py
from audit_demo import queue_order
def test_success_prints_only_to_stdout(capsys):
assert queue_order("A-101") is True
captured = capsys.readouterr()
assert captured.out == "QUEUED A-101\n"
assert captured.err == ""
def test_rejection_prints_only_to_stderr(capsys):
assert queue_order("B-202", valid=False) is False
captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == "REJECTED B-202\n"
def test_readouterr_starts_a_new_window(capsys):
queue_order("A-101")
first = capsys.readouterr()
queue_order("A-102")
second = capsys.readouterr()
assert first.out == "QUEUED A-101\n"
assert second.out == "QUEUED A-102\n"
assert first.err == second.err == ""
The final test catches a common misconception. readouterr() returns output accumulated since the previous read and resets its buffer; it does not return all output since the beginning of the test each time. The capture fixture continues capturing after a read. That makes it useful for multi-stage CLI workflows: read after setup, trigger the user action, then assert only the action's output. Store the first snapshot if you need to compare both windows later.
Verify Step 4: Run python -m pytest -q tests/test_capsys_streams.py; expect 3 passed. Then run python -m pytest -q tests/test_capsys_streams.py -s; the assertions should still pass because a capture fixture takes precedence over pytest's global -s setting inside that test. The -s flag changes ordinary test-run output, not the fixture's ability to inspect its own stream writes. If the exact string assertion fails on an application that writes localized or time-based content, assert the stable contract fields rather than freezing incidental formatting.
Notice that the rejection also emits a WARNING log. The test never asserts that warning through .err, because the module's logger and print(..., file=sys.stderr) have separate capture paths. When debugging an API or browser support script, identifying the path first prevents a confusing false negative. The Python API automation framework guide discusses keeping such diagnostics distinct from user-facing response assertions.
Step 5: Pytest caplog and capsys examples: assert both channels for one action
Now test the operational and user-facing contracts together in tests/test_combined_capture.py. Request both fixtures in the same function. Capture the logger at INFO so the test would also catch an accidental change from WARNING to INFO on a rejected order, then filter to the event from this action.
# tests/test_combined_capture.py
import logging
from audit_demo import queue_order
def test_rejected_order_has_log_and_cli_message(caplog, capsys):
caplog.set_level(logging.INFO, logger="audit_demo")
assert queue_order("B-202", valid=False) is False
streams = capsys.readouterr()
records = [r for r in caplog.records if r.name == "audit_demo"]
assert len(records) == 1
assert records[0].levelno == logging.WARNING
assert records[0].getMessage() == "Rejected order B-202: invalid input"
assert streams.out == ""
assert streams.err == "REJECTED B-202\n"
This is a useful pattern for command handlers and batch jobs. A user should see a concise failure on stderr, while an operator gets a levelled log entry with a stable identifier. The test checks both without turning every emitted byte into an assertion. If the product changes the rejection reason but retains the same user-facing status, the log assertion signals the narrower contract change. If only the stream disappears, the log still proves the action ran, making the failure easier to locate.
Verify Step 5: Run python -m pytest -q tests/test_combined_capture.py; expect 1 passed. For a meaningful mutation check, change file=sys.stderr to the default stream in audit_demo.py, rerun this test, and observe .out and .err fail in the expected direction. Restore the original line. This demonstrates that the two stream assertions protect a real behavior rather than merely restating the Boolean return value.
Do not replace a log assertion with capsys.readouterr().err just because your terminal displays logging on stderr. A logger's handlers and pytest's capture/reporting configuration determine terminal display; they do not make the logger event a CLI stderr contract. When developing against a browser fixture or an HTTP client, use the same separation between library diagnostics and externally visible behavior. The Playwright Python fixtures with pytest guide is a useful next example of keeping fixture responsibilities narrow.
Step 6: Use capfd for file-descriptor output
capsys replaces Python's stream objects; it does not intercept a direct os.write(1, ...) call. Some native extensions and child processes write to file descriptors 1 and 2. Use capfd for those cases. audit_demo.native_notice() already calls os.write, so this test can prove the distinction with a small, platform-neutral example.
# tests/test_capfd_native.py
from audit_demo import native_notice
def test_native_notice_is_captured_by_capfd(capfd):
native_notice()
captured = capfd.readouterr()
assert captured.out == "native ready\n"
assert captured.err == ""
Verify Step 6: Run python -m pytest -q tests/test_capfd_native.py; expect 1 passed. You can also run python -m pytest -q tests/test_capfd_native.py --capture=sys; the fixture still captures its direct file-descriptor write even though the global option chooses sys-level reporting. Avoid requesting capfd and capsys in the same test: both manage stdout and stderr, and pytest rejects conflicting capture fixtures. Choose the lower-level fixture only when the code under test actually writes below Python streams.
A subprocess nuance matters here. If you start a child with inherited stdout and stderr, file-descriptor capture can see its writes. If you instead call subprocess.run(..., capture_output=True, text=True), the returned CompletedProcess.stdout and .stderr already own the child's output; assert those fields directly. There is no value in asking a pytest capture fixture to re-capture output that the subprocess API redirected into pipes. For binary payloads, use capsysbinary or capfdbinary, which return bytes rather than text. Pick the fixture based on the production I/O path, not on which name sounds familiar.
Step 7: Add a parameterized contract for both outcomes
A single parameterized test can guard the accepted and rejected cases without duplicating the action/assertion structure. Save tests/test_order_contract.py. Keep expected severity, log text, and stream fields together in each case so a reviewer can read the full contract for one outcome in one place.
# tests/test_order_contract.py
import logging
import pytest
from audit_demo import queue_order
@pytest.mark.parametrize(
"order_id,valid,expected_result,level,message,stdout,stderr",
[
pytest.param(
"A-101", True, True, logging.INFO,
"Queued order A-101", "QUEUED A-101\n", "",
id="accepted",
),
pytest.param(
"B-202", False, False, logging.WARNING,
"Rejected order B-202: invalid input", "", "REJECTED B-202\n",
id="rejected",
),
],
)
def test_order_output_contract(
caplog, capsys, order_id, valid, expected_result,
level, message, stdout, stderr,
):
caplog.set_level(logging.INFO, logger="audit_demo")
assert queue_order(order_id, valid=valid) is expected_result
streams = capsys.readouterr()
assert caplog.record_tuples == [("audit_demo", level, message)]
assert (streams.out, streams.err) == (stdout, stderr)
Parameterization is useful here because both cases have the same shape. It should not replace a focused test when different outcomes require different setup, asynchronous waits, or substantial domain-specific assertions. The explicit IDs make pytest output say accepted and rejected, which is more informative than a generated tuple of values. The string literals are intentionally small and stable; a large transcript or JSON body would deserve a structured parser or snapshot review strategy instead.
Verify Step 7: Run python -m pytest -q tests/test_order_contract.py; expect 2 passed. Then run python -m pytest -q tests; the files created in Steps 2 through 7 should produce 9 passing tests. Use python -m pytest tests -ra --show-capture=all when diagnosing a failure. --show-capture controls which captured sections appear in the failure report; it does not substitute for assertions inside the tests. Run the same command in CI under your project's pinned Python and pytest environment so local and pipeline diagnostics agree.
Troubleshooting
Problem: caplog.records is empty for an INFO event. The logger or capture handler may be filtering below INFO. Call caplog.set_level(logging.INFO, logger="audit_demo") before the action, as the example does. If application startup replaces root handlers with logging.config.dictConfig, keep pytest's capture handler attached instead of assigning a new root handler list. Check the logger name with record.name on a known emitted event.
Problem: capsys.readouterr().err is empty even though pytest shows a warning. Pytest reports captured logging separately from captured stderr. Assert logger output through caplog.records, record_tuples, or text. Use .err only for bytes written through the Python stderr stream. This distinction is especially easy to miss when an ordinary terminal run displays both kinds of evidence near each other.
Problem: the second readouterr() call loses the first line. The first call intentionally drains the current buffer. Keep its returned namedtuple, or delay the read until all output has been generated. When checking two phases, take two snapshots and compare each with the expected phase, as in Step 4; do not expect the second snapshot to replay the first.
Problem: capsys misses output from a native library or child process. A direct file-descriptor write bypasses sys.stdout and sys.stderr. Switch that test to capfd, or assert CompletedProcess.stdout when your subprocess code captures its own output. If the output is bytes, use a binary capture fixture. Do not combine capsys and capfd in one test to guess which one will work.
Problem: output appears on the terminal despite a passing capture assertion. Check for --capture=tee-sys, live logging through --log-cli-level, or a capsys.disabled() context. These controls affect visibility and reporting. A test that requests capsys can still read its own captured output under -s; verify the actual readouterr() result before changing application code.
Problem: the exact text assertion fails only in one environment. First check whether the program includes dynamic paths, timestamps, locale-specific text, or platform line endings. Keep exact assertions for deliberate CLI contract lines such as QUEUED A-101\n; for incidental formatting, normalize only the unstable component or assert parsed fields. Do not use a broad substring if the requirement is specifically stdout versus stderr.
Where To Go Next
You now have a runnable capture suite with a business action, log assertions, stream assertions, and a lower-level output case. Take one real command or background worker from your codebase and write down its three observable contracts: return or exception, operator log, and user-facing output. Add only the assertions that correspond to a product requirement; keep the rest of the logs as failure evidence.
For wider pytest practice, continue with the pytest beginner tutorial and the pytest interview questions. If your next task is a service test, adapt the same capture logic while building the Python API automation framework. For a browser suite, see pytest fixtures for Playwright Python. If a test unexpectedly collects nothing, the pytest versus unittest guide helps compare discovery and assertion conventions. These links lead to adjacent work; this article's examples stand alone.
Interview Questions and Answers
A strong answer distinguishes the source of the evidence before naming a fixture. The questions below mirror the interviewQnA field and can be practiced with the actual test files from this tutorial.
Q: When do you choose caplog instead of capsys? Use caplog for Python logging records and assert logger identity, severity, and message. Use capsys for Python writes to stdout or stderr. A terminal displaying a log near stderr text does not make them the same contract.
Q: Why might an INFO record be missing? Logging levels can filter the event before capture. Set the required level on the relevant named logger with caplog.set_level(logging.INFO, logger="audit_demo") before invoking the code, then inspect caplog.records.
Q: What does capsys.readouterr() do after the first call? It returns the current .out and .err strings and resets that buffer. Capturing continues, so a later call returns only output produced after the earlier snapshot.
Q: How do you verify a warning and a CLI error together? Request caplog and capsys in one test, run the action once, and assert the warning record and stderr line separately. That catches a change to either operator diagnostics or user-visible output.
Q: Why does os.write(1, ...) call for capfd? It writes to the file descriptor directly, bypassing the sys.stdout object replaced by capsys. capfd captures descriptor-level output and exposes the same readouterr() interface.
Q: How should a suite avoid brittle output assertions? Assert exact text only for deliberate external contracts. For dynamic or diagnostic material, filter structured records by name and level, then assert stable fields rather than a whole multi-line transcript.
Common Mistakes
- Asserting a logger warning in
capsys.readouterr().errmerely because it appears near stderr in a terminal. - Forgetting to set a capture level before exercising an INFO or DEBUG path.
- Calling
caplog.clear()orcapsys.readouterr()after the action and then expecting earlier evidence to remain in the buffer. - Comparing every log from an integration test to a single-item list even though libraries emit unrelated records.
- Replacing root logger handlers during tests and thereby detaching pytest's log capture handler.
- Using
capsysto validate direct file-descriptor writes, or requesting bothcapsysandcapfdfor the same test. - Enabling
-sor live logging as a substitute for assertions about the actual output contract.
Each mistake has a different symptom. An empty log list points to level or handler configuration; an empty stream points to the wrong capture channel or an earlier read; extra records suggest an assertion broader than the business requirement. Diagnose the channel first, then tighten the test.
Conclusion
These Pytest caplog and capsys examples show how to assert the right evidence at the right layer. caplog proves structured logging behavior; capsys proves Python stdout and stderr behavior; capfd covers direct descriptor writes. The order example demonstrates all three without a custom plugin, while its parameterized test checks the accepted and rejected contracts in one repeatable suite.
Run the full python -m pytest -q tests command from a clean environment, then port one test to a real QA workflow. Keep log assertions tied to operational requirements and stream assertions tied to what users or downstream tools actually read.
Interview Questions and Answers
How would you test a function that logs a warning and prints an error?
I would request `caplog` and `capsys` in the same test and invoke the function once. I would check the warning's logger name, level, and message through `caplog.records`, then check the exact stderr line through `capsys.readouterr().err`. That keeps operational diagnostics separate from the CLI contract.
Why do you set a logger level in a caplog test?
An INFO or DEBUG event can be filtered before pytest captures it. I set the required level on the named logger before exercising the code, then let pytest restore that setting after the test. This makes the test independent of suite-wide log settings.
What is the difference between caplog.records and caplog.record_tuples?
`records` contains full `logging.LogRecord` objects, so I can inspect fields such as `name`, `levelno`, and `args`. `record_tuples` gives concise triples of logger name, numeric level, and formatted message. I use the smaller representation when those three fields fully describe the contract.
How does capsys.readouterr behave on repeated calls?
Each call returns stdout and stderr accumulated since the previous read and resets that capture buffer. The fixture continues capturing afterward. I take snapshots at meaningful workflow boundaries rather than expecting later reads to replay earlier output.
When is capfd necessary?
I use `capfd` for direct writes to operating-system file descriptors, including `os.write` and some inherited child output. `capsys` manages Python's `sys.stdout` and `sys.stderr` objects and can miss those writes. If my subprocess API returns its own captured output, I assert the returned fields instead.
How would you avoid fragile assertions against a large log stream?
I would filter records to the relevant logger and action identifier, then assert only the required severity and message fields. I would avoid comparing unrelated library logs or timestamps. For user-visible output, I would assert exact lines only when that formatting is an intentional contract.
Frequently Asked Questions
What is the difference between caplog and capsys in pytest?
`caplog` captures Python logging records, including logger name, severity, and formatted message. `capsys` captures Python writes to stdout and stderr as text strings. Use both when one action has separate logging and CLI output contracts.
How do I assert an INFO log with caplog?
Call `caplog.set_level(logging.INFO, logger="your_logger")` before invoking the code. Then inspect `caplog.record_tuples` or filter `caplog.records` by logger name and level. An INFO event may otherwise be filtered out.
Does capsys capture logging output?
Pytest reports captured logs separately from stdout and stderr. Assert logger events through `caplog`, even if a terminal run displays them near stderr text. Reserve `capsys.readouterr().err` for text actually written to Python's stderr stream.
Why is the second capsys.readouterr call empty?
`readouterr()` returns the current captured output and resets its buffer. If nothing was printed after the first call, the next snapshot is empty. Store the first result when you need to compare earlier output.
When should I use capfd instead of capsys?
Use `capfd` when the code writes directly to file descriptors 1 or 2, such as `os.write` or some native libraries. `capsys` captures Python stream-object writes. If subprocess output was redirected with `capture_output=True`, assert the subprocess result directly.
Can I use caplog and capsys in one pytest test?
Yes. Add both fixture names to the test signature, execute the action once, and assert the log records and stream snapshot separately. This is useful when a command logs an operator warning and prints a user-facing error.
Does pytest -s disable capsys assertions?
No. A capture fixture requested by the test takes precedence over the global `-s` setting for that test. The fixture can still return its captured output through `readouterr()`.
Related Guides
- API Testing with pytest and requests: Step-by-Step Tutorial
- Playwright Assert Realtime Notifications Examples: A Practical Tutorial
- Playwright drag and drop: Examples and Best Practices
- Playwright mock date and time: Examples and Best Practices
- Playwright popup and new tab handling: Examples and Best Practices
- Playwright tag and grep filters: Examples and Best Practices