3  Tester, c’est douter

Testing shows the presence, not the absence, of bugs.

– Edsger Dijkstra

Quand on débute un nouveau projet, tout semble simple : peu de complexité, peu d’interdépendances. Il est alors tentant de repousser l’écriture de tests. Pourtant, à mesure que le projet évolue, les modifications se multiplient, les composants se croisent, et les effets de bord deviennent la norme. C’est là que les tests prennent tout leur sens : non seulement pour vérifier que le code fonctionne, mais aussi — et surtout — pour que vous puissiez continuer à le faire évoluer en toute confiance. Les tests vous placent comme client et consommateur de votre propre travail.

3.1 Testabilité

Facilité à vérifier le bon fonctionnement du système.

  • Modularité
  • Isolation des composants
  • Données de test reproductibles

Impact architectural :

  • Injection de dépendances
  • Tests en boîte noire / blanche
  • Mocking / stubs dans les microservices

3.2 Pourquoi tester ?

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 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.

3.3 Pourquoi automatiser les tests ?

  • Principles of flow (Flux continu),
  • Fast Feedback (Retour rapide)

Dans le roman The Phoenix Project (Gene Kim and Spafford 2013), 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.

3.4 Quels types de tests ?

Les tests peuvent être de différents types :

  • Unitaires,
  • Fonctionnels,
  • D’intégration,
  • End-to-end.
Type de test Objectif principal Vitesse Fiabilité
Unitaire Tester une fonction/méthode isolée 🟢 Rapide 🟢 Très fiable
Intégration Tester l’interaction entre modules 🟡 Moyenne 🟡 Moyenne
Fonctionnel Valider des scénarios métier complets 🟡 Moyenne 🟢 Plutôt fiable
End-to-end Simuler le parcours utilisateur complet 🔴 Lente 🟡 Fragile

3.4.1 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 absurde ; 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 =================

3.4.2 Tests Fonctionnels

Les tests fonctionnels (ou tests d’acceptation) valident un comportement métier complet, souvent vu du point de vue de l’utilisateur. Ils ne testent pas des unités de code isolées, mais s’assurent qu’un cas d’usage fonctionne correctement, dans son ensemble.

Exemples typiques :

  • Vérifier que l’on peut soumettre un formulaire de création d’utilisateur.
  • Vérifier qu’un utilisateur non authentifié est redirigé vers la page de login.
  • Vérifier qu’un utilisateur peut effectuer un achat de bout en bout.

Ces tests utilisent généralement un navigateur automatisé (comme Selenium ou Playwright) ou un client HTTP (comme django.test.Client) pour interagir avec le site comme le ferait un utilisateur.

Exemple avec django.test.Client :

from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse

class SignupViewTest(TestCase):
    def test_signup_creates_user(self):
        response = self.client.post(reverse("signup"), {
            "username": "testuser",
            "password1": "ComplexPass123!",
            "password2": "ComplexPass123!",
        })
        self.assertEqual(User.objects.count(), 1)
        self.assertRedirects(response, reverse("home"))

Ce type de test est plus lent que les tests unitaires car il nécessite de nombreuses opérations (requêtes HTTP, accès à la base, rendu de templates…), mais il est indispensable pour valider une expérience utilisateur complète.

3.4.3 Tests d’intégration

Les tests d’intégration se situent entre les tests unitaires et les tests fonctionnels : ils vérifient que plusieurs composants fonctionnent ensemble comme prévu.

Cela inclut :

  • La communication entre une vue Django et la base de données,
  • L’interaction entre une API externe et vos services,
  • L’intégration entre plusieurs modules internes (ex : un service de paiement + notifications).

Contrairement aux tests unitaires qui mockent presque tout, les tests d’intégration travaillent avec des dépendances réelles (base SQLite ou PostgreSQL de test, fichiers temporaires, services réseau mockés via Docker, etc.).

Exemple typique : valider que lorsqu’un modèle Order est sauvegardé, une notification est envoyée.

from django.test import TestCase
from myapp.models import Order
from myapp.notifications import was_notification_sent

class OrderIntegrationTest(TestCase):
    def test_order_saves_and_triggers_notification(self):
        order = Order.objects.create(total=100, user_id=1)
        self.assertTrue(was_notification_sent(order.id))

Ces tests peuvent être plus fragiles, mais sont essentiels pour valider les interactions entre les briques de votre système.

3.4.4 Tests end-to-end

Les tests end-to-end couvrent le parcours utilisateur complet, dans un environnement aussi proche que possible de la production. Ils incluent :

  • Le navigateur (via Selenium, Playwright ou Cypress),
  • Le backend Django réel (souvent déployé sur un environnement de staging),
  • Une vraie base de données (parfois réinitialisée à chaque test),
  • Des appels vers des APIs simulées ou réelles.

Ces tests sont les plus longs et les plus coûteux à maintenir, mais ce sont eux qui vous donnent la confiance ultime que votre application fonctionne comme attendu dans la vraie vie.

Exemple avec selenium (simplifié) :

from selenium import webdriver
from django.test import LiveServerTestCase

class LoginE2ETest(LiveServerTestCase):
    def setUp(self):
        self.browser = webdriver.Firefox()

    def tearDown(self):
        self.browser.quit()

    def test_login_flow(self):
        self.browser.get(self.live_server_url + "/login/")
        username_input = self.browser.find_element("name", "username")
        password_input = self.browser.find_element("name", "password")
        username_input.send_keys("testuser")
        password_input.send_keys("password123")
        self.browser.find_element("name", "submit").click()
        self.assertIn("Welcome", self.browser.page_source)

3.5 Comment écrire 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. Écrire les tests avant le code s’appelle le TDD (Test-Driven Development). Ce n’est pas une obligation, mais c’est un excellent moyen de clarifier ce que le code doit faire avant de l’écrire. 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

3.5.1 django.test

from django.test import TestCase


class TestModel(TestCase):
    def test_str(self):
        raise NotImplementedError('Not implemented yet')

3.5.2 Mesurer la 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 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.

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

3.5.3 Tester plusieurs versions en parallèle

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.

[cols=“,,,”,options=“header”,] |=== | |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

3.5.4 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)

3.5.4.1 Tests de nommage

from django.urls 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.assertEqual(response.status_code, 200)

3.5.4.2 Tests d’URLs

from django.urls import reverse, resolve
from django.test import TestCase

from .views import home

class HomeTests(TestCase):
    def test_home_view_status_code(self):
      view = resolve("/")
      self.assertEqual(view.func, home)

3.5.4.3 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

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 au framework de garantir leur bon fonctionnement.

3.6 Conclusions

Les tests automatisés sont un investissement, pas une charge. Ils vous offrent :

  • De la sérénité : moins de bugs, moins de régressions,
  • Du feedback immédiat : en local, sur votre CI, avant même un déploiement,
  • Une documentation implicite du comportement attendu,
  • Et surtout : la liberté de refactorer sans trembler.

Mais tous les tests ne se valent pas. Il faut adapter le type de test au bon niveau de votre architecture :

  • Tests unitaires pour la logique métier pure,
  • Tests d’intégration pour s’assurer que les modules collaborent correctement,
  • Tests fonctionnels pour valider les cas d’usage concrets,
  • Tests E2E pour garantir que l’expérience utilisateur fonctionne, du clic au backend.

L’idée n’est pas d’avoir 100 % de couverture, mais d’avoir les bons tests aux bons endroits. Les tests doivent vous servir, pas vous ralentir.

Un projet bien testé, c’est un projet plus sûr, plus facile à faire évoluer et plus agréable à maintenir.