pytest caplog: assert a logger error was logged (tested)
Use caplog: run the code, then assert ("app", logging.ERROR, "disk full") in caplog.record_tuples. If INFO messages are missing, call caplog.set_level(logging.INFO) first (the default level is WARNING); for a logger with its own level, pass logger="app".
The caplog fixture attaches a handler to the root logger for each test. You can assert on caplog.text (formatted lines), caplog.messages, caplog.records (LogRecord objects) or caplog.record_tuples ((logger, level, message)). Measured on pytest 9.1.1, Python 3.13.5.
1. Assert that an error was logged (and why INFO is missing)
$ cat test_cl.py
import logging, pytest
log = logging.getLogger("app")
def work():
log.error("disk full")
log.info("done")
def test_error(caplog):
work()
assert "disk full" in caplog.text
assert caplog.records[0].levelname == "ERROR"
def test_info_default(caplog):
work()
assert "done" in caplog.text
def test_info_level(caplog):
caplog.set_level(logging.INFO)
work()
assert "done" in caplog.text
def test_tuples(caplog):
work()
assert ("app", logging.ERROR, "disk full") in caplog.record_tuples
$ pytest -q | grep -E "^E |passed|failed"
E AssertionError: assert 'done' in 'ERROR app:test_cl.py:5 disk full\n'
E + where 'ERROR app:test_cl.py:5 disk full\n' = <_pytest.logging.LogCaptureFixture object at 0x7febc1b09d10>.text
1 failed, 3 passed in 0.01s
log.error() is captured, but log.info("done") is not: with no configuration the root logger level is WARNING, so INFO and DEBUG records never reach caplog. Call caplog.set_level(logging.INFO) before the code under test (as test_info_level does), or set it for the whole suite:
$ cat pytest.ini
[pytest]
log_level = INFO
$ pytest -q | tail -1
4 passed in 0.01s
2. Your app sets its own logger level, or propagate=False
$ cat test_prop.py
import logging
log = logging.getLogger("app")
log.setLevel(logging.WARNING) # set by your app's logging config
def work():
log.warning("retrying")
log.info("connected")
def test_root_level(caplog):
caplog.set_level(logging.INFO) # root logger only
work()
assert "connected" in caplog.text
def test_named_logger(caplog):
caplog.set_level(logging.INFO, logger="app")
work()
assert "connected" in caplog.text
def test_no_propagate(caplog):
logging.getLogger("app").propagate = False # e.g. dictConfig with propagate: False
try:
work()
assert "retrying" in caplog.text
finally:
logging.getLogger("app").propagate = True
$ pytest -q | grep -E "^E |passed|failed|^-+ Captured|^retrying"
E AssertionError: assert 'connected' in 'WARNING app:test_prop.py:6 retrying\n'
E + where 'WARNING app:test_prop.py:6 retrying\n' = <_pytest.logging.LogCaptureFixture object at 0x7fd067ead2b0>.text
------------------------------ Captured log call -------------------------------
E AssertionError: assert 'retrying' in ''
E + where '' = <_pytest.logging.LogCaptureFixture object at 0x7fd06810a5d0>.text
----------------------------- Captured stderr call -----------------------------
retrying
2 failed, 1 passed in 0.01s
caplog.set_level(logging.INFO) only changes the root logger. If your app sets logging.getLogger("app").setLevel(WARNING), pass logger="app". If the logger has propagate = False (common in dictConfig setups), records never reach the root handler and caplog.text is empty; the message went to stderr through Python's last-resort handler instead. Attach caplog.handler to that logger:
$ cat test_fix.py
import logging
log = logging.getLogger("app")
def test_no_propagate_fixed(caplog):
app = logging.getLogger("app")
app.propagate = False
app.addHandler(caplog.handler) # attach caplog's handler directly
try:
log.warning("retrying")
assert "retrying" in caplog.text
finally:
app.removeHandler(caplog.handler)
app.propagate = True
$ pytest -q | tail -1
1 passed in 0.00s
3. at_level, log.exception, clear() and "no errors logged"
$ cat test_more.py
import logging
log = logging.getLogger("app")
def test_at_level(caplog):
with caplog.at_level(logging.DEBUG, logger="app"):
log.debug("cache miss")
assert caplog.messages == ["cache miss"]
def test_exception(caplog):
try:
1 / 0
except ZeroDivisionError:
log.exception("job failed")
rec = caplog.records[0]
assert rec.levelno == logging.ERROR
assert rec.exc_info[0] is ZeroDivisionError
assert "ZeroDivisionError" in caplog.text
def test_clear(caplog):
log.warning("setup noise")
caplog.clear()
log.warning("real")
assert caplog.messages == ["real"]
def test_no_errors(caplog):
log.warning("deprecated option")
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
$ pytest -q | tail -1
4 passed in 0.01s
caplog.at_level() restores the level when the block exits. log.exception() logs at ERROR with exc_info set, and the traceback appears in caplog.text. caplog.clear() drops records from earlier in the test.
Related: pytest fixture not found, pytest ScopeMismatch (caplog is function-scoped).
Frequently asked questions
Why is caplog.text empty?
Either the message is below the logger's effective level (default WARNING, so use caplog.set_level), or the logger has propagate=False so records never reach caplog's root handler.
How do I assert a specific log level in pytest?
Check caplog.record_tuples for (logger_name, logging.ERROR, message), or inspect caplog.records[i].levelno.
How do I set the caplog level for every test?
Add log_level = INFO under [pytest] in pytest.ini (or the matching key in pyproject.toml).
Written with AI assistance. Measured on 2026-10-23 with pytest 9.1.1 on Python 3.13.5 in a clean virtualenv. Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; Python and pytest are free.