Back to Blog
Python

pytest Fixtures: monkeypatch, tmp_path, caplog, and capsys

python pytest monkeypatch tmp_path caplog and capsys: Learn how pytest's monkeypatch, tmp_path, caplog, and capsys fixtures isolate tests from the environment, filesys...

pytestmonkeypatchtmp_pathcaplogcapsystest-isolation
Illustration of pytest fixture tools isolating a test from environment, filesystem, logs, and output streams

python pytest monkeypatch tmp_path caplog and capsys requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When a pytest test needs to control its environment rather than accept whatever the machine happens to provide, four built-in fixtures cover the majority of cases: monkeypatch for changing attributes and environment variables, tmp_path for an isolated filesystem, caplog for inspecting log records, and capsys for capturing output written to stdout and stderr. Each fixture is function-scoped by default, meaning every test gets a fresh instance, and none of them require plugins or external packages.

The table below summarizes what each fixture provides before the sections that follow show them in use.

FixturePurposeTypical return value
monkeypatchPatch attributes, environment variables, and working directoryObject with setattr, setenv, delenv, chdir
tmp_pathUnique temporary directory per testpathlib.Path
caplogCapture logging recordsObject with records, text, set_level
capsysCapture stdout and stderrObject with readouterr()

monkeypatch: Changing Attributes and Environment Safely

monkeypatch is the fixture you reach for when a test must replace a function, a class attribute, or an environment variable without leaving changes behind. All modifications are reverted automatically when the test finishes, which keeps tests independent of each other.

The most common operations are:

  • monkeypatch.setattr(target, name, value) — replace an attribute
  • monkeypatch.setenv(name, value) — set an environment variable
  • monkeypatch.delenv(name, raising=False) — remove an environment variable
  • monkeypatch.chdir(path) — change the working directory

Consider a function that reads an API key from the environment:

import os def get_api_key(): return os.environ.get("API_KEY", "default-key")

A test that verifies the fallback behavior can control the environment directly:

def test_api_key_fallback(monkeypatch): monkeypatch.delenv("API_KEY", raising=False) assert get_api_key() == "default-key" def test_api_key_from_environment(monkeypatch): monkeypatch.setenv("API_KEY", "test-key-123") assert get_api_key() == "test-key-123"

The raising=False argument prevents a KeyError when the variable is not already set. Without it, the test would fail on machines where API_KEY happens to be absent.

Patching a function attribute follows the same pattern. If a module imports a client object that should not perform a network call during tests, replace the callable with a stub:

import payments def test_charge_success(monkeypatch): def fake_charge(card, amount): return {"status": "ok", "amount": amount} monkeypatch.setattr(payments, "charge", fake_charge) result = run_checkout(card="4242", amount=50) assert result["status"] == "ok"

Because monkeypatch restores the original attribute after the test, the stub never leaks into other tests. This is the main reason to prefer it over manually assigning and restoring attributes in try/finally blocks.

tmp_path: A Fresh Directory for Every Test

tmp_path provides a pathlib.Path pointing to a directory that pytest creates for the test and removes afterward. The directory is unique per test, so tests that write files do not collide with each other even when they run in parallel.

A function that writes a configuration file can be tested without touching the real filesystem:

def write_config(directory, name, content): path = directory / name path.write_text(content) return path

The test creates the file inside tmp_path and asserts on its contents:

def test_write_config(tmp_path): result = write_config(tmp_path, "app.conf", "debug=true") assert result.exists() assert result.read_text() == "debug=true"

Because tmp_path is a Path, you can use the full pathlib API: mkdir, glob, read_text, write_bytes, and so on. For tests that need a specific subdirectory structure, create it explicitly:

def test_nested_output(tmp_path): output_dir = tmp_path / "build" / "cache" output_dir.mkdir(parents=True) write_config(output_dir, "cache.json", "{}") assert (output_dir / "cache.json").exists()

The fixture is function-scoped, so each test receives its own directory. If a test needs the same prepared directory across multiple tests, a session-scoped fixture can build on tmp_path_factory instead, but for most unit tests the per-test isolation of tmp_path is exactly what you want.

caplog: Asserting on Log Records Instead of Printed Output

caplog captures records emitted through the standard logging module. It is the right tool when your code logs structured information and the test needs to verify that a specific message was emitted, at a specific level, possibly with specific fields.

The fixture exposes:

  • caplog.records — a list of logging.LogRecord objects
  • caplog.text — the formatted log output as a single string
  • caplog.set_level(level, logger=...) — set the capture threshold
  • caplog.at_level(level, logger=...) — context manager for temporary level changes

By default, caplog captures at the WARNING level. To capture INFO messages, raise the level first:

import logging def process_order(order_id): logging.info("Processing order %s", order_id) return f"done-{order_id}" def test_process_order_logs_info(caplog): with caplog.at_level(logging.INFO): result = process_order("ord-1") assert result == "done-ord-1" assert any(r.message == "Processing order ord-1" for r in caplog.records)

caplog.text is convenient when you only care that a message appears anywhere in the output:

def test_process_order_logs_text(caplog): with caplog.at_level(logging.INFO): process_order("ord-2") assert "Processing order ord-2" in caplog.text

The distinction between caplog and capsys matters: caplog sees logging records, while capsys sees what is written to stdout and stderr. Code that uses print() is invisible to caplog, and code that uses logging is invisible to capsys.

capsys: Capturing stdout and stderr

capsys captures everything a test writes to stdout and stderr, including output from the code under test and from third-party libraries. Call capsys.readouterr() to retrieve what has been captured so far; it returns a namedtuple with out and err fields.

A function that prints a report can be verified like this:

import sys def print_report(title): print(f"Report: {title}") print("generated", file=sys.stderr)
def test_print_report(capsys): print_report("Q3") captured = capsys.readouterr() assert captured.out == "Report: Q3\n" assert captured.err == "generated\n"

Because readouterr() returns everything captured since the last call, you can call it multiple times within one test to inspect output produced at different stages:

def test_multiple_reads(capsys): print("first") first = capsys.readouterr().out print("second") second = capsys.readouterr().out assert first == "first\n" assert second == "second\n"

One practical detail: capsys captures at the file-descriptor level, so it also catches output from C extensions or libraries that write directly to the underlying file descriptor rather than through Python's sys.stdout. If you only need to capture Python-level writes, capfd is the lower-level variant that works on file descriptors; capsys is the more common choice for pure Python code.

Combining the Fixtures in a Realistic Test

The four fixtures are designed to work together. A realistic test often needs to patch an external dependency, write a temporary file, verify a log message, and confirm what was printed — all in one test.

Consider a function that loads a config file, logs the loaded settings, and prints a summary:

import json import logging import os def load_and_report(config_dir, filename): path = config_dir / filename data = json.loads(path.read_text()) logging.info("Loaded config with %d keys", len(data)) print(f"Loaded {len(data)} settings") return data

A test that exercises all four fixtures:

def test_load_and_report(tmp_path, caplog, capsys, monkeypatch): config_file = tmp_path / "settings.json" config_file.write_text('{"debug": true, "retries": 3}') monkeypatch.setenv("APP_ENV", "test") with caplog.at_level(logging.INFO): result = load_and_report(tmp_path, "settings.json") captured = capsys.readouterr() assert result == {"debug": True, "retries": 3} assert "Loaded config with 2 keys" in caplog.text assert captured.out == "Loaded 2 settings\n" assert os.environ["APP_ENV"] == "test"

Each fixture handles one concern: tmp_path provides the file to read, caplog verifies the log record, capsys verifies the printed output, and monkeypatch controls the environment variable. Because monkeypatch reverts the environment change at the end of the test, the test does not affect other tests that read APP_ENV.

Fixture Scope and Test Isolation Tradeoffs

All four fixtures are function-scoped by default, which is the right default for unit tests: each test starts from a clean state. The cost is that setup work repeated across many tests — for example, creating the same directory structure or preparing the same log level — is repeated as well.

When that repetition becomes noticeable, the usual move is to extract a helper fixture that composes the built-in ones:

import pytest @pytest.fixture def prepared_env(tmp_path, monkeypatch): config_dir = tmp_path / "config" config_dir.mkdir() (config_dir / "app.conf").write_text("debug=true") monkeypatch.setenv("APP_CONFIG_DIR", str(config_dir)) return config_dir

Tests can then request prepared_env instead of manually recreating the setup. The underlying tmp_path and monkeypatch fixtures still guarantee isolation; the helper only reduces duplication.

A caveat worth noting: because monkeypatch restores attributes after each test, a patch applied inside a session-scoped fixture will be reverted when that fixture's scope ends, not after each test. If you need a patch to persist across tests in a session, apply it inside the fixture body and keep the fixture session-scoped — but be aware that the patch then affects every test in the session, which can mask isolation problems. Prefer function-scoped patching unless you have a concrete reason to do otherwise.

Similarly, tmp_path is always function-scoped and cannot be shared across tests. If a large fixture file must be reused, create it once in a session-scoped fixture and copy it into tmp_path per test, rather than trying to share the writable directory itself. That keeps each test's filesystem isolated while avoiding repeated expensive generation.

The practical rule is: use the built-in fixtures at their default scope for individual tests, and only introduce custom fixtures when the setup is genuinely repeated and the isolation semantics remain clear.

python pytest monkeypatch tmp_path caplog and capsys | RYUSLOG DEV