Skip to main content

Working with pytest

In this chapter, we'll explore advanced pytest features that make testing more powerful and maintainable. We'll cover:

  • Fixtures and fixture scope
  • Parametrized tests with @pytest.mark.parametrize
  • Test markers and custom markers
  • Plugins and extending pytest
  • conftest.py and sharing fixtures
  • Useful command-line options

Fixtures

Fixtures provide test dependencies through dependency injection:

import pytest


@pytest.fixture
def database():
"""Create a test database connection."""
db = Database(":memory:")
db.create_tables()
yield db
db.close()


def test_insert_user(database):
database.insert("users", {"name": "Alice"})
users = database.query("SELECT * FROM users")
assert len(users) == 1

How fixtures work

  1. pytest sees database parameter in test function
  2. Finds matching fixture
  3. Runs fixture code before yield
  4. Provides yield value to test
  5. After test, runs code after yield (cleanup)

Fixture return vs yield

# Return - no cleanup
@pytest.fixture
def simple_data():
return {"key": "value"}


# Yield - with cleanup
@pytest.fixture
def database():
db = create_database()
yield db
db.close() # Cleanup runs after test

Fixture scope

Control when fixtures are created and destroyed:

@pytest.fixture(scope="function") # Default: new for each test
def per_test_data():
return {}


@pytest.fixture(scope="class") # Once per test class
def per_class_data():
return {}


@pytest.fixture(scope="module") # Once per test file
def per_module_data():
return {}


@pytest.fixture(scope="session") # Once per test run
def per_session_data():
return {}

Example: Session-scoped database

@pytest.fixture(scope="session")
def database():
"""Create database once for entire test session."""
db = Database()
db.connect()
yield db
db.disconnect()


@pytest.fixture
def clean_database(database):
"""Clear database before each test."""
database.clear_all()
return database

Parametrized tests

Run the same test with different inputs:

import pytest


@pytest.mark.parametrize("input,expected", [
(1, 1),
(2, 4),
(3, 9),
(4, 16),
])
def test_square(input, expected):
assert input ** 2 == expected

Output:

test_math.py::test_square[1-1] PASSED
test_math.py::test_square[2-4] PASSED
test_math.py::test_square[3-9] PASSED
test_math.py::test_square[4-16] PASSED

Multiple parameters

@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
assert add(a, b) == expected

Named test cases

import pytest


@pytest.mark.parametrize("email,valid", [
pytest.param("user@example.com", True, id="valid_email"),
pytest.param("user", False, id="no_at_symbol"),
pytest.param("@example.com", False, id="no_username"),
pytest.param("user@", False, id="no_domain"),
])
def test_validate_email(email, valid):
assert validate_email(email) == valid

Combining parametrize decorators

@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_combinations(x, y):
# Runs 6 tests: (1,10), (1,20), (2,10), (2,20), (3,10), (3,20)
assert x + y > 0

Test markers

Built-in markers

import pytest


@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
pass


@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9+")
def test_new_python_feature():
pass


@pytest.mark.xfail(reason="Known bug")
def test_known_bug():
assert broken_function() == "expected"

Custom markers

Define in pytest.ini or pyproject.toml:

# pytest.ini
[pytest]
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
api: marks tests that hit external APIs

Usage:

import pytest


@pytest.mark.slow
def test_large_dataset():
# Takes a long time
pass


@pytest.mark.integration
def test_database_connection():
pass

Run specific markers:

pytest -m slow # Only slow tests
pytest -m "not slow" # Skip slow tests
pytest -m "slow or api" # Slow or API tests

conftest.py

Shared fixtures and configuration:

Root conftest.py

# tests/conftest.py
import pytest


@pytest.fixture
def app():
"""Create application for testing."""
from myapp import create_app
return create_app(testing=True)


@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()

Automatic fixtures

Use autouse=True to apply fixtures automatically:

@pytest.fixture(autouse=True)
def reset_environment():
"""Reset environment before each test."""
os.environ.clear()
os.environ.update(ORIGINAL_ENV)
yield
os.environ.clear()
os.environ.update(ORIGINAL_ENV)

Useful fixtures from pytest

tmp_path

def test_write_file(tmp_path):
file = tmp_path / "test.txt"
file.write_text("hello")
assert file.read_text() == "hello"

capsys / capfd

Capture stdout/stderr:

def test_print_output(capsys):
print("hello")
captured = capsys.readouterr()
assert captured.out == "hello\n"

monkeypatch

Modify objects during tests:

def test_with_env_var(monkeypatch):
monkeypatch.setenv("API_KEY", "test123")
assert os.environ["API_KEY"] == "test123"


def test_mock_function(monkeypatch):
def fake_now():
return datetime(2024, 1, 1)

monkeypatch.setattr("mymodule.datetime.now", fake_now)

Plugins

pip install pytest-cov # Coverage reporting
pip install pytest-xdist # Parallel test execution
pip install pytest-mock # Better mocking
pip install pytest-asyncio # Async test support
pip install pytest-timeout # Test timeouts
pip install pytest-randomly # Randomize test order

pytest-cov (Coverage)

pytest --cov=myapp --cov-report=html

pytest-xdist (Parallel)

pytest -n auto # Use all CPU cores
pytest -n 4 # Use 4 workers

pytest-asyncio

import pytest


@pytest.mark.asyncio
async def test_async_function():
result = await async_fetch_data()
assert result == expected

Command-line options

Useful flags

pytest -v # Verbose output
pytest -vv # More verbose
pytest -q # Quiet output
pytest -x # Stop on first failure
pytest --lf # Run only last failed tests
pytest --ff # Run failed tests first
pytest -k "test_user" # Run tests matching pattern
pytest --tb=short # Shorter tracebacks
pytest --tb=no # No tracebacks
pytest --durations=10 # Show 10 slowest tests
pytest --collect-only # Show tests without running

pytest.ini configuration

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
filterwarnings =
ignore::DeprecationWarning
markers =
slow: marks tests as slow
integration: integration tests

Advanced patterns

Fixture factories

@pytest.fixture
def make_user():
"""Factory to create users with custom attributes."""
def _make_user(name="Default", email="default@example.com"):
return User(name=name, email=email)
return _make_user


def test_user_factory(make_user):
user1 = make_user(name="Alice")
user2 = make_user(name="Bob", email="bob@example.com")
assert user1.name == "Alice"
assert user2.email == "bob@example.com"

Request fixture

Access test metadata:

@pytest.fixture
def dynamic_fixture(request):
"""Fixture that adapts based on test."""
marker = request.node.get_closest_marker("db_type")
db_type = marker.args[0] if marker else "sqlite"
return create_database(db_type)


@pytest.mark.db_type("postgresql")
def test_with_postgres(dynamic_fixture):
pass

Indirect parametrization

Parametrize fixtures:

@pytest.fixture
def user(request):
return User(name=request.param)


@pytest.mark.parametrize("user", ["Alice", "Bob"], indirect=True)
def test_user_greeting(user):
assert user.name in user.greeting()

Testing patterns

Arrange-Act-Assert

def test_user_can_change_password(database, make_user):
# Arrange
user = make_user(email="user@example.com")
database.save(user)

# Act
user.change_password("newpassword")
database.save(user)

# Assert
saved_user = database.get_user(user.id)
assert saved_user.check_password("newpassword")

Test classes with shared setup

class TestUserAuthentication:
@pytest.fixture(autouse=True)
def setup(self, database, make_user):
self.user = make_user(password="secret123")
database.save(self.user)

def test_correct_password(self):
assert self.user.check_password("secret123")

def test_wrong_password(self):
assert not self.user.check_password("wrong")

Wrapping up

We've covered:

  • Fixtures - Provide test dependencies with setup/cleanup
  • Fixture scope - Control fixture lifetime
  • @pytest.mark.parametrize - Run tests with different inputs
  • Markers - Categorize and control test execution
  • conftest.py - Share fixtures across tests
  • Plugins - Extend pytest functionality
  • Command-line options - Control test runs

Key takeaways

  1. Use fixtures for test dependencies
  2. Parametrize to reduce test duplication
  3. Use markers to organize tests
  4. Put shared fixtures in conftest.py
  5. Use plugins for common needs
  6. Master the command-line for productivity

pytest is a powerful testing framework. These features will help you write cleaner, more maintainable tests!