Aller au contenu

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 ou example_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):

Fenêtre de terminal
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:

Fenêtre de terminal
$ 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é.

Fenêtre de terminal
$ 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.

py37py38py39
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 :

Fenêtre de terminal
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 fichiers urls.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 reverse
from 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 reverse
from 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:

Fenêtre de terminal
$ 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%:

Fenêtre de terminal
$ coverage run --source='.' src/manage.py test wish; coverage report; coverage html;
.
----------------------------------------------------------------------
Ran 1 test in 0.006s
OK
Creating 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