Tests de transition d’état

Tester les systèmes dont le comportement dépend de l’état d’un objet et détecter les défauts dans les enchaînements métier.

Mis à jour:

Sommaire

Les tests de transition d'état sont utiles lorsque le comportement du système dépend de l'état actuel d'un objet. Les utilisateurs, les commandes, les tickets, les paiements, les abonnements et les sessions ont tous des statuts et des transitions autorisées.

Quand l'utiliser

  • →Il existe des états explicites tels que créé, actif, bloqué, annulé et terminé.
  • →Certaines transitions sont interdites.
  • →Le comportement et les actions disponibles dépendent de l'état actuel.
  • →Les bogues impliquent souvent des violations du cycle de vie plutôt que des champs individuels.

Que documenter

  • →Les états de l'objet
  • →Transitions autorisées
  • →Événements qui déclenchent des transitions
  • →Restrictions : qui peut initier une transition, dans quelles conditions et ce qui suit

Exemple

Supposons qu'un rapport de bogue ait les statuts Ouvert, En cours, Prêt pour le test, Réouvert et Fermé. Les tests de transition d'état vérifient si la fermeture directe est autorisée, qui peut la rouvrir et si un autre cycle de correctifs préserve la logique correcte.

Ce que QA devrait examiner

  • →Transitions interdites : le système doit les bloquer ou les expliquer de manière appropriée.
  • →Effets secondaires de la transition : notifications, audit, modifications d'accès et journalisation.
  • →Transitions répétées et mouvement vers l’arrière.
  • →États après échec : que se passe-t-il si une opération est interrompue à mi-chemin ?
🔁

Si un objet a un cycle de vie, dessiner même un simple diagramme d’état est généralement utile. Il révèle rapidement des angles morts difficiles à déceler dans une liste de cas.

Exemple de cycle de vie d’une commande avec un contrat explicite

Ce modèle fictif porte sur une seule commande ; il ne décrit pas toutes les boutiques ni toutes les API de paiement. Draft désigne une commande non envoyée, Placed une commande soumise, Paid une commande payée, Shipped une commande expédiée et Cancelled un état terminal. Seules les cinq transitions ci-dessous sont autorisées. Annulations après paiement, remboursements, autorisations, erreurs de transport et requêtes concurrentes exigent des règles supplémentaires.

plain text
Draft --submit--> Placed --pay--> Paid --ship--> Shipped
Draft --cancel--> Cancelled
Placed --cancel--> Cancelled

Version textuelle : submit fait passer de Draft à Placed ; pay de Placed à Paid ; ship de Paid à Shipped. cancel fait passer de Draft ou Placed à Cancelled. Shipped et Cancelled n’ont aucune transition sortante. Toute autre combinaison état/événement est refusée sans changement d’état ni effet métier.

Une table de test complète à événement unique

Avant chaque ligne, remettez la commande dans l’état de la première colonne et utilisez une nouvelle clé de requête. Le compteur mesure les transitions métier enregistrées dans ce modèle ; il ne représente pas un nombre universel de journaux, notifications ou paiements externes. Une transition acceptée l’incrémente de un ; un refus n’ajoute rien.

plain text
État initial	Événement	Résultat	État final	Nouveaux effets
Draft	submit	accepté	Placed	1
Draft	pay	refusé	Draft	0
Draft	ship	refusé	Draft	0
Draft	cancel	accepté	Cancelled	1
Placed	submit	refusé	Placed	0
Placed	pay	accepté	Paid	1
Placed	ship	refusé	Placed	0
Placed	cancel	accepté	Cancelled	1
Paid	submit	refusé	Paid	0
Paid	pay	refusé	Paid	0
Paid	ship	accepté	Shipped	1
Paid	cancel	refusé	Paid	0
Shipped	submit	refusé	Shipped	0
Shipped	pay	refusé	Shipped	0
Shipped	ship	refusé	Shipped	0
Shipped	cancel	refusé	Shipped	0
Cancelled	submit	refusé	Cancelled	0
Cancelled	pay	refusé	Cancelled	0
Cancelled	ship	refusé	Cancelled	0
Cancelled	cancel	refusé	Cancelled	0

Les nouvelles tentatives relèvent d’un contrat distinct, pas d’un état supplémentaire

Pour ce modèle, une requête réussie stocke son événement et son résultat sous une clé limitée à cette commande. La répétition de la même clé et du même événement renvoie le résultat stocké sans nouvel effet secondaire. La réutilisation d'une clé réussie pour un événement différent renvoie un conflit de clés. Les demandes rejetées ne sont pas stockées. Cette règle intentionnellement simplifiée ne constitue pas une affirmation concernant un code de statut HTTP ou un véritable fournisseur de paiement.

plain text
Séquence	Réponse attendue	État actuel	Total des effets enregistrés
submit / s1	accepté, Placed	Placed	1
pay / p1	accepté, Paid	Paid	2
pay / p1 à nouveau	accepté, Paid (résultat enregistré)	Paid	2
ship / p1	key-conflict, Paid	Paid	2
pay / p2	refusé, Paid	Paid	2
ship / s2	accepté, Shipped	Shipped	3

Un résultat mémorisé représente la requête initiale, pas forcément l’état actuel de la commande après des transitions ultérieures. Une vraie API doit préciser la portée et la durée de conservation des clés, la comparaison des données, l’ordre des opérations et la persistance atomique. Ce modèle pédagogique en mémoire ne démontre pas une protection contre des débits simultanés en double.

Exécuter le modèle de référence localement

Enregistrez le code dans order_state_model.py et exécutez python3 order_state_model.py. Il vérifie les 20 combinaisons état/événement, puis la séquence de nouvelles tentatives. Sortie attendue : Transitions: 5 accepted, 15 rejected; replay checks: passed. Ces vérifications portent sur le modèle pédagogique ; testez séparément l’application selon les mêmes règles déclarées.

plain text
STATES = ("Draft", "Placed", "Paid", "Shipped", "Cancelled")
EVENTS = ("submit", "pay", "ship", "cancel")
TRANSITIONS = {
    ("Draft", "submit"): "Placed", ("Draft", "cancel"): "Cancelled",
    ("Placed", "pay"): "Paid", ("Placed", "cancel"): "Cancelled",
    ("Paid", "ship"): "Shipped",
}
class Order:
    def __init__(self, state="Draft"):
        self.state, self.effects, self.saved = state, 0, {}
    def apply(self, event, key):
        if key in self.saved:
            old_event, old_result = self.saved[key]
            return old_result if event == old_event else ("key-conflict", self.state)
        target = TRANSITIONS.get((self.state, event))
        if target is None:
            return ("rejected", self.state)
        self.state = target
        self.effects += 1
        result = ("accepted", target)
        self.saved[key] = (event, result)
        return result

accepted = 0
for state in STATES:
    for event in EVENTS:
        order = Order(state)
        target = TRANSITIONS.get((state, event))
        expected = ("accepted", target) if target else ("rejected", state)
        assert order.apply(event, "unique") == expected
        assert order.state == (target or state)
        assert order.effects == int(target is not None)
        accepted += target is not None
order = Order()
assert order.apply("submit", "s1") == ("accepted", "Placed")
assert order.apply("pay", "p1") == ("accepted", "Paid")
assert order.apply("pay", "p1") == ("accepted", "Paid")
assert order.effects == 2
assert order.apply("ship", "p1") == ("key-conflict", "Paid")
assert order.apply("pay", "p2") == ("rejected", "Paid")
assert order.effects == 2
assert order.apply("ship", "s2") == ("accepted", "Shipped")
assert order.effects == 3
print(f"Transitions: {accepted} accepted, {20-accepted} rejected; replay checks: passed")

Pour un vrai système, étendez la couverture des séquences : délai d'attente entre le paiement et la persistance, rappels en double, versions obsolètes et course aux annulations avec paiement. Définissez ce qui doit se passer avant d’écrire les assertions. Cinq transitions couvertes ne prouvent pas que tous les chemins en plusieurs étapes ou les récupérations après échec fonctionnent.

Pour aller plus loin

Utiliser des tables de décision pour les gardes de transition

Vérifiez les actions API rejetées sans effets secondaires

ISTQB CTFL 4.0.1 : section 4.2.4, tests de transition d'état