Testing
pytest, fixtures, mocking, coverage, TDD in Python
pytest is the standard testing framework for modern Python. Its fixture system, powerful assertion rewriting, and rich plugin ecosystem make it far more ergonomic than the built-in unittest. Combined with mocking (unittest.mock), coverage measurement, and Testcontainers, Python has a complete testing story at every level.
Key Points
- pytest auto-discovers test files matching test_*.py or *_test.py and functions matching test_*
- assert statement: pytest rewrites assertions to provide detailed failure messages — no self.assertEqual needed
- Fixtures: @pytest.fixture — dependency injection for tests; scopes: function (default), class, module, session
- @pytest.mark.parametrize: run the same test with multiple inputs — reduces duplicate test code
- unittest.mock.MagicMock: auto-speccing mock objects; patch() / patch.object() context manager to replace dependencies
- conftest.py: shared fixtures and plugins — automatically discovered by pytest, no imports needed
- pytest-cov: measure code coverage — pytest --cov=src --cov-report=html
- freezegun: freeze datetime.now() in tests — deterministic time-dependent tests
- pytest-asyncio: test async functions — @pytest.mark.asyncio
pytest: fixtures with teardown, parametrize, mocking with patch, async tests, Testcontainers
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
# Basic fixture
@pytest.fixture
def db_session():
session = create_test_session()
yield session # teardown runs after yield
session.rollback()
session.close()
# Parametrize — test multiple cases
@pytest.mark.parametrize("input,expected", [
("hello", 5),
("", 0),
("python", 6),
])
def test_string_length(input, expected):
assert len(input) == expected
# Mocking — replace external dependency
def test_send_email(mocker): # pytest-mock plugin
mock_smtp = mocker.patch("myapp.mailer.smtplib.SMTP")
send_welcome_email("alice@example.com")
mock_smtp.return_value.__enter__.return_value.sendmail.assert_called_once()
# patch as context manager
def test_payment_gateway():
with patch("myapp.payments.stripe.charge") as mock_charge:
mock_charge.return_value = {"status": "succeeded"}
result = process_payment(100, "card_123")
assert result.success is True
mock_charge.assert_called_with(amount=100, source="card_123")
# Async test
@pytest.mark.asyncio
async def test_fetch_user(async_client, db_session):
response = await async_client.get("/users/1")
assert response.status_code == 200
assert response.json()["name"] == "Alice"
# conftest.py — shared fixtures
@pytest.fixture(scope="session")
def docker_db():
from testcontainers.postgres import PostgresContainer
with PostgresContainer("postgres:16") as pg:
yield pg.get_connection_url()Real-World Example
The fixture scope hierarchy (function < class < module < session) is key for test performance. A session-scoped Docker Postgres container starts once for the entire test run (30 seconds startup amortised over 1000 tests) vs once per test (30 second × 1000 = 8 hours). Use session scope for expensive resources, function scope for data that must be isolated between tests.