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...
| Outil | Usage | Commande |
|---|---|---|
| pytest | Tests unitaires | pytest |
| pytest-cov | Couverture | pytest --cov=src |
| pytest-asyncio | Tests async | Auto avec config |
| hypothesis | Tests propriétés | @given(...) |
| tox | Multi-versions | tox |
| nox | Sessions flexibles | nox |
| testcontainers | Intégration | with PostgresContainer(): |
| ruff | Linting | ruff check src/ |