Partie 2 : Des tests fiables et performants avec Behat et Selenium
Précédemment, nous avons vu des astuces pour optimiser la CI. Cette fois-ci nous allons nous aventurer plus loin, au niveau des tests, car un problème récurrent demeure : leur instabilité.
Avez vous déjà eu des tests qui passent en local mais qui échouent sur la CI, ou inversement, sans que l’on sache pourquoi ?
Le problème des tests instables ou aléatoires est qu’on ne peut pas se fier à eux car le résultat n’est pas prédictible, on parle alors de non-déterminisme des tests. Ils s’accumulent et découragent les développeurs à en faire d’avantage.
Comme pour la CI, la stack de tests doit être améliorée en permanence. Nous allons voir certains principes fondamentaux à respecter afin d’avoir des tests fiables et performants. Cela pourra vous aider à éliminer la majorité des tests aléatoires mais pas entièrement, d’ou ce principe de vigilance et d’amélioration continue que j’essaye de transmettre.
Selenium, behat, docker et symfony seront les outils utilisés ici.
1. L’isolation des tests
Un scénario de test ne doit pas se baser sur l’état d’un scénario précédent.
C’est le problème numéro 1 que l’on doit corriger.
Un scénario de test end to end ou fonctionnel a généralement un état (une base de données mysql, un cache redis…). Cet état peut être modifié par la création, la modification ou la suppression d’une ou plusieurs ressources. Si cet état n’est pas remis à zéro, le scénario B se basera sur l’état qu’a laissé le scénario A. Cela pose plusieurs problèmes : on ne peut pas modifier un scénario sans modifier les suivants, de plus, nous sommes obliger de les lancer dans un ordre bien précis car ils dépendent des uns des autres, exécuter un scénario unitairement devient alors impossible.
La solution est que chaque scénario de test initialise ses propres données.
Vous pouvez avoir un fichier de fixtures pour chaque scénario ou un jeu de données global pour tous les tests, c’est à vous de choisir, les deux stratégies ont des avantages et inconvénients.
L’idéal dans les deux cas est de se baser sur des dumps sql (c’est aussi valable pour mongodb ou autre) créés au préalable avec mysqldump car c’est la solution la plus rapide par rapport à des méthodes comme le truncate ou le chargement de fichier yaml via alice. Le rollback des transactions n’est pas non plus possible dans notre contexte http. Ces dumps sont versionnés afin qu’ils puissent directement être utilisés par la CI ou par d’autre développeurs sans être régénérés à chaque fois.
Ceci est une approche parmi tant d’autres et n’a pas pour vocation d’être la meilleur, exemple :
Dans notre context behat FixturesContext, nous utilisons le tag behat @BeforeScenario afin d’exécuter la méthode clearDatabase avant chaque scenario. Pourquoi avant et non après ? Car un test peut être stoppé à n’importe quel moment et ne garantis pas l’appel à la méthode. Un simple shell_exec nous permet d’exécuter la commande mysql. A noter qu’il faut que le client mysql soit installé sur l’image docker que vous utilisez pour lancer les tests, cela ne veut pas dire que vous faites tourner un serveur mysql dessus, il sert juste à importer le dump en spécifiant l’hôte correspondant au container mysql.
Cependant réimporter la base de données à chaque fois ajoute un overhead, vos tests seront plus stable mais en contrepartie ils seront aussi plus lent. Ce n’est pas grave en local car vous jouez rarement toutes les suites de test mais cela peut avoir un impact sur les temps de la CI.
Certains de vos scenarii ne modifient pas obligatoirement la base de données, ils peuvent faire uniquement de la lecture. Par exemple un scénario qui fait une requête GET ne modifie pas la base de données :
Il suffit des les identifier et de les taguer @read-only. Ensuite, dans notre méthode clearDatabase on vérifie que le scénario ou la feature possède ce tag pour ne pas importer le dump.
Et pour finir, sur la CI il suffit d’exécuter les tests read-only en premier après avoir charger une fois la base de données, puis les tests non read-only qui eux la chargerons à chaque fois.
Un dernier tips : si vous avez plusieurs base de données, vous pouvez paralléliser les commandes d’import mysql avec le composant Process de Symfony.
2. L’asynchrone
Lorsque vous passez par un vrai navigateur, dans le cas des tests end to end avec Selenium, les tests behat sont souvent trop rapide par rapport au navigateur. Il y a de grande chance que lorsque vous cliquez sur un lien ou effectuez n’importe quel action, la page n’a pas le temps de se charger entièrement, le test échoue alors qu’il aurait pu passé.
La solution est la méthode spin. Ajoutez la à votre contexte behat qui étend le contexte Mink de base : Behat\MinkExtension\Context\MinkContext, c’est important pour la suite.
Elle permet simplement de passer une autre fonction (anonyme) en paramètre et la réessayer toutes les secondes jusqu’à ce qu’elle passe. Au delà de 5 secondes on considère que c’est une vraie erreur et non pas une lenteur du navigateur.
Ensuite nous allons surcharger toutes les méthodes de Mink :
Et ainsi de suite. On surcharge ces méthodes car elles sont utilisées en majorité dans vos tests et dans vos steps, on évite ainsi d’utiliser un wait explicite.
N’utilisez jamais de step comme I wait 10 seconds ou I wait 5 seconds until I see … c’est difficilement maintenable et lisible. Surcharger toutes les méthodes internes à Mink avec la méthode spin suffit à couvrir tous les cas d’usage.
3. Eviter la redondance des steps : exemple de l’authentification
Se loguer avec un utilisateur à chaque début de scénario est un incontournable. L’approche la plus simple est de passer par l’interface, de rentrer son login/password dans le formulaire pour le soumettre et suivre la redirection. Vous allez donc dupliquer et refaire ces étapes à chaque fois, ajoutant ainsi du temps supplémentaire à l’exécution des tests.
Une meilleur approche serais de tester une seule fois ce scénario en passant par l’interface et avoir une step Behat qui simule l’authentification dans tous les autres cas.
Pour cela il faut savoir comment marche l’authentification et comment cela se traduit dans le navigateur. Dans le cas d’un site web classique (en symfony par exemple) cela se traduit par un cookie de session, il porte un nom, PHPSESSID
en général et un id qui correspond à la session de l’utilisateur sur le serveur. Ce cookie est visible dans l’inspecteur du navigateur. Il est très simple de créer et d’injecter ce cookie mais il faut au préalable mettre à jour la session pour que l’utilisateur soit considéré connecté.
Pour savoir quel est l’attribut de session à mettre à jour il y a un moyen simple avec symfony. Connectez vous sur votre interface en environnement de dev et ouvrez la web debug toolbar, survoler ensuite l’onglet utilisateur :
Nous avons ici plusieurs informations qui nous intéressent : la classe du token utilisé : UsernamePasswordToken ainsi que le nom du firewall : main.
En ouvrant complétement le profiler dans l’onglet Session, on peut voir que l’attribut de session est _security_main et que sa valeur est un objet de type UsernamePasswordToken serializé : “C:74:”Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken”:19158:{a:3:{i:0;s:8:”password” …
Maintenant que nous avons toutes les informations nécessaires, nous pouvons implémenter la step :
Apres avoir récupérer l’utilisateur, on créer le token et on le serialize dans la session. On visite une première fois la page, c’est obligatoire si on veut injecter le cookie, puis on revisite cette même page au cas ou il y aurait une redirection la première fois. Vous êtes maintenant authentifié.
On aurait pu faire la même step sur une SPA, en injectant un token Bearer ou JWT dans le localStorage.
4. Les services externes
Quand on parle de services externes, on parle de ceux dont vous n’avez pas la main : des boites noires, rien à voir avec vos propres APIs ou services.
Voici un controller symfony classique :
L’appel au service externe est définit dans le service Stock. Nous n’avons pas besoin d’en connaitre les détails d’implémentation, disons qu’il fait simplement une requête http et retourne à la fin un objet de type StockModel. Vu que c’est un service externe, il peut être indisponible à un instant T qui pourrait faire échouer notre test.
Grace à l’injection de dépendances de symfony nous pouvons surcharger le controller en environnement de test uniquement et ainsi retourner une valeur fixe :
Modifions le fichier services_test.yml afin de surcharger notre service (son id est le FQCN de la classe de base) en lui spécifiant la nouvelle classe à utiliser :
Et pour finir modifions le fichier services.yml afin d’exclure le dossier Controller/Test de l’autowiring :
Le service est maintenant mocké en environnement de test.
5. L’horloge
Parfois nous voulons avoir le contrôle total sur la date du système. N’avez vous jamais eu des tests qui échouent au 1 janvier ?
Une solution est d’utiliser libfaketime. Elle comporte beaucoup d’options et permet d’avoir des dates absolues, relatives etc. Je vous invite à lire la documentation.
Pour l’installer, c’est très simple. Voici les lignes à ajouter dans le Dockerfile :
Et dans docker-compose.yml, il faut ajouter la variable d’environnement FAKETIME dans votre service :
Conclusion :
Cette dernière partie s’achève, n’hésitez pas à faire un tour sur la première partie https://rpg600.medium.com/partie-1-optimiser-sa-ci-617bdb86ed0 qui se focalise sur l’optimisation de la CI. Avoir une stack de test stable est primordial afin de pouvoir travailler efficacement ! Ceci ne doit pas être pris à la légère et se doit d’être amélioré en permanence.