Blog // Exirel.me

Retrouvez tous les articles liés au tag Unit Testing via le flux rss dédié à ce tag.

Python, Makefile, et tests

Par Florian Strzelecki - 19:15 - 24.10.2016

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

Depuis quelques temps, j'essaie plusieurs fichiers Makefile pour mes projets en pythons : pour lancer les tests, les validateurs divers, et générer les différents rapports (tests, couverture, et qualité).

Voici ce à quoi j'arrive pour le moment :

# Project tasks
# ==========

test:
    coverage run --source=<package> setup.py test

report: pylint
    coverage html

pylint:
    pylint <package> > pylint.html || exit 0

isort:
    isort -rc <package> test.py

flake8:
    flake8

quality: isort flake8

all: quality test report

En général, je lance simplement avec make all, pour tout faire d'un coup sans me poser de questions. Cela va donc lancer :

Premier constat : inutile pour moi de lancer des tests sur du code qui n'est pas correctement formaté. Le coût d'un code propre est minime voire quasi inexistant - j'ai l'habitude, ça aide beaucoup - alors autant se fixer des règles strictes et s'y tenir.

Second constat : je n'utilise plus (ou presque plus) de lanceur de tests spécifiques (py.test, nosetests, etc.). Pourquoi ? Parce qu'au final, ces outils ne font qu'ajouter de la déco sur les tests, mais ils ne m'aident pas à écrire de meilleurs tests. Je suis habitué à unittest, et la plupart du temps, je n'ai pas besoin d'une explication particulière pour un test.

D'ailleurs, que l'on utilise simplement assert ou les méthodes du framework unittest, un message manuel est souvent préférable à une interprétation du lanceur de tests :

import unittest

class TestIsPositive(unittest.TestCase):
    def test_behavior(self):
        assert is_positive(0) is True, (
            'is_positive must return True with 0')
        assert is_positive(2) is True, (
            'is_positive must return True with > 0')
        self.assertFalse(
            is_positive(-1),
            'is_positive must return False with < 0')

Bien entendu, c'est un cas où le message est un peu accessoire : il faut imaginer des cas plus complexes, où le résultat d'une fonction est d'un type complexe, et donc la raison d'être demande un peu plus qu'une simple "ceci doit être égal à cela".

Quoi qu'il en soit, make all est maintenant ma routine quotidienne, simple, rapide, et efficace.

Assert

Par Florian Strzelecki - 00:02 - 13.10.2015

Tags : Python, Bonne pratique, Unit Testing, ProTips, assert

Le mot clé assert, tout le monde connaît à peu près :

def test_your_function()
    assert my_function() == 'good result', (
        'We expect `good result` here.')

Il permet, dans un test unitaire, de vérifier que tout se passe comme prévu. Il est à noter qu'un assert qui se passe bien est un assert qui ne "fait" rien. Il ne fait quelque chose (lever une exception) que lorsque quelque chose ne va pas.

Généralement, il est utilisé dans les tests unitaires, avec pour but de vérifier un aspect particulier du code - d'où souvent le côté "unitaire" de ces tests.

AssertionError

Il est tout à fait possible de recréer l'effet d'un assert avec le bon raise :

>>> assert False, 'We expect True here, not False.'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: We expect True here, not False.

Ceci revient à utiliser raise :

>>> raise AssertionError('We expect True here, not False.')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: We expect True here, not False.

C'est d'ailleurs grâce à cela que le framework unittest peut proposer des méthodes spécifiques pour écrire des tests unitaires. Il se trouve qu'aujourd'hui, d'autres framework de tests en Python reviennent à assert, et préfèrent faire une introspection du code pour proposer une meilleure lecture du résultat du test.

Cela veut dire aussi qu'il est tout à fait possible d'attraper l'exception levée par un assert :

>>> try:
...     assert False, 'Oh no!'
... except AssertionError:
...     print('Error detected.')
... 
Error detected.

Là encore, c'est ce qui permet aux frameworks de tests d'exécuter plusieurs tests à la suite, peu importe qu'ils passent ou qu'ils échouent. assert n'est donc qu'un peu de sucre syntaxique au-dessus d'une fonctionnement somme toute très classique d'exception.

Pas seulement dans les tests.

Partant de là, il est aussi possible d'utiliser assert dans du code en dehors des tests unitaires. Oui, mais comment bien faire ?

Pas comme ça...

Tout d'abords, ne validez jamais les valeurs d'une entrée utilisateur ou d'un système externe avec assert : ce n'est pas son but. Cela veut dire :

Par exemple, si vous devez vérifier que la valeur fournie est bien supérieur à 0, ne faites pas ceci :

assert user_input > 0

Mais cela :

if user_input <= 0:
    raise ValueError(user_input)

assert ne doit jamais servir le fonctionnel.

Il y a une raison simple à cela : l'option -O de python. Voici ce qui se passe lorsque je lance un petit programme sans cette option :

$ python app.py 
Traceback (most recent call last):
  File "app.py", line 7, in <module>
    validate(0)
  File "app.py", line 4, in validate
    assert user_input > 0
AssertionError
$ echo $?
1

Et maintenant avec :

$ python -O app.py
$ echo $?
0

Le assert a été purement et simplement ignoré - ce qui n'est pas du tout le résultat attendu ! Si vous voulez vraiment que votre programme utilise une AssertionError, il vaut mieux la lever vous même plutôt qu'utiliser un assert.

Alors comment ?

Un assert permet de vérifier une condition et de "laisser passer" si elle est vraie : nous pouvons utiliser son pouvoir de ne rien faire si tout se passe bien à notre avantage.

La plupart du temps, une application dispose de deux ou plus niveau de code :

assert est une forme de communication.

Si dans le code de haut niveau nous ne pouvons pas utiliser assert, le code de bas niveau est un excellent candidat pour ça. Par exemple, imaginons une fonction qui divise un nombre par un paramètre donné, mais seulement différent de 0 :

def divide_by(number):
    assert not number == 0, (
        'You can not `divide_by` 0.')
    # process number

Ainsi, un développeur pour s'assurer qu'aucun de ses collègues ne tente d'appeler cette fonction en dehors de son module avec un paramètre incorrect - les développeurs ne lançant que très rarement leur application avec l'option -O.

Personnellement, je l'utilise beaucoup pour valider des keywords argument obligatoire dans du code interne :

def do_something(self, bar=None, **kwargs):
    assert 'foo' in kwargs, 'We expect foo!'
    assert bar is not None, 'We expect a value for bar''
    super().do_something(bar=bar, **kwargs)

De cette façon, je peux faire en sorte de conserver la même signature que la fonction parent, tout en ajoutant des pré-conditions. Cela reste un cas spécifique, et j'invite chacun à faire des choix judicieux et réfléchis.

Digression : couverture de code

Dans un précédent article, je parlais de la couverture de code : assert implique une différence importante pour ladite couverture.

Si vous prenez ces deux bouts de code qui ont l'air identique :

def divide_by(number):
    assert not number == 0, (
        'You can not `divide_by` 0.')
    return CONSTANT / number

def divide_user_input(user_input):
    if user_input == 0:
        raise AssertionError('You can not `divide_by` 0.')
    return CONSTANT / number

Ils peuvent être testés de la même façon :

>>> assert divide_by(1) == CONSTANT
>>> assert divide_user_input(1) == CONSTANT

Cependant, le test de divide_by donnera une couverture de 100%, alors que l'autre fonction n'aura une couverture de seulement 66%.

Pourquoi ? Parce qu'un if génère une branche du code, qu'il faut couvrir aussi, tandis qu'un assert ne génère pas de branche, et n'a pas à être "vérifier" par un test.

C'est une distinction importante, car elle permet de faire la distinction entre :

C'est à vous de juger si une fonction peut être appelée avec des paramètres externes à l'application, ou si cela reste purement en interne. Dans le doute, évitez les assert, mais sinon, c'est un outil très puissant, à la fois pour la simplicité du code, pour éviter des tests "en trop", et, enfin, pour la communication entre développeurs.

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.

Tester une IntegrityError pour un modèle Django

Par Florian Strzelecki - 11:05 - 10.09.2014

Tags : Django, SQL, Unit Testing, Postgres, IntegrityError

Quoi de plus agaçant qu'avoir du code fonctionnel "en vrai", mais dont les tests ne passent pas ? C'est ce qui m'est arrivé hier, alors que je voulais optimiser l'usage de requêtes SQL avec Postgres (solution finalement trouvée avec l'aide de @twidi, un monsieur qui fait des trucs très cool).

Le problème est assez simple : il s'agit de vérifier que votre code lève une IntegrityError lorsqu'une opération ne respecte pas une contrainte d'intégrité, comme une contrainte de clé étrangère invalide sur un item.save(). Prenons les modèles suivant très simple :

from django.db import models

class Category(models.Model):
    title = models.CharField(max_length=118)

class Item(models.Model):
    title = models.CharField(max_length=118)
    category = models.ForeignKey(Category)

La première chose à faire est d'utiliser transaction.atomic autour de l'appel à la méthode save, dans votre code de l'application (et non pas dans le code de vos tests) :

def some_function(invalid_category_pk):
    """The PK is invalid here. No. Matter. What."""
    item = Item(category_id=invalid_category_pk)

    with transaction.atomic():
        item.save()

Si vous utilisez la commande shell, vous verrez l'exception IntegrityError, et passerez le cœur léger à l'écriture des tests unitaires.

Oui, mais.

Si comme moi vous cherchez une classe de base fournie par Django, vous aurez probablement envie d'utiliser TestCase. Cela tombe bien, c'est une sous-classe de TransactionTestCase. Cela devrait fonctionner. N'est-ce pas ? Hein ?

Non. Oh que non.

Aussi étrange que cela m'est apparu au premier abord, si vous utilisez TestCase, cela ne fonctionnera pas : cette classe étend TransactionTestCase spécifiquement pour retirer la gestion des transactions. Oui, personnellement, je trouve cela perturbant et trompeur, et je laisse les mauvaises langues dire que mon avis est biaisé par le temps perdu à découvrir et comprendre cela.

Donc, pour tester cette fonction, il suffit de faire ceci :

from django.db import IntegrityError
from django.test.testcases import TransactionTestCase

class TestFunctions(TransactionTestCase):

    def test_some_function_raise(self):
        """Assert some_function raise when category does not exist"""
        with self.assertRaises(IntegrityError):
            some_function(7814567)

Et voilà ! C'était facile. J'ai perdu des heures pour découvrir ça. Fuck my life.

Au passage, voici la docstring de TestCase :

Does basically the same as TransactionTestCase, but surrounds every test
with a transaction, monkey-patches the real transaction management routines
to do nothing, and rollsback the test transaction at the end of the test.
You have to use TransactionTestCase, if you need transaction management
inside a test.

Je développe donc j'écris des tests unitaires.

Par Florian Strzelecki - 02:07 - 18.05.2011

Tags : J'aime, Programmation, Bonne pratique, Développement, Unit Testing, Technique

Suite à mon article sur PHPUnit, il m'a été posé la question, fort simple dans sa forme, de "Mais pourquoi faire des tests ?".

Après tout, cette question, je me la suis aussi posé avant de faire des tests, ainsi qu'à mon premier apprentissage, et encore aujourd'hui quand j'écris tel ou tel morceau de code, je me demande à quoi servent les tests que je vais écrire, ou que j'ai déjà écrit.

Je vais essayer de faire rapide, car expliquer "pourquoi faut-il faire des tests" n'est pas mon but premier : tout un tas de gens ont écrit tout un tas de choses sur le pourquoi comment, avec études théoriques et pratiques. Non, moi, je vais me contenter de vous exposer, de manière très subjective, pourquoi je fais des tests unitaires.

À la découverte de PHPUnit

Par Florian Strzelecki - 19:40 - 10.05.2011

Tags : Programmation, Bonne pratique, PHP, Unit Testing, Technique

Les tests unitaires, je connais depuis que je fais du python, et je sais qu'il est possible d'en faire avec php, mais je n'avais jamais vraiment essayer avec ce langage. Il faut dire que mes dernières expériences professionnelles n'étaient pas vraiment portées sur la qualité du code (ce qui est dommage, et particulièrement frustrant pour moi).

Mais ce matin, j'ai relevé ma motivation pour m'intéresser de plus près aux tests unitaires avec php. Je suis tombé sur PHPUnit, et j'ai donc passé la matinée à l'installer, l'utiliser, et à me poser des questions.

Cet article traite donc de mon exploration (très récente) de PHPUnit, de sa phase d'installation puis d'utilisation. Le contexte est un petit projet de carte en 2D gérée par Tile (2D-tile based system) avec un algorithme de shadowcasting recursif et tout un tas d'autres détails.

D'ailleurs, ce petit projet sera l'occasion d'un autre article après la publication d'une première version stable (probablement sur bitbucket) avec sa documentation. Oui, c'est du teasing, c'est mal, mais je parle d'abords des tests unitaires. Si je veux.