Blog // Exirel.me

Couverture menteuse

Par Florian Strzelecki - 18:49 - 03.10.2015

Tags : Python, Programmation, Bonne pratique, Unit Testing, Code Coverage

Le domaine des tests unitaires est vaste, les approches foisonnent, et les polémiques sont nombreuses. Il en est une toute particulière que j'affectionne qui concerne la couverture de code.

Bien sûr, je ne jure que par une bonne couverture de code. Écrire des tests et les exécuter sans regarder la couverture de code (ou "code coverage" dans le jargon), ne représente pas un grand intérêt pour moi. Si je n'ai pas l'assurance que je teste l'ensemble du code, je me sens comme en hiver sans un bon pull : à découvert devant le froid et la colère des éléments.

La couverture de code est un mensonge.

Cette expression, je l'entends très souvent. Trop souvent même, que ce soit par des gens qui sont contre ou des gens qui sont pour - oui, en 2015, il y a encore des gens qui sont contre la couverture de code, et même contre les tests. Ce qui est un peu triste, d'ailleurs - mais passons.

Cependant, rare sont les fois où j'entends des explications claires ou des exemples d'une "bonne" mesure. Comment y parvenir ? Comment détecter les trous ? Comment mieux prévenir les problèmes ?

Je ne vais pas couvrir l'ensemble des cas possibles, mais je suis tombé sur un cas d'école cette semaine à mon travail.

Cas d'école

Prenons un package créé pour l'exemple, et qui reprend le problème que j'ai rencontré cette semaine :

(venv)$ ls documents/
__init__.py  models.py readers.py  writers.py

Il y a donc un package documents qui contient quelques modules. Lorsque je lance les tests, voici ce que j'obtiens :

(venv)$ py.test --cov

========================= test session starts ==========================

tests/test_documents.py ...
tests/test_models.py ..
tests/test_readers.py .

------------ coverage: platform linux, python 3.4.3-final-0 ------------
Name                    Stmts   Miss Branch BrPart  Cover   Missing
-------------------------------------------------------------------
documents/__init__.py      12      0      4      0   100%   
documents/models.py        14      0      4      1    94%   16->19
documents/readers.py        8      0      2      0   100%   
documents/writers.py        4      0      2      0   100%   
-------------------------------------------------------------------
TOTAL                      38      0     12      1    98%

======================= 6 passed in 0.03 seconds =======================

Cela se présente plutôt bien n'est-ce pas ? Il y a quelques trous, mais rien de bien alarmant : un test ou deux en plus et tout ira mieux.

Mais voyez-vous déjà le premier problème ? Non ? Vraiment pas ? Je vous aide :

tests/test_documents.py ...
tests/test_models.py ..
tests/test_readers.py .

Il n'existe aucun fichier de tests pour le module documents.writers. Alors, soit, peut-être qu'ils sont mélangés avec les tests principaux, vu le nombre ridicule de code dans ce module.

Soit (là vous devez vous imaginer mon air dubitatif, teinté d'une légère touche de désapprobation).

Mais cela me met quand même la puce à l'oreille.

Des tests vraiment unitaires ?

Revenons un peu à la base de ce que sont les tests unitaires : comme leur nom l'indique, ils s'intéressent à une "unité" de code, une portion réduite et de préférence isolée du reste : ne tester qu'une fonction et pas le reste.

Boundaries

Je conseille vivement le visionnage de boundaries de Gary Bernhardt lors d'une conférence en 2012.

Bien sûr, les limites sont toujours un peu flou, et il faut savoir composer tout en étant pragmatique. Je ne vais pas rentrer dans les détails et les considérations sur ce que veut dire unitaire, ou sur la façon d'isoler des tests. Je ne vais même pas parler des mock et de patch.

Cependant, essayons de garder en tête que ces tests doivent nous aider à travailler, et ils ne sont pas seulement là pour nous faire perdre du temps, ou transmettre de beaux rapports à un quelconque supérieur en manque de KPI.

Mon premier conseil est donc le suivant :

Un fichier de tests par fichier de code au minimum.

Cela vous permettra de mieux séparer vos tests, de vérifier que vous ne testez pas "trop" de chose à la fois. C'est une façon de structurer votre travail et d'aller plus vite à la fois dans le refactoring du code et dans la résolution de problèmes.

Pour moi, c'est une façon pratique d'établir une séparation claire entre chaque partie du code, en imitant la même structure que le code. C'est une façon - un peu superficielle - de conserver un état d'esprit "unitaire".

Trouver les trous

Donc, j'ai un score global de 98% de couverture de code, plutôt honorable. Mettons maintenant que je souhaite rajouter une fonction à l'un des sous module, par exemple, documents.readers, je vais donc aussi ajouter des tests.

Si vous regardez les résultats précédents, vous verrez que ce module est testé à 100%. Mais que se passe-t-il si je ne lance que les tests du module ?

(venv)$ py.test tests/test_readers.py --cov documents/readers.py

========================= test session starts ==========================

tests/test_readers.py .

------------ coverage: platform linux, python 3.4.3-final-0 ------------
Name                   Stmts   Miss Branch BrPart  Cover   Missing
------------------------------------------------------------------
documents/readers.py       8      3      2      0    50%   13, 17-18

======================= 1 passed in 0.02 seconds =======================

Seulement 50% ! Autant dire une couverture insuffisante. Bien sûr, avoir 100% ne me dit rien, mais un résultat de 50% est catégorique : il n'y a pas assez de tests. Vous pouvez le tourner dans tous les sens, il y a du code qui, ici, n'est jamais testé - et ce, sans même avoir besoin de parler de "qualité" des tests.

C'est l'une des forces de la mesure de la couverture de code : elle permet de trouver là où le code n'est pas du tout testé, si et seulement si les tests sont bien structurés. Et que quelqu'un pense à les lancer séparément.

L'absence de couverture en dit souvent plus long qu'une couverture parfaite.

Dans cet exemple, il est évident que les tests sur le module documents.readers ne sont pas suffisants : il faut en écrire d'autres - et de préférence de qualité !

J'ai pu m'en apercevoir grâce à deux choses :

Se donner les moyens

Mon second conseil est donc le suivant :

Exécuter les tests d'un seul module pour en vérifier la couverture.

Lorsque vous travaillez sur un module, commencez par exécuter les tests de ce module uniquement. Cela vous renseignera sur la couverture de ce module uniquement. S'il y a un trou, au moins vous le voyez tout de suite, sans qu'il ne soit masqué par une suite de tests qui le couvre par effet de bord, ou par dépendance.

Faisons preuve de réalisme : si un développeur ne prend pas la peine de porter un peu d'attention à ses tests et à la couverture de code, aucun problème ne sera jamais résolu. C'est le contrat de base : si personne ne fait les choses bien, il n'y a aucun outil et aucune mesure qui pourra vous sauver la mise lorsque les problèmes remonterons à la surface.

S'il n'y a pas de trou, et bien... c'est là que la qualité des tests en eux-même entre en jeu. C'est à ce moment là que vous devez être vigilant et adopter d'autres techniques pour aller plus loin - mais ce n'est pas le sujet de cet article.

Couverture avec py.test

Parlons de quelques détails techniques. Pour mon article, j'ai utilisé :

Ensuite, j'ai configuré mon fichier .coveragerc :

[run]
branch = True
source = documents

[report]
show_missing = True

Par défaut, je n'ai donc pas à préciser le module à mesurer (ici documents), ni à indiquer les informations que je souhaite voir (les branches couvertes et manquantes, et les lignes non couvertes).

Pour lancer toute la suite de tests la commande est on ne peut plus simple :

(venv)$ py.test --cov

Et pour ne lancer qu'un fichier de tests en particulier, avec une couverture de code uniquement pour le module testé (les deux notations sont identiques) :

(venv)$ py.test tests/path/to/test_file.py --cov path/to/module.py
(venv)$ py.test tests/path/to/test_file.py --cov package.module

Je peux aussi demander la couverture sur plusieurs modules en utilisant plusieurs fois l'option --cov :

(venv)$ py.test tests/path/to/test_file.py \
--cov package.module \
--cov package.other.module

Faites des tests !

Et voilà. J'espère avoir abordé une question intéressante avec des conseils simples et faciles à mettre en œuvre. Bien sûr, ce n'est rien de révolutionnaire. Pour autant, c'est une erreur très fréquente que j'ai rencontrée partout - à commencer dans mon propre code.

Un peu d'amour et d'attention pour sa couverture de code, ça ne fait de mal à personne.

Sachez par ailleurs que si vous aimez les tests et le code de qualité, je cherche un ou deux bons développeurs Python pour travailler avec moi sur le site international d'Oscaro. N'hésitez pas à me contacter par mail ou via Twitter.