MFormations
Modern Python Engineering

Chapitre 4

04-Testing-Python

04-Testing-Python

Testing Python — Cours Complet

1. pytest — Le Framework de Test Moderne

Installation et Configuration

pip install pytest pytest-cov pytest-asyncio pytest-mock
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
asyncio_mode = "auto"
addopts = "-v --cov=src --cov-report=term-missing"

Tests de Base

# tests/test_math.py
def test_addition() -> None:
    assert 1 + 1 == 2

def test_string_methods() -> None:
    assert "hello".upper() == "HELLO"
    assert "hello".capitalize() == "Hello"

# Test avec exception
import pytest

def test_division_by_zero() -> None:
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_custom_exception_message() -> None:
    with pytest.raises(ValueError, match="must be positive"):
        int("not-a-number")

Fixtures

# tests/conftest.py — fixtures partagées
import pytest
from typing import Generator
from pathlib import Path
import tempfile

@pytest.fixture
def temp_dir() -> Generator[Path, None, None]:
    """Fixture qui crée un dossier temporaire."""
    with tempfile.TemporaryDirectory() as tmp:
        yield Path(tmp)

@pytest.fixture
def sample_data() -> dict[str, int]:
    """Fixture simple (pas de cleanup nécessaire)."""
    return {"a": 1, "b": 2, "c": 3}

@pytest.fixture(scope="session")
def db_connection() -> Generator[str, None, None]:
    """Fixture session — une fois par session de test."""
    print("\nConnecting to database...")
    conn = "db_connection_string"
    yield conn
    print("\nClosing database connection...")
# tests/test_fixtures.py
def test_temp_dir(temp_dir: Path) -> None:
    test_file = temp_dir / "test.txt"
    test_file.write_text("hello")
    assert test_file.read_text() == "hello"

def test_sample_data(sample_data: dict) -> None:
    assert sample_data["a"] == 1

Parametrize

import pytest

@pytest.mark.parametrize("input_val,expected", [
    (1, 2),
    (2, 4),
    (3, 6),
    (10, 20),
])
def test_double(input_val: int, expected: int) -> None:
    assert input_val * 2 == expected

# Paramétrisation multiple
@pytest.mark.parametrize("a,b,expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
])
class TestAddition:
    def test_add(self, a: int, b: int, expected: int) -> None:
        assert a + b == expected

# IDs personalisées
@pytest.mark.parametrize("text,expected", [
    ("hello", "HELLO"),
    ("world", "WORLD"),
    ("Python", "PYTHON"),
], ids=["lowercase", "lowercase2", "capitalized"])
def test_upper(text: str, expected: str) -> None:
    assert text.upper() == expected

Marks

import pytest

@pytest.mark.slow
def test_heavy_computation() -> None:
    import time
    time.sleep(5)
    assert True

@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature() -> None:
    ...

@pytest.mark.skipif(
    "sys.version_info < (3, 12)",
    reason="Requires Python 3.12+"
)
def test_new_feature() -> None:
    ...

@pytest.mark.xfail(reason="Known bug #123")
def test_known_failure() -> None:
    assert 1 == 2  # expected to fail

# Exécution sélective
# pytest -m slow
# pytest -m "not slow"
# pytest -m "slow or db"

2. unittest — Le Framework Standard

TestCase

import unittest
from unittest.mock import Mock, patch

class TestMathOperations(unittest.TestCase):
    def setUp(self) -> None:
        """Prépare l'environnement (avant chaque test)."""
        self.values = [1, 2, 3, 4, 5]

    def tearDown(self) -> None:
        """Nettoie après chaque test."""
        pass

    @classmethod
    def setUpClass(cls) -> None:
        """Une fois avant tous les tests."""
        cls.shared_resource = "database"

    def test_sum(self) -> None:
        self.assertEqual(sum(self.values), 15)

    def test_max(self) -> None:
        self.assertEqual(max(self.values), 5)

    def test_contains(self) -> None:
        self.assertIn(3, self.values)

    def test_exception(self) -> None:
        with self.assertRaises(ValueError):
            int("not-a-number")

Mock et Patch

from unittest.mock import Mock, patch, MagicMock

# Mock basique
mock = Mock()
mock.return_value = 42
assert mock() == 42

# Mock avec side_effect
mock = Mock()
mock.side_effect = [1, 2, 3]
assert mock() == 1
assert mock() == 2
assert mock() == 3

# patch
class Database:
    def query(self, sql: str) -> list:
        return [{"id": 1}]

class Service:
    def __init__(self):
        self.db = Database()

    def get_user(self, user_id: int) -> dict:
        result = self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
        return result[0] if result else {}

@patch("path.to.Database.query")
def test_get_user(mock_query: MagicMock) -> None:
    mock_query.return_value = [{"id": 1, "name": "Alice"}]
    service = Service()
    user = service.get_user(1)
    assert user["name"] == "Alice"
    mock_query.assert_called_once()

3. doctest — Tests dans la Documentation

def factorial(n: int) -> int:
    """Calcule la factorielle de n.

    >>> factorial(0)
    1
    >>> factorial(5)
    120
    >>> factorial(3)
    6
    """
    if n == 0:
        return 1
    return n * factorial(n - 1)

# Exécution
if __name__ == "__main__":
    import doctest
    doctest.testmod()

4. Property-Based Testing (Hypothesis)

from hypothesis import given, strategies as st, assume

# Test de propriétés
@given(st.integers(), st.integers())
def test_commutative_addition(a: int, b: int) -> None:
    assert a + b == b + a

@given(st.lists(st.integers()))
def test_reverse_twice_is_identity(lst: list[int]) -> None:
    assert lst[::-1][::-1] == lst

# Stratégies avancées
@given(
    name=st.text(min_size=1, max_size=50),
    age=st.integers(min_value=0, max_value=150),
    email=st.emails(),
)
def test_user_creation(name: str, age: int, email: str) -> None:
    user = {"name": name, "age": age, "email": email}
    assume("@" in email)  # assume = précondition
    assert "@" in user["email"]

@given(st.lists(st.integers()))
def test_sorting_stability(lst: list[int]) -> None:
    sorted_lst = sorted(lst)
    assert len(sorted_lst) == len(lst)
    assert all(sorted_lst[i] <= sorted_lst[i+1] for i in range(len(sorted_lst)-1))

# Strategies personnalisées
Point = st.tuples(st.floats(), st.floats())
@given(Point, Point)
def test_distance_properties(p1: tuple, p2: tuple) -> None:
    x1, y1 = p1
    x2, y2 = p2
    dist = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
    assert dist >= 0  # La distance est toujours positive

5. Coverage

# pyproject.toml
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/migrations/*"]

[tool.coverage.report]
show_missing = true
fail_under = 80
skip_empty = true
# Exécution
pytest --cov=src --cov-report=html --cov-report=term-missing

# Vérification
coverage report
coverage html  # génère htmlcov/index.html

6. Async Tests

import pytest

# pytest-asyncio — mode auto dans pyproject.toml
async def test_async_function() -> None:
    import asyncio
    result = await asyncio.sleep(0.1, result=42)
    assert result == 42

# Fixture async
@pytest.fixture
async def async_resource() -> str:
    import asyncio
    await asyncio.sleep(0.1)
    return "resource"

async def test_with_async_fixture(async_resource: str) -> None:
    assert async_resource == "resource"

# Test d'exception async
async def test_async_exception() -> None:
    import asyncio
    with pytest.raises(asyncio.TimeoutError):
        await asyncio.wait_for(
            asyncio.sleep(10),
            timeout=0.1,
        )

7. Test Infrastructure

tox — Multi-Versions

# tox.ini
[tox]
envlist = py310, py311, py312

[testenv]
deps =
    pytest
    pytest-cov
    pytest-asyncio
commands =
    pytest tests/ --cov=src

[testenv:lint]
deps = ruff
commands = ruff check src/ tests/

nox — Flexible

# noxfile.py
import nox

@nox.session(python=["3.10", "3.11", "3.12"])
def tests(session: nox.Session) -> None:
    session.install(".[dev]")
    session.run("pytest", "tests/", "--cov=src")

@nox.session
def lint(session: nox.Session) -> None:
    session.install("ruff")
    session.run("ruff", "check", "src/", "tests/")

@nox.session
def typecheck(session: nox.Session) -> None:
    session.install("mypy")
    session.run("mypy", "src/")

8. TDD (Test-Driven Development)

# Étape 1 : Écrire le test
def test_calculate_bmi() -> None:
    assert calculate_bmi(weight=70, height=1.75) == pytest.approx(22.86, rel=0.01)
    assert calculate_bmi(weight=0, height=1.75) == 0
    with pytest.raises(ValueError, match="Height must be positive"):
        calculate_bmi(weight=70, height=0)

# Étape 2 : Écrire l'implémentation minimale
def calculate_bmi(weight: float, height: float) -> float:
    if height <= 0:
        raise ValueError("Height must be positive")
    if weight <= 0:
        return 0.0
    return weight / (height ** 2)

# Étape 3 : Refactor
# Le code est déjà propre — pass

9. Integration Tests

testcontainers

from testcontainers.postgres import PostgresContainer
import pytest
import asyncpg

@pytest.mark.integration
async def test_database_operations() -> None:
    with PostgresContainer("postgres:16") as pg:
        conn = await asyncpg.connect(
            host=pg.get_container_host_ip(),
            port=pg.get_container_exposed_port(5432),
            user=pg.USER,
            password=pg.PASSWORD,
            database=pg.DB,
        )
        await conn.execute("CREATE TABLE test (id int)")
        await conn.execute("INSERT INTO test VALUES (1)")
        result = await conn.fetch("SELECT * FROM test")
        assert len(result) == 1
        await conn.close()

pytest-docker

# conftest.py
import pytest

@pytest.fixture(scope="session")
def docker_compose_files(pytestconfig):
    return ["docker-compose.test.yml"]

@pytest.fixture(scope="session")
def database_url(docker_services):
    url = docker_services.wait_for_service("db", 5432)
    return f"postgresql://user:pass@localhost:{url.port}/testdb"

10. Boucles de Rétroaction

Diagramme en cours de génération...
OutilUsageCommande
pytestTests unitairespytest
pytest-covCouverturepytest --cov=src
pytest-asyncioTests asyncAuto avec config
hypothesisTests propriétés@given(...)
toxMulti-versionstox
noxSessions flexiblesnox
testcontainersIntégrationwith PostgresContainer():
ruffLintingruff check src/