Toolmingo
Guides8 min read

Pytest Cheat Sheet: Python Testing Reference

A complete pytest cheat sheet — fixtures, parametrize, mocking, markers, CLI options, and coverage. Copy-ready patterns for unit and integration testing in Python.

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.TestCase needed — 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.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools