Pytest is the most popular Python testing framework. It requires no boilerplate, discovers tests automatically, and scales from simple unit tests to complex integration suites. This cheat sheet covers every pytest feature you need day-to-day.
Quick reference
| Task | Pytest API |
|---|---|
| Run all tests | pytest |
| Run one file | pytest test_users.py |
| Run one test | pytest test_users.py::test_login |
| Run by keyword | pytest -k "login" |
| Run by marker | pytest -m slow |
| Stop on first failure | pytest -x |
| Verbose output | pytest -v |
| Show local variables | pytest -l |
| Print stdout | pytest -s |
| Re-run failures | pytest --lf |
| Check coverage | pytest --cov=src |
| Parametrize a test | @pytest.mark.parametrize(...) |
| Skip a test | @pytest.mark.skip(reason="...") |
| Expected failure | @pytest.mark.xfail |
| Temporary directory | tmp_path fixture |
| Capture output | capsys fixture |
Installation
pip install pytest pytest-cov pytest-mock
Optional but recommended:
pip install pytest-asyncio # async tests
pip install pytest-xdist # parallel execution
Configuration — pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
markers = [
"slow: marks tests as slow",
"integration: marks integration tests",
]
Or use a minimal pytest.ini:
[pytest]
testpaths = tests
addopts = -v
Writing tests
Pytest discovers any file named test_*.py or *_test.py and any function starting with test_.
# tests/test_math.py
def add(a: int, b: int) -> int:
return a + b
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_add_zero():
assert add(0, 0) == 0
Grouping with classes
class TestAdd:
def test_positive(self):
assert add(2, 3) == 5
def test_negative(self):
assert add(-1, -4) == -5
No
unittest.TestCaseneeded — plain classes work fine.
Assertions
Pytest rewrites assert statements to show detailed failure messages.
# Equality
assert result == 42
assert result != 0
# Membership
assert "error" in message
assert key not in data
# Comparison
assert value > 0
assert len(items) >= 3
# Type
assert isinstance(obj, MyClass)
assert callable(fn)
# Approximate float equality
assert result == pytest.approx(3.14159, rel=1e-4)
assert 0.1 + 0.2 == pytest.approx(0.3)
# Raises an exception
import pytest
def test_division_by_zero():
with pytest.raises(ZeroDivisionError):
1 / 0
def test_value_error_message():
with pytest.raises(ValueError, match="must be positive"):
validate(-1)
# Warns
def test_deprecation():
with pytest.warns(DeprecationWarning):
legacy_function()
Fixtures
Fixtures are reusable setup/teardown functions injected by name.
import pytest
@pytest.fixture
def user():
return {"id": 1, "name": "Alice", "email": "alice@example.com"}
def test_user_name(user):
assert user["name"] == "Alice"
def test_user_email(user):
assert user["email"] == "alice@example.com"
Fixture with teardown
@pytest.fixture
def db_connection():
conn = create_connection("sqlite:///:memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
yield conn # test runs here
conn.close() # teardown after test
def test_insert_user(db_connection):
db_connection.execute("INSERT INTO users VALUES (1, 'Alice')")
row = db_connection.execute("SELECT name FROM users").fetchone()
assert row[0] == "Alice"
Fixture scope
@pytest.fixture(scope="function") # default — fresh each test
@pytest.fixture(scope="class") # shared within a class
@pytest.fixture(scope="module") # shared within a file
@pytest.fixture(scope="session") # shared across the entire test run
Fixture in conftest.py
Place shared fixtures in conftest.py — pytest loads it automatically, no import needed:
# tests/conftest.py
import pytest
from myapp import create_app, db as _db
@pytest.fixture(scope="session")
def app():
app = create_app({"TESTING": True, "DATABASE_URL": "sqlite:///:memory:"})
return app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def db(app):
with app.app_context():
_db.create_all()
yield _db
_db.drop_all()
Fixture parametrize
@pytest.fixture(params=["sqlite", "postgres"])
def database(request):
return connect(request.param)
def test_query(database):
# runs twice: once for sqlite, once for postgres
result = database.execute("SELECT 1")
assert result is not None
Parametrize
Run the same test with multiple input/output pairs:
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, -50, 50),
])
def test_add(a, b, expected):
assert add(a, b) == expected
Parametrize with IDs
@pytest.mark.parametrize("value,valid", [
("alice@example.com", True),
("not-an-email", False),
("", False),
], ids=["valid email", "invalid format", "empty string"])
def test_email_validation(value, valid):
assert validate_email(value) == valid
Combining parametrize decorators
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
# runs 4 times: (1,10), (1,20), (2,10), (2,20)
assert multiply(x, y) == x * y
Mocking
Use pytest-mock for clean mock integration via the mocker fixture:
def test_send_email(mocker):
mock_smtp = mocker.patch("myapp.mail.smtplib.SMTP")
send_welcome_email("alice@example.com")
mock_smtp.return_value.__enter__.return_value.sendmail.assert_called_once()
Common mocker methods
mocker.patch("module.ClassName") # patch a class
mocker.patch("module.function_name") # patch a function
mocker.patch.object(obj, "method_name") # patch one method on an object
mocker.patch("builtins.open", mocker.mock_open(read_data="hello")) # patch open()
mocker.MagicMock() # create a standalone mock
Assert mock calls
def test_api_call(mocker):
mock_get = mocker.patch("requests.get")
mock_get.return_value.json.return_value = {"status": "ok"}
mock_get.return_value.status_code = 200
result = fetch_status("https://api.example.com")
mock_get.assert_called_once_with("https://api.example.com", timeout=10)
assert result == "ok"
Spy (real implementation + call tracking)
def test_calls_helper(mocker):
spy = mocker.spy(mymodule, "helper_function")
mymodule.do_something()
assert spy.call_count == 1
Using unittest.mock directly
from unittest.mock import patch, MagicMock, call
def test_without_plugin():
with patch("myapp.requests.get") as mock_get:
mock_get.return_value = MagicMock(status_code=200)
response = fetch_data()
assert response.status_code == 200
Markers
Built-in markers
@pytest.mark.skip(reason="not implemented yet")
def test_future():
...
@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_signal():
...
@pytest.mark.xfail(reason="known bug #123")
def test_flaky():
assert broken_function() == 1 # expected to fail
@pytest.mark.xfail(strict=True) # must fail — pass = error
def test_must_fail():
...
Custom markers
Register in pyproject.toml:
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m not slow')",
"integration: marks integration tests",
"smoke: critical path tests",
]
Apply to tests:
@pytest.mark.slow
def test_large_dataset():
...
@pytest.mark.integration
def test_database_round_trip():
...
Run or exclude by marker:
pytest -m slow # only slow tests
pytest -m "not slow" # exclude slow
pytest -m "slow or integration" # either
pytest -m "slow and not flaky" # both conditions
Built-in fixtures
def test_temp_file(tmp_path):
# tmp_path is a pathlib.Path object to a unique temp directory
file = tmp_path / "data.txt"
file.write_text("hello")
assert file.read_text() == "hello"
def test_stdout(capsys):
print("hello world")
captured = capsys.readouterr()
assert captured.out == "hello world\n"
def test_stderr(capsys):
import sys
print("error", file=sys.stderr)
captured = capsys.readouterr()
assert captured.err == "error\n"
def test_env_var(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-123")
assert get_api_key() == "test-key-123"
def test_env_removed(monkeypatch):
monkeypatch.delenv("SECRET", raising=False)
assert os.environ.get("SECRET") is None
def test_chdir(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
assert Path.cwd() == tmp_path
Async tests
Install pytest-asyncio and configure it:
[tool.pytest.ini_options]
asyncio_mode = "auto" # auto-detect async tests
import pytest
import httpx
@pytest.mark.asyncio
async def test_async_fetch():
async with httpx.AsyncClient() as client:
response = await client.get("https://httpbin.org/get")
assert response.status_code == 200
@pytest.fixture
async def async_client(app):
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
yield client
async def test_async_endpoint(async_client):
response = await async_client.get("/health")
assert response.status_code == 200
Coverage
# Install
pip install pytest-cov
# Run with coverage
pytest --cov=src
# Show missing lines
pytest --cov=src --cov-report=term-missing
# HTML report
pytest --cov=src --cov-report=html # opens htmlcov/index.html
# Fail if coverage drops below threshold
pytest --cov=src --cov-fail-under=80
.coveragerc or pyproject.toml config
[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/migrations/*", "*/__init__.py"]
[tool.coverage.report]
fail_under = 80
show_missing = true
CLI flags quick reference
| Flag | Effect |
|---|---|
-v |
Verbose — show each test name |
-vv |
More verbose — show diffs |
-s |
No output capture (print to terminal) |
-x |
Stop on first failure |
--maxfail=3 |
Stop after 3 failures |
-k "login" |
Run tests matching keyword |
-m slow |
Run tests with marker |
--lf |
Re-run last failed tests |
--ff |
Failed first, then the rest |
--tb=short |
Short traceback (also: long, line, no) |
--tb=no |
No traceback |
-l |
Show local variables on failure |
-p no:warnings |
Suppress warnings |
--co |
Collect and show tests without running |
-n 4 |
Parallel with 4 CPUs (pytest-xdist) |
--durations=10 |
Show 10 slowest tests |
Common patterns
Factory fixture
@pytest.fixture
def make_user():
def _make(name="Alice", role="user", **kwargs):
return User(name=name, role=role, **kwargs)
return _make
def test_admin_can_delete(make_user):
admin = make_user(role="admin")
assert admin.can_delete() is True
def test_user_cannot_delete(make_user):
user = make_user(role="user")
assert user.can_delete() is False
Parametrize with fixture
@pytest.fixture
def api_client(request):
version = request.param
return create_client(base_url=f"/api/{version}")
@pytest.mark.parametrize("api_client", ["v1", "v2"], indirect=True)
def test_health(api_client):
assert api_client.get("/health").status_code == 200
Patch environment variables cleanly
@pytest.fixture(autouse=True)
def reset_settings(monkeypatch):
"""Isolate each test from real environment variables."""
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv("SECRET_KEY", "test-secret")
7 common mistakes
| Mistake | Problem | Fix |
|---|---|---|
assert func() == True |
assert func() gives better error messages |
Use assert func() directly |
| Mutable fixture default | Shared state bleeds between tests | Use scope="function" (default) or yield+teardown |
mock.patch wrong path |
Patching where defined, not where used | Patch mymodule.requests.get, not requests.get |
No conftest.py for shared fixtures |
Re-defining fixtures in every file | Move shared fixtures to conftest.py |
Not using pytest.approx |
assert 0.1 + 0.2 == 0.3 fails |
assert 0.1 + 0.2 == pytest.approx(0.3) |
Ignoring --tb=short |
Long tracebacks obscure the real error | Add addopts = --tb=short to config |
| Testing implementation details | Tests break on refactoring | Test behavior and public API, not internals |
Frequently asked questions
How is pytest different from unittest?
Pytest is less verbose — no class inheritance, no self.assert* methods, plain assert statements. It also has a richer plugin ecosystem and better output. unittest tests run fine under pytest.
Where should I put tests?
Two common layouts: tests/ directory next to src/ (recommended), or src/mypackage/tests/ alongside the code. Use testpaths = ["tests"] in config to be explicit.
What is conftest.py?
A special file pytest loads automatically. Use it for shared fixtures, hooks, and plugins. Each directory can have one and fixtures cascade up the directory tree.
How do I run only the tests that failed last time?
pytest --lf (last-failed). Use --ff to run failed first, then the rest.
How do I debug a failing test?
Add breakpoint() inside the test and run pytest -s to drop into pdb. Or use pytest --pdb to automatically enter the debugger on failures.
How do I test CLI scripts?
Use click.testing.CliRunner for Click apps, or subprocess.run for arbitrary scripts. The capsys fixture captures stdout/stderr for direct function calls.