Maîtriser pytest : guide complet du développeur Python (2/3)

Après avoir exploré les fondamentaux de pytest dans mon premier article, je vais maintenant nous pencher sur l'organisation et la structuration des tests à plus grande échelle. La qualité d'une suite de tests ne repose pas uniquement sur les tests eux-mêmes, mais aussi sur la façon dont ils sont organisés et maintenus au fil du temps.

Dans cette deuxième partie, j'aborderais les aspects essentiels de l'organisation des tests avec pytest. Nous verrons comment structurer vos tests efficacement en miroir du code source, comment utiliser les décorateurs pour mieux catégoriser et contrôler l'exécution de vos tests, et comment tirer parti des fixtures pour gérer proprement les états et les dépendances.

J'explorerais également les pièges courants à éviter, notamment lors de l'utilisation des scopes de fixtures, pour vous permettre de prendre les bonnes décisions architecturales dès le début de votre projet.

Organisation pratique des tests

Structure en miroir du code source

La première règle fondamentale consiste à organiser nos tests en miroir de la structure de notre code source. Prenons un exemple concret :

projet/
├── src/
│   └── math_func.py
└── tests/
    └── src/
        └── test_math_func.py

Cette organisation en miroir présente plusieurs avantages. Premièrement, elle rend immédiatement évident où trouver les tests pour n'importe quelle partie du code. Deuxièmement, elle facilite la maintenance lorsque le projet grandit, car la structure des tests évolue naturellement avec celle du code source.

Impact sur le cycle de développement

Cette approche structurée transforme les tests d'une tâche fastidieuse en un outil de développement puissant. Elle permet aux développeurs de :

  1. Retrouver rapidement les tests pertinents lors de modifications du code.
  2. Ajouter de nouveaux tests sans perturber l'existant.
  3. Faciliter la revue de code en rendant la structure des tests immédiatement compréhensible.

Les décorateurs et leur utilisation

@pytest.mark pour le marquage

Les décorateurs pytest permettent de catégoriser et organiser nos tests. Prenons un exemple concret :

@pytest.mark.critical
def test_user_authentication():
    # Test de fonctionnalité critique
    pass

@pytest.mark.quick
def test_password_validation():
    # Test rapide
    pass

Ces marqueurs personnalisés nous permettent d'exécuter des sous-ensembles spécifiques de tests. Par exemple, on peut lancer uniquement les tests critiques sur un environnement de production avec :

pytest -v -m critical

Ou bien exécuter les tests rapides pendant le développement avec :

pytest -v -m quick

skip et skipif pour la gestion conditionnelle

Le décorateur @pytest.mark.skip permet d'ignorer temporairement certains tests :

@pytest.mark.skip(reason="Fonctionnalité en cours de refactoring")
def test_new_feature():
    pass

Pour des conditions plus complexes, @pytest.mark.skipif offre une flexibilité supplémentaire :

@pytest.mark.skipif(
    sys.version_info < (3, 8), 
    reason="Nécessite Python 3.8 ou supérieur"
)
def test_new_syntax():
    pass

Le décorateur skipif, bien que puissant, doit être utilisé avec discernement. Son utilisation systématique peut créer un faux sentiment de sécurité : lorsqu'une suite de tests est marquée comme "réussie" dans un pipeline d'intégration continue, les équipes ont tendance à ne pas vérifier les tests qui ont été ignorés. Cette pratique peut masquer des lacunes importantes dans la couverture des tests.

Il existe cependant des cas légitimes d'utilisation de skipif, notamment pour les tests qui dépendent d'infrastructures spécifiques. Par exemple, des tests d'intégration end-to-end interagissant avec des services cloud externes pourraient utiliser une condition comme skipif(env == "dev") pour ne s'exécuter que dans les environnements appropriés. Dans ces situations, il est important de documenter clairement pourquoi certains tests sont conditionnels et de mettre en place des mécanismes de surveillance pour s'assurer que ces tests sont effectivement exécutés dans les environnements ciblés.

parametrize pour les tests paramétrés

Le décorateur '@pytest.mark.parametrize' est un outil qui permet de réutiliser une même logique de test avec différentes valeurs d'entrée. Imaginons que nous testons une fonction d'addition :

from typing import int

def add(x: int, y: int) -> int:
    return x + y

@pytest.mark.parametrize('x, y, expected', [
    (5, 3, 8),      # Cas simple
    (0, 7, 7),      # Addition avec zéro
    (-2, 5, 3),     # Nombre négatif
    (10, 20, 30)    # Grands nombres
])
def test_add(x: int, y: int, expected: int):
    assert add(x, y) == expected

Ce décorateur fonctionne comme une boucle qui va exécuter le test pour chaque jeu de données. Dans cet exemple, pytest va lancer quatre tests différents avec nos quatre jeux de valeurs. C'est comme si nous avions écrit :

def test_add_simple():
    assert add(5, 3) == 8

def test_add_with_zero():
    assert add(0, 7) == 7

def test_add_with_negative():
    assert add(-2, 5) == 3

def test_add_large_numbers():
    assert add(10, 20) == 30

L'avantage est double : notre code de test est plus concis, et il est plus facile d'ajouter de nouveaux cas de test - il suffit d'ajouter un nouveau tuple dans la liste des paramètres. Si un test échoue, pytest nous indiquera précisément quel jeu de données a posé problème.

Options de ligne de commande

Contrôle de l'exécution

pytest -x                  # Arrête à la première erreur
pytest --maxfail=2        # Arrête après 2 erreurs
pytest -tb=no             # Désactive les tracebacks

Verbosité et filtrage

Pytest offre plusieurs niveaux de verbosité qui permettent de contrôler la quantité de détails dans les rapports de tests.

# Mode silencieux : affiche uniquement un résumé minimal
pytest -q                

# Mode verbeux simple : détaille chaque test avec troncature des différences
pytest -v                 

# Mode verbeux complet : affiche l'intégralité des différences
pytest -vv               

# Mode très verbeux : similaire à -vv, rarement utile en pratique
pytest -vvv

Le mode -v est souvent le plus pratique au quotidien car il fournit suffisamment d'informations pour identifier rapidement les problèmes tout en maintenant une sortie concise. Pour les cas où vous devez analyser en détail les différences entre les valeurs attendues et obtenues, le mode -vv devient utile car il affiche les différences dans leur intégralité.

Le mode silencieux (-q) est particulièrement adapté aux environnements d'intégration continue où une sortie concise est préférable, tandis que le niveau de verbosité maximal (-vvv) n'apporte généralement pas d'informations supplémentaires significatives par rapport au mode -vv.

Pour selectionner des tests à exécuter, pytest propose également l'option -m :

# Exécute uniquement les tests marqués avec @pytest.mark.integration
pytest -m integration

Techniques avancées de test

Setup et Teardown : préparation et nettoyage

Le setup prépare l'environnement nécessaire avant l'exécution des tests :

class TestDatabase:
    def setup_method(self):
        # Exécuté avant chaque méthode de test
        self.db = Database()
        self.db.connect()
        self.initial_data = [{"id": 1, "name": "Test"}]
        self.db.insert(self.initial_data)

Le teardown assure le nettoyage après les tests pour éviter les effets de bord :

class TestDatabase:
    def teardown_method(self):
        # Exécuté après chaque méthode de test
        self.db.clear_all_data()
        self.db.disconnect()

Les fixtures : une approche différente au setup/teardown

Cependant, les fixtures pytest offrent, selon moi, une approche plus élégante que le setup/teardown traditionnel ! 

Les fixtures sont une fonctionnalité clé de pytest qui permet de gérer l'état et les dépendances de nos tests. Elles permettent de :

  1. Préparer un état initial avant un test
  2. Fournir des données ou des objets aux tests qui en ont besoin
  3. Nettoyer proprement après l'exécution du test

@pytest.fixture
def database() -> Database:
    # Setup
    db = Database()
    db.connect('data.json')
    
    yield db
    
    # Teardown automatique
    db.close()

def test_database_query(database: Database):
    result = database.query("SELECT * FROM users")
    assert result is not None

L'utilisation de 'yield' dans la fixture est particulièrement élégante : tout ce qui suit le 'yield' est automatiquement exécuté après le test, même en cas d'erreur.

Les fixtures sont très flexibles grâce à plusieurs fonctionnalités :

  1. Le paramètre scope contrôle leur durée de vie (function, class, module ou session)
  2. Elles peuvent dépendre d'autres fixtures, créant ainsi des chaînes de dépendances
  3. L'utilisation de yield sépare clairement la phase de setup du teardown
  4. L'injection de dépendance est explicite dans la signature des tests

Un avantage majeur de pytest est qu'il fournit un ensemble de fixtures prêtes à l'emploi qui couvrent les besoins les plus courants :

  • tmp_path : crée un répertoire temporaire isolé pour chaque test
  • monkeypatch : permet de modifier temporairement l'environnement d'exécution
  • capsys : capture les sorties standard (stdout/stderr)
  • caplog : intercepte les messages de logging

# Exemple avec tmp_path : gestion des fichiers temporaires
def test_file_processing(tmp_path):
    # tmp_path fournit un répertoire temporaire unique
    data_file = tmp_path / "data.txt"
    data_file.write_text("test content")
    result = process_file(data_file)
    assert result == "PROCESSED: test content"

# Exemple avec monkeypatch : modification temporaire de l'environnement
def test_api_call(monkeypatch):
    # Simule une variable d'environnement
    monkeypatch.setenv("API_KEY", "test_key")
    # Remplace une fonction par une version de test
    monkeypatch.setattr("requests.get", lambda x: MockResponse())
    result = call_api()
    assert result.status_code == 200

# Exemple avec capsys : capture des sorties standard
def test_print_function(capsys):
    print("Hello World")
    log_error("Error message")
    captured = capsys.readouterr()
    assert "Hello World" in captured.out
    assert "Error message" in captured.err

# Exemple avec caplog : capture des logs
def test_logging_behavior(caplog):
    # Configure le niveau de capture des logs
    caplog.set_level(logging.INFO)
    process_data({"id": 123})
    assert "Processing data for id: 123" in caplog.text
    assert caplog.records[0].levelname == "INFO"

Cette réutilisabilité des fixtures, qu'elles soient personnalisées ou intégrées, permet de construire des tests maintenables et expressifs.

Les scopes de fixtures : attention aux effets de bord

Le scope d'une fixture définit sa durée de vie et sa portée de réutilisation dans la suite de tests. Par exemple, une fixture avec un scope "function" est recréée pour chaque fonction de test, tandis qu'une fixture avec un scope "module" est partagée entre tous les tests d'un même module.

Les scopes des fixtures méritent une attention particulière. Bien que pytest offre plusieurs niveaux de portée (function, class, module, session), ces portées plus larges que 'function' introduisent des risques subtils dans votre suite de tests.

Pour comprendre ces risques, examinons un exemple concret illustrant comment un scope mal choisi peut compromettre l'intégrité de vos tests :

@pytest.fixture(scope="module")
def shared_data():
    # Cette donnée sera partagée par tous les tests du module
    data = {"counter": 0}
    return data

def test_increment(shared_data):
    # Premier test modifiant l'état partagé
    shared_data["counter"] += 1
    assert shared_data["counter"] == 1

def test_another_increment(shared_data):
    # Ce second test dépend maintenant de l'ordre d'exécution !
    shared_data["counter"] += 1
    assert shared_data["counter"] == 1  # Échec : counter vaut déjà 1

Ce code illustre plusieurs problèmes caractéristiques des scopes élargis :

L'état partagé crée des dépendances invisibles entre les tests. Dans notre exemple, le résultat de test_another_increment dépend directement de l'exécution préalable de test_increment. Cette interdépendance viole un principe fondamental des tests : chaque test devrait pouvoir s'exécuter de manière isolée.

Ces dépendances peuvent se manifester de manière particulièrement insidieuse :

  • Les tests peuvent réussir ou échouer selon leur ordre d'exécution
  • Un échec peut provenir d'effets de bord d'un test précédent, compliquant considérablement le débogage
  • La maintenance devient plus difficile car modifier un test peut affecter le comportement d'autres tests

Pour maintenir une suite de tests robuste, voici les pratiques recommandées :

  1. Adopter par défaut le scope function

Ce scope garantit que chaque test reçoit une nouvelle instance de la fixture, préservant l'isolation des tests.

  1. Réserver les scopes plus larges à des cas très spécifiques
  2. Documenter explicitement l'utilisation des scopes élargis
  3. Protéger les données partagées

Si un scope élargi est inévitable, assurez-vous que les données partagées ne peuvent pas être modifiées accidentellement par les tests.

La règle d'or reste l'indépendance des tests : quel que soit le scope choisi, chaque test doit pouvoir s'exécuter seul, dans n'importe quel ordre, sans que son résultat en soit affecté. Cette indépendance est essentielle pour maintenir une suite de tests fiable et maintenable sur le long terme.

Conclusion

Dans cette deuxième partie consacrée à pytest, nous avons exploré l'organisation et la structure d'une suite de tests professionnelle. De la structuration du code à l'utilisation des fixtures et des décorateurs, nous avons vu comment construire des tests robustes et maintenables. Ces pratiques et outils vous permettront d'adapter vos tests à tout type de projet, du plus simple au plus complexe.

Dans le prochain article, nous plongerons dans le monde du mocking et des pratiques avancées avec pytest, pour compléter votre maîtrise de cet outil.