Python Testing with pytest: The Complete Professional Guide
From writing your first test to achieving 90%+ coverage -- fixtures, parametrize, mocking, async tests, and CI integration all in one guide.
Contents
Setup and First Test
pip install pytest pytest-cov pytest-asyncio pytest-mock
# src/calculator.py
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# tests/test_calculator.py
from src.calculator import divide
def test_divide_normal():
assert divide(10, 2) == 5.0
def test_divide_float():
assert divide(7, 2) == 3.5
def test_divide_negative():
assert divide(-9, 3) == -3.0
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
Fixtures -- The Core of pytest
Fixtures set up resources shared across tests. They replace setup/teardown methods with clean, composable dependency injection.
import pytest
import tempfile, os
@pytest.fixture
def sample_csv(tmp_path):
"""Create a temp CSV file for tests."""
file = tmp_path / "data.csv"
file.write_text("name,age\nAlice,30\nBob,25\n")
return str(file)
@pytest.fixture(scope="session")
def db_connection():
"""One DB connection for the entire test session."""
conn = create_test_db()
yield conn
conn.close() # teardown runs after yield
@pytest.fixture
def api_client(db_connection):
"""Fixtures can depend on other fixtures."""
from myapp.main import app
from fastapi.testclient import TestClient
app.state.db = db_connection
return TestClient(app)
Fixture Scopes
| Scope | Created Once Per | Use When |
|---|---|---|
function (default) | Each test function | Most cases |
class | Test class | Related tests share state |
module | Test file | Expensive setup (DB schema) |
session | Entire test run | External connections, servers |
Parametrize -- Test Multiple Cases
import pytest
from src.calculator import divide
@pytest.mark.parametrize("a, b, expected", [
(10, 2, 5.0),
(7, 2, 3.5),
(-9, 3, -3.0),
(0, 5, 0.0),
(100, 4, 25.0),
])
def test_divide_cases(a, b, expected):
assert divide(a, b) == expected
# Run 5 separate named tests from one function
# pytest -v -> test_divide_cases[10-2-5.0] PASSED, ...
# Combining multiple parametrize decorators (cartesian product)
@pytest.mark.parametrize("codec", ["utf-8", "latin-1", "utf-16"])
@pytest.mark.parametrize("mode", ["r", "rb"])
def test_file_read(tmp_path, codec, mode):
# Tests all 6 combinations automatically
...
Mocking External Dependencies
Never call real APIs, databases, or external services in unit tests. Use mocks.
# src/weather.py
import requests
def get_temperature(city: str) -> float:
resp = requests.get(f"https://api.weather.com/current?city={city}")
resp.raise_for_status()
return resp.json()["temp_c"]
# tests/test_weather.py
from unittest.mock import patch, MagicMock
from src.weather import get_temperature
def test_get_temperature_success():
mock_response = MagicMock()
mock_response.json.return_value = {"temp_c": 22.5}
mock_response.raise_for_status.return_value = None
with patch("src.weather.requests.get", return_value=mock_response) as mock_get:
temp = get_temperature("London")
assert temp == 22.5
mock_get.assert_called_once_with(
"https://api.weather.com/current?city=London"
)
def test_get_temperature_api_failure():
with patch("src.weather.requests.get") as mock_get:
mock_get.side_effect = requests.exceptions.ConnectionError("No connection")
with pytest.raises(requests.exceptions.ConnectionError):
get_temperature("London")
pytest-mock (cleaner syntax)
def test_with_mocker(mocker):
mock_get = mocker.patch("src.weather.requests.get")
mock_get.return_value.json.return_value = {"temp_c": 18.0}
mock_get.return_value.raise_for_status.return_value = None
assert get_temperature("Paris") == 18.0
mock_get.assert_called_once()
Testing Exceptions
import pytest
from src.calculator import divide
def test_divide_by_zero_raises():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
def test_exception_attributes():
with pytest.raises(ValueError) as exc_info:
divide(5, 0)
assert "zero" in str(exc_info.value).lower()
assert exc_info.type is ValueError
Async Tests
pip install pytest-asyncio
# pytest.ini
[pytest]
asyncio_mode = auto
import pytest
import aiohttp
from unittest.mock import AsyncMock, patch
async def fetch_user(user_id: int) -> dict:
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.example.com/users/{user_id}") as resp:
return await resp.json()
@pytest.mark.asyncio
async def test_fetch_user():
mock_session = AsyncMock()
mock_resp = AsyncMock()
mock_resp.json.return_value = {"id": 1, "name": "Alice"}
mock_session.__aenter__.return_value.get.return_value.__aenter__.return_value = mock_resp
with patch("aiohttp.ClientSession", return_value=mock_session):
result = await fetch_user(1)
assert result["name"] == "Alice"
Coverage Reports
# Run tests with coverage
pytest --cov=src --cov-report=term-missing --cov-report=html
# Output shows uncovered lines:
# src/calculator.py 95% Missing: line 24
# HTML report at htmlcov/index.html -- open in browser
# .coveragerc -- enforce minimum coverage
[coverage:report]
fail_under = 85
exclude_lines =
pragma: no cover
if __name__ == .__main__.:
raise NotImplementedError
CI Integration -- GitHub Actions
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: "${{ matrix.python-version }}"}
- run: pip install -r requirements.txt
- run: pytest --cov=src --cov-report=xml -v
- uses: codecov/codecov-action@v4
with: {files: ./coverage.xml}
[YES] Best practices: Write tests before fixing bugs (regression tests). Aim for 85%+ coverage. Keep unit tests fast (<1ms each). Use integration tests for DB/API -- keep them separate from unit tests.
Key Takeaways: pytest fixtures replace setup/teardown cleanly. Use parametrize to test edge cases systematically. Mock external dependencies so tests are fast and deterministic. Enforce coverage minimums in CI.
PythonpytestTestingTDDMockingCI/CDCode Coverage
0 Comments