La suite de tests du monolithe Python de Ramp approchait des 20 minutes à mesure que l'équipe dépassait les 5 000 tests, majoritairement de style fonctionnel/intégration. Plutôt que de réécrire la suite, l'équipe infrastructure s'est attaquée directement au temps d'exécution : augmenter la concurrence de la CI en corrigeant un goulot d'étranglement à la configuration de la base de données qui limitait les workers parallèles à quatre, arrêter de démonter et reconstruire la base de données de test de zéro entre les exécutions locales, et construire un exécuteur de tests incrémental qui n'exécute que les tests pertinents pour une modification de code donnée.
La première version de l'exécuteur incrémental utilisait coverage.py pour cartographier, pour chaque test, quelles lignes de code il exécutait, puis sur un nouveau commit comparait les fichiers modifiés à cette carte. Cela fonctionnait, mais présentait une lacune précise : la base de code de Ramp utilise des classes de modèle Flask-SQLAlchemy, et la mesure de couverture attribue presque toute l'exécution aux internes de SQLAlchemy plutôt qu'à la déclaration de la classe de modèle elle-même. L'ajout d'une colonne à un modèle n'était pas enregistré comme « cette classe a changé » avec une couverture purement par lignes, si bien que l'exécuteur incrémental pouvait ignorer des tests qui auraient dû détecter un bug modifiant le schéma.
Pour corriger cela, l'équipe a remplacé coverage.py par sys.settrace, intégré à Python, en inspectant les événements `call` pour un argument `self` ou `cls`, en résolvant cet objet vers sa classe, et en effectuant un parcours en largeur vers le haut de `__bases__` pour associer chaque classe ancêtre au fichier où elle était définie. Cela permettait à l'instantané d'enregistrer non seulement quelles lignes un test exécutait, mais aussi de quelles classes de toute sa chaîne d'héritage il dépendait — ainsi, une modification de la déclaration d'une classe de base invalidait correctement les tests de toutes les sous-classes, même lorsqu'aucun chemin de code d'une sous-classe n'était directement exercé.
Combiné à la correction de la concurrence et à l'absence de reconstruction complète de la base de données, l'exécuteur incrémental couvrait 30 à 40 % de la suite sur un commit typique et détectait environ 90 % des échecs qu'une exécution complète aurait détectés, donnant aux ingénieurs un signal exploitable en quelques minutes au lieu des 10 à 20 complètes.
Tracer l'héritage de classes à l'exécution afin qu'une modification d'une classe de base non exercée déclenche tout de même ses tests
Coverage.py enregistre les lignes exécutées, pas les classes auxquelles elles appartiennent, si bien que l'exécution à l'intérieur des internes ORM de SQLAlchemy n'est pas attribuée à la classe de modèle de l'utilisateur.
sys.settrace expose directement les frames d'appel, si bien qu'inspecter `self`/`cls` permet de retrouver « quelle classe est-ce » indépendamment de l'endroit où l'exécution au niveau des lignes a eu lieu.
Parcourir `__bases__` depuis cette classe transforme un événement d'appel en une liste complète d'ancêtres, si bien qu'une modification d'une classe de base est attribuée à chaque fichier de la chaîne.
Exécuter l'exécuteur incrémental en parallèle de la suite complète, et non à sa place, a fait que l'écart de détection d'environ 10 % n'est jamais devenu un risque en production.
Un raccourci de dépendance basé sur la couverture peut manquer de vraies dépendances, comme l'héritage, que les outils de couverture n'attribuent pas au code modifié — vérifiez ce point aveugle avant de faire confiance au chemin rapide.
Ramp a jugé que l'exécuteur incrémental ne valait pas d'investissement supplémentaire au-delà de ce point — combler l'écart de détection restant d'environ 10 % aurait nécessité une restructuration autour de quelque chose comme Bazel, ce qui n'était pas la priorité du moment. La correction de la concurrence (doubler la taille des machines après avoir supprimé une réinitialisation redondante de la base de données par worker) a à elle seule réduit de moitié le temps de CI, et éviter les reconstructions complètes de la base de données en local a fait gagner 10 à 30 secondes par exécution. L'équipe a publié le parcours d'héritage basé sur sys.settrace comme alternative à des options open source comme pytest-testmon, qui ne disposait pas du support de parallélisation de Ramp.
VÉRIFIEZ LES SOURCES
Les sources
- Faster Python Tests Ramp Engineering (Builders Blog) · builders.ramp.com ·