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
- pytest sees
databaseparameter in test function - Finds matching fixture
- Runs fixture code before
yield - Provides yield value to test
- 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
Popular 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
- Use fixtures for test dependencies
- Parametrize to reduce test duplication
- Use markers to organize tests
- Put shared fixtures in conftest.py
- Use plugins for common needs
- Master the command-line for productivity
pytest is a powerful testing framework. These features will help you write cleaner, more maintainable tests!