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.