Python Testing with pytest: Complete Guide to Unit and Integration Tests

!DOCTYPE html> Python Testing with pytest: The Complete Professional Guide

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.

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

ScopeCreated Once PerUse When
function (default)Each test functionMost cases
classTest classRelated tests share state
moduleTest fileExpensive setup (DB schema)
sessionEntire test runExternal 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

Post a Comment

0 Comments