Tests et intégration
Testing shows the presence, not the absence, of bugs.
— Edsger Dijkstra
Lorsque nous démarrons une nouvelle application, la toute première version est généralement la partie la plus simple à mettre en place : elle ne nécessite pratiquement aucune maintenance ou considération pour les futurs désidérata qui pourraient vous être demandés.
Les tests unitaires et d’intégration vous place comme client et consommateur de votre propre travail.
Pourquoi réaliser des tests ?
Les tests permettent d’automatiser la vérification de comportements déterminés. Ils sont nécessaires, car les briques sur lesquelles vous vous basez pour mettre un service à disposition évoluent elles aussi :
- Nouvelles versions d’un interpréteur,
- Nouvelles versions de librairies tierces,
- Correctifs de sécurité,
- Evolution des protocoles de sécurité (SSL, TLS, …),
- Suivi des interfaces d’intégration,
- …
Sans automatiser les tests, il faudrait, à chaque modification du code, vérifier l’intégralité de de la base de code qui a été impactée, avec parfois des données cachées. Il faut donc voir les tests comme ayant un seuil de “rentabilité” : au début du projet, le code est propre, facile à comprendre et ne nécessite aucun test ; au fur et à mesure de son évolution, des modifications sont apportées à plusieurs endroits. Le temps dépensé au début du projet devient alors rentable et permet d’améliorer la confiance qu’a le développeur dans son code, et d’avoir un effet immédiat sur sa vélocité. Dans le cas contraire, le risque est de découvrir des impacts a posteriori, avec des effets potentiellement désastreux. La réalisation et la réussite des tests a donc un impact assez grand sur la sécurité psychologique (ou charge mentale) qu’une personne pourrait ressentir lors de l’accomplissement de son travail.
Il est donc important et nécessaire de réaliser des tests. Il est également important de calculer quoi tester : la présence d’un test ne signifie pas que ce test est correctement implémenté - et sans aller jusqu’à réaliser des tests de tests, il nous semble pertinent de réfléchir et d’équilibrer le travail à acomplir.
Types de tests
Les tests peuvent être de différents types :
- Unitaires,
- Fonctionnels,
- D’intégration,
- End-to-end.
Tests unitaires
Python embarque tout un environnement facilitant le lancement de tests au travers du module unittest
; une bonne pratique (parfois discutée) consiste cependant à basculer vers pytest
, qui présente quelques avantages par rapport au module de base :
- Une syntaxe plus concise (au prix de quelques conventions, même celles-ci restent configurables): un test est une fonction, et ne doit pas obligatoirement faire partie d’une classe héritant de
TestCase
- la seule nécessité étant que cette fonction fasse partie d’un module commençant ou finissant par “test” (test_example.py
ouexample_test.py
). - Une compatibilité avec du code Python “classique” - vous ne devrez donc retenir qu’un seul ensemble de commandes,
- Des fixtures faciles à réutiliser entre vos différents composants,
- Une compatibilité avec le reste de l’écosystème, dont la couverture de code présentée ci-dessous.
Ainsi, après installation, il nous suffit de créer notre module test_models.py
, dans lequel nous allons simplement tester l’addition d’un nombre et d’une chaîne de caractères (oui, c’est complètement biesse; on est sur la partie théorique ici):
def test_add(): assert 0 + 0 == "La tête à Toto"
Forcément, cela va planter.
Pour nous en assurer (dès fois que quelqu’un en doute), il nous suffit de démarrer la commande pytest
:
$ pytest
================= test session starts =================
platform ... rootdir: ... plugins: django-4.1.0 collected 1 item gwift\test_models.py F [100%]
================= FAILURES =================
_________________ test_basic_add _________________def test_basic_add():> assert 0 + 0 == "La tête à Toto"E AssertionError: assert (0 + 0) == 'La tête à Toto'tests.py:2: AssertionError
================= short test summary info =================FAILED tests.py::test_basic_add - AssertionError: assert (0 + 0) == 'La tête à Toto'
================= 1 failed in 0.10s =================
Tests Fonctionnels
TBC
Tests d’intégration
TBC
Tests end-to-end
TBC
Couverture de code
La couverture de code est une analyse qui donne un pourcentage lié à la quantité de code couvert par les tests. Il ne s’agit pas de vérifier que le code est bien testé, mais de vérifier quelle partie du code est testée.
Le paquet coverage
se charge d’évaluer le pourcentage de code couvert par les tests.
Avec pytest, il convient d’utiliser le paquet pytest-cov, suivi de la commande pytest –cov=gwift tests/
.
Si vous préférez rester avec le cadre de tests de Django, vous pouvez passer par le paquet django-coverage-plugin. Ajoutez-le dans le fichier requirements/base.txt, et lancez une couverture de code grâce à la commande coverage. La configuration peut se faire dans un fichier .coveragerc que vous placerez à la racine de votre projet, et qui sera lu lors de l’exécution.
Maintenant que nous avons défini les différents types de tests envisageables, nous pouvons jouer au jeu de la couverture, qui consiste à augmenter ou égaliser la couverture existante à chaque nouvelle fonctionnalité ajoutée ou bug corrigé. De cette manière, sans arriver à une couverture de 100%, chaque modification du code améliorera la base existante.
Suivant l’outil d’intégration continue que vous utiliserez, cette évolution pourra être affichée à chaque demande de fusion, et pourra être considérée comme un indicateur de qualité.
$ coverage run --source "." manage.py test
$ coverage report
Name Stmts Miss Cover --------------------------------------------- gwift\gwift\__init__.py 0 0 100% gwift\gwift\settings.py 17 0 100% gwift\gwift\urls.py 5 5 0% gwift\gwift\wsgi.py 4 4 0% gwift\manage.py 6 0 100% gwift\wish\__init__.py 0 0 100% gwift\wish\admin.py 1 0 100% gwift\wish\models.py 49 16 67% gwift\wish\tests.py 1 1 0% gwift\wish\views.py 6 6 0% --------------------------------------------- TOTAL 89 32 64% ----$ coverage html
Avec pytest
, il convient d’utiliser le paquet pytest-cov
, suivi de la commande pytest --cov=gwift tests/
.
Si vous préférez rester avec le cadre de tests de Django, vous pouvez passer par le paquetdjango-coverage-plugin.
Ajoutez-le dans le fichier requirements/base.txt
, et lancez une couverture de code grâce à la commande coverage
.
La configuration peut se faire dans un fichier .coveragerc
que vous placerez à la racine de votre projet, et qui sera lu lors de l’exécution.
Matrice de compatibilité
Une matrice de compatibilité consiste à spécifier un ensemble de versions d’un même interpréteur, afin de s’assurer que votre application continue à fonctionner. Nous sommes donc un cran plus haut que la spécification des versions des librairies, puisque nous nous situons directement au niveau de l’interpréteur. L’objectif consiste à définir un tableau à deux dimensions, dans lequel nous trouverons la compatibilité entre notre application et une version de l’interpréteur.
py37 | py38 | py39 | |
---|---|---|---|
lib2 | ✅ | ✅ | ✅ |
lib3 | ❌ | ✅ | ✅ |
lib4 | ❌ | ✅ | ✅ |
L’outil le plus connu est Tox, qui consiste en un outil basé sur virtualenv et qui permet de :
- Vérifier que votre application s’installe correctement avec différentes versions de Python et d’interpréteurs
- Démarrer des tests parmi ces différents environnements.
La documentation de Poetry spécifie une section indiquant comment faire fonctionner Tox et Poetry conjointement.
A noter que pour que les commandes ci-dessus fonctionnent correctement, il sera nécessaire que vous ayez les différentes versions d’interpréteurs installées ou accessibles.
Une solution consiste à utiliser pyenv
, qui s’occupera d’installer les versions devant l’être, afin d’éviter les erreurs de type ERROR: pyXX: InterpreterNotFound: pythonX.X
:
pyenv install 3.10
Pourquoi automatiser les tests ?
- Principles of flow,
- Fast feedback
Dans le roman “The Phoenix Project” cite:[phoenix_project], un des principes présentés consiste à avoir un retour le plus rapide possible, et ce à n’importe quelle étape de la chaîne de valeurs, c’est-à-dire du développement jusqu’à la mise à disposition. L’objectif est d’améliorer la confiance que nous pouvons avoir dans le produit développé, d’éviter qu’un problème rencontré ne survienne à nouveau, tout en accélérant la découverte de nouveaux problèmes et la récupération d’un environnement qui serait tombé.
Si nous visualisons les phases de développement comme un pipeline composé d’étapes séquentielles, il est nécessaire que la rencontre d’une erreur arrête toute la chaîne de production, pour éviter qu’une erreur n’arrive jusqu’à une phase de déploiement. Ce process fait notamment référence à la corde d’Andon dans les chaînes de production de Toyota (et, plus tard : à la méthode Lean).
Pour atteindre cet objectif, il est nécessaire :
- De disposer de tests automatisés,
- De scénarii de déploiement,
- D’une télémétrie omniprésente.
Ecrire des tests
Il y a deux manières d’écrire les tests: soit avant, soit après l’implémentation. Oui, idéalement, les tests doivent être écrits à l’avance. Entre nous, on ne va pas râler si vous faites l’inverse, l’important étant que vous le fassiez. Une bonne métrique pour vérifier l’avancement des tests est la couverture de code.
Chaque application est créée par défaut avec un fichier tests.py, qui inclut la classe TestCase
depuis le package django.test
:
On a deux choix ici :
- Utiliser les librairies de test de Django
- Utiliser Pytest
django.test
from django.test import TestCase
class TestModel(TestCase): def test_str(self): raise NotImplementedError('Not implemented yet')
Pytest
TBC
Couverture de code
Pour valider la couverture de code, nous aurons besoin de coverage
et de django_coverage_plugin
.
Ajoutez les au groupe de dépendances en test uniquement, puis ajoutez un fichier de configuration, qui sera valable quel que soit l’environnement de développement qui sera concerné :
…
.coveragerc
[run] branch = True omit = ../migrations plugins = django_coverage_plugin
[report] ignore_errors = True
[html] directory = coverage_html_report …
Exemples
En résumé, il est recommandé de :
- Tester que le nommage d’une URL (son attribut
name
dans les fichiersurls.py
) corresponde à la fonction que l’on y a définie - Tester que l’URL envoie bien vers l’exécution d’une fonction (et que cette fonction est celle que l’on attend)
Tests de nommage
from django.core.urlresolvers import reversefrom django.test import TestCase
class HomeTests(TestCase): def test_home_view_status_code(self): url = reverse("home") response = self.client.get(url) self.assertEquals(response.status_code, 200)
Tests d’URLs
from django.core.urlresolvers import reversefrom django.test import TestCase
from .views import home
class HomeTests(TestCase): def test_home_view_status_code(self): view = resolve("/") self.assertEquals(view.func, home)
Couverture de code
Pour l’exemple, nous allons écrire la fonction percentage_of_completion
sur la classe Wish
, et nous allons spécifier les résultats attendus avant même d’implémenter son contenu.
Prenons le cas où nous écrivons la méthode avant son test :
class Wish(models.Model): [...] @property def percentage_of_completion(self): """ Calcule le pourcentage de complétion pour un élément. """ number_of_linked_parts = WishPart.objects.filter(wish=self).count() total = self.number_of_parts * self.numbers_available percentage = (number_of_linked_parts / total) return percentage * 100
Lancez maintenant la couverture de code. Vous obtiendrez ceci:
$ coverage run --source "." src/manage.py test wish$ coverage report
Name Stmts Miss Branch BrPart Cover------------------------------------------------------------------src\gwift\__init__.py 0 0 0 0 100%src\gwift\settings\__init__.py 4 0 0 0 100%src\gwift\settings\base.py 14 0 0 0 100%src\gwift\settings\dev.py 8 0 2 0 100%src\manage.py 6 0 2 1 88%src\wish\__init__.py 0 0 0 0 100%src\wish\admin.py 1 0 0 0 100%src\wish\models.py 36 5 0 0 88%------------------------------------------------------------------TOTAL 69 5 4 1 93%
Si vous générez le rapport HTML avec la commande coverage html
et que vous ouvrez le fichier
coverage_html_report/src_wish_models_py.html
, vous verrez que les méthodes en rouge ne sont pas testées.
A contrario, la couverture de code atteignait 98% avant l’ajout de cette nouvelle méthode.
Pour cela, on va utiliser un fichier tests.py
dans notre application wish
.
A priori, ce fichier est créé automatiquement lorsque vous initialisez une nouvelle application.
from django.test import TestCase
class TestWishModel(TestCase): def test_percentage_of_completion(self): """ Vérifie que le pourcentage de complétion d'un souhait est correctement calculé. Sur base d'un souhait, on crée quatre parts et on vérifie que les valeurs s'étalent correctement sur 25%, 50%, 75% et 100%. """ wishlist = Wishlist( name='Fake WishList', description='This is a faked wishlist' ) wishlist.save()
wish = Wish( wishlist=wishlist, name='Fake Wish', description='This is a faked wish', number_of_parts=4 ) wish.save()
part1 = WishPart(wish=wish, comment='part1') part1.save()
self.assertEqual(25, wish.percentage_of_completion)
part2 = WishPart(wish=wish, comment='part2') part2.save()
self.assertEqual(50, wish.percentage_of_completion)
part3 = WishPart(wish=wish, comment='part3') part3.save()
self.assertEqual(75, wish.percentage_of_completion)
part4 = WishPart(wish=wish, comment='part4') part4.save()
self.assertEqual(100, wish.percentage_of_completion)
L’attribut @property
sur la méthode percentage_of_completion()
va nous permettre d’appeler directement la méthode
percentage_of_completion()
comme s’il s’agissait d’une propriété de la classe, au même titre que les champs number_of_parts
ou numbers_available
.
Attention que ce type de méthode contactera la base de données à chaque fois qu’elle sera appelée. Il convient de ne
pas surcharger ces méthodes de connexions à la base: sur de petites applications, ce type de comportement a très peu d’impacts, mais ce n’est plus le cas sur de grosses applications ou sur des méthodes fréquemment appelées.
Il convient alors de passer par un mécanisme de cache, que nous aborderons plus loin.
En relançant la couverture de code, on voit à présent que nous arrivons à 99%:
$ coverage run --source='.' src/manage.py test wish; coverage report; coverage html;.----------------------------------------------------------------------Ran 1 test in 0.006s
OKCreating test database for alias 'default'...Destroying test database for alias 'default'...Name Stmts Miss Branch BrPart Cover------------------------------------------------------------------src\gwift\__init__.py 0 0 0 0 100%src\gwift\settings\__init__.py 4 0 0 0 100%src\gwift\settings\base.py 14 0 0 0 100%src\gwift\settings\dev.py 8 0 2 0 100%src\manage.py 6 0 2 1 88%src\wish\__init__.py 0 0 0 0 100%src\wish\admin.py 1 0 0 0 100%src\wish\models.py 34 0 0 0 100%src\wish\tests.py 20 0 0 0 100%------------------------------------------------------------------TOTAL 87 0 4 1 99%
En continuant de cette manière (ie. Ecriture du code et des tests, vérification de la couverture de code), on se fixe un objectif idéal dès le début du projet. En prenant un développement en cours de route, fixez-vous comme objectif de ne jamais faire baisser la couverture de code.
A noter que tester le modèle en lui-même (ses attributs ou champs) ou des composants internes à Django n’a pas de sens: cela reviendrait à mettre en doute son fonctionnement interne. Selon le principe du SRP link:#SRP[[SRP]], c’est le framework lui-même qui doit en assurer la maintenance et le bon fonctionnement.
Conclusions
TBC