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.