Introduction
pytest is Python's most widely used test framework, offering minimal boilerplate, a rich fixture system, and a broad plugin ecosystem. Beyond unit tests, integration tests verify that modules work correctly with real dependencies.
This article covers fixture scopes, conftest.py organization, mocking with unittest.mock and pytest-mock, database and HTTP client integration, and test isolation in CI. We focus on deterministic data and transaction rollback patterns to reduce flaky tests.
pytest Basics and Fixtures
pytest auto-discovers test functions; assert statements produce detailed comparison output. @pytest.fixture defines shared setup and teardown. The scope parameter (function, class, module, session) controls how often the fixture is recreated.
Yield fixtures are ideal for post-test cleanup: open a database connection, run the test, then rollback or close after yield. autouse=True injects fixtures into every test automatically for shared configuration.
import pytest
@pytest.fixture
def db_session():
session = create_session()
yield session
session.rollback()
session.close()
def test_create_user(db_session):
user = User(name="Ali")
db_session.add(user)
db_session.commit()
assert user.id is not Noneconftest.py and Test Organization
conftest.py files share fixtures hierarchically by test directory; tests/conftest.py applies to all nested tests. Separating heavy integration tests under tests/integration/ enables a fast unit loop with pytest -m 'not integration'.
@pytest.mark.parametrize runs the same test with multiple inputs, reducing duplication. Define markers, testpaths, and addopts in pytest.ini or pyproject.toml.
- tests/unit/: fast unit tests
- tests/integration/: DB and API tests
- conftest.py: shared fixtures
- markers: integration, slow, smoke
Mocking and monkeypatch
unittest.mock.patch or pytest-mock's mocker fixture isolates external services. @patch('mymodule.requests.get') makes HTTP calls return fake responses. MagicMock defines return values and side effects.
pytest monkeypatch temporarily changes environment variables and object attributes; changes revert after the test. Keep mocks minimal in integration tests; mock only uncontrollable externals (payment gateway, email) and use a real database for your own data layer.
def test_fetch_user(mocker):
mock_get = mocker.patch("app.client.requests.get")
mock_get.return_value.json.return_value = {"id": 1, "name": "Ali"}
result = fetch_user(1)
assert result["name"] == "Ali"
mock_get.assert_called_once_with("/users/1")Database Integration Tests
The test database must be separate from production; spin up postgres:16 with Docker Compose in CI. In SQLAlchemy, a transaction fixture runs each test inside a transaction and rolls back afterward so data does not accumulate.
pytest-django and pytest-asyncio provide framework-specific integration. Generate test data with Factory Boy or Faker; tests tied to fixed IDs are brittle. Run Alembic migrations in the test environment to verify schema compatibility.
API and HTTP Integration Tests
FastAPI TestClient or httpx.AsyncClient test endpoints without starting a server. Starlette's TestClient calls the ASGI app directly. If real TCP is required, pytest can start uvicorn in a subprocess.
responses or httpx mock transport simulates external API dependencies. Contract tests (Pact) verify consumer and provider API agreements; they catch regressions early in microservice architectures.
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}CI, Coverage, and Flaky Tests
pytest --cov=app --cov-report=xml produces coverage reports; covering critical paths is more realistic than chasing 100%. pytest-xdist parallelizes tests (-n auto) to shorten runtime; watch shared fixture scopes.
Flaky tests usually stem from timing, ordering, or shared state. pytest-rerunfailures retries transient failures; it is not a permanent fix without addressing root cause. Ensure isolation between tests and use a fixed seed (random.seed).
- Keep each test independent and repeatable
- Separate integration tests with markers
- Use real services or testcontainers in CI
- Isolate and fix flaky tests, do not hide them
Conclusion
Well-organized integration tests with pytest increase refactor confidence and deployment speed. Fixture hierarchy, correct mock boundaries, and an isolated test database are the foundation of a sustainable test suite.
Maintain the test pyramid: mostly fast unit tests, a few critical integration tests. Writing a failing test first when adding features (TDD) reduces long-term regression cost.