ANF Qualité Logicielle
Outils de test
11-15 octobre 2021 - IJCLAB
Cyril L'Orphelin (CCIN2P3/CNRS)

Pourquoi tester ? [ Mode développeur pressé ]

  • Ouais c'est long et rébarbatif ...

  • C'est du temps de perdu

  • Je le ferai quand j'aurais du temps , là il faut que je délivre le produit

  • J'ai juste changé une ligne

  • Ce bout de code vole déjà

Pourquoi tester ?

  • Mieux gérer les risques : détecter les bugs avant la sortie en production, améliorer le confort des équipes, avoir un filet de sécurité

  • Étendre la responsabilité individuelle : s'assurer du niveau de qualité d'un logiciel avant de le mettre à disposition

  • Avoir un retour rapide sur votre développement

  • Se prémunir des cas d’utilisation invalides

  • Eviter d'accumuler de la dette technique avec une application maintenable et évolutive

  • C'est un retour sur investissement !

Stratégie de tests

  • L'écriture de tests peut prendre du temps donc cette étape doit être prévue dans le cycle de développement

  • Plutôt que prévoir un temps dévolu seulement à l'écriture des tests, faite le au fur et à mesure

  • Ou même avant de coder - cf TDD

  • Adaptez votre code pour qu'il soit testable, n'adaptez pas vos tests ! Votre code va gagner en lisibilité et découplage

Test Driven Development

Développement piloté par les tests (TDD) : écrire le test unitaire avant d’écrire le code source
    Cycle préconisé
  1. Écrire le test unitaire
  2. Vérifier qu'il échoue
  3. Ecrire le code suffisant pour qu'il passe le tests
  4. Vérifier que le test passe
  5. Nettoyez et refactoriser le code écrit

Les tests unitaires

Les tests unitaires

    Un test est unitaire lorsque :
  • Il ne communique pas avec la base de données

  • Il ne communique pas avec d’autres ressources sur le réseau

  • Il ne manipule pas un ou plusieurs fichiers

  • Il peut s’exécuter en même temps que les autres tests unitaires

  • Il est automatique

  • Il est répétable

Les tests unitaires - Bonnes pratiques

  • 100% des appels externes du composant sont simulés par des bouchons.

  • Un test doit être simple, normé, commenté, facile à lire et à comprendre.

  • Un test doit être rapide à exécuter (la suite de tests doit pouvoir être exécutée souvent).

  • Les tests doivent être écrits le plus tôt possible (il n’y a jamais de « plus tard »).

  • Un test = une assertion.

  • Les parties critiques de l’application sont testées en priorité.

  • Le code de l’application doit être écrit et refactorisé de façon à être testable.

  • Le nom d’un test doit être normalisé (la lecture du nom doit permettre de savoir exactement ce que va faire le test).

Les tests unitaires - Structure "AAA"

  • « Arrange » : initialisation du contexte d’exécution du test unitaire (variables, données, dépendances, bouchons…)

  • « Act » : exécution du traitement nécessaire pour la vérification du comportement du code testé

  • « Assert » : vérification du critère de réussite du test au travers d’une assertion.

Les types de tests

    Lors de la rédaction de tests pour une unité, il y a trois types principaux de tests auxquels il convient de penser
  • les tests de cas normaux, qui vérifient que l'unité se comporte bien dans les situations « normales »

  • les tests de cas d'erreur, qui vérifient que les erreurs qui doivent être signalées le sont bien, p.ex. lorsqu'un argument invalide est fourni

  • les tests de cas aux limites, qui vérifient que l'unité se comporte bien dans les situations délicates, p.ex. qu'une méthode qui accepte un tableau de taille quelconque fonctionne correctement s'il est vide

Bonnes pratiques : cas aux limites

  • pour des arguments entiers : tester avec 0 ainsi que des entiers positifs et négatifs

  • pour les chaînes de caractères : tester avec une chaîne vide, une chaîne contenant des caractères quelconques, une chaîne contenant des mots séparés par des espaces, virgules ou des retours à la ligne, voir si les chiffres ou caractères spéciaux sont bien supportés, voir s’il est possible de traiter une très longue chaîne de caractères, ...

  • pour une liste : tester avec une liste vide, une liste avec un seul élément, une liste contenant des éléments tous différents, une liste contenant plusieurs fois le même élément, …

  • pour des fichiers : tester avec des fichiers vides, valides et invalides

Les test en Python avec unittest
unittest

Python comprend un module de testing assez complet et pratique nommé unittest.
unittest permet de définir des cas de tests, avec notamment un jeu d'assertions assez complet et pratique.
Alternatives possibles : Doctest , py.test, Hypothesis, tox, mock

unittest permet

  • l'automatisation des tests

  • le partage de code pour la mise en place et la finalisation des tests

  • l'agrégation de tests en collections

unittest en ligne de commande
  • Le module unittest est utilisable depuis la ligne de commande pour exécuter des tests à partir de modules, de classes ou même de méthodes de test individuelles

  • python -m unittest test_module1 test_module2
    python -m unittest test_module.TestClass
    python -m unittest test_module.TestClass.test_method
                        
  • Unittest prend aussi en charge une découverte simple des tests

    • Tous les fichiers de test doivent être des modules ou des paquets importables du répertoire du projet

    •                 cd project_directory
      python -m unittest discover
                      
                      
    • Si vous avez installé coverage.py (voir présentation Outils d’analyse et de mesure de la qualité du code)

                    cd project_directory
    coverage run -m unittest discover
                    
                    
Organisation des tests
  • Un scénario de test est une classe avec une unité logique groupant un certain nombre de tests

  • Dans unittest, les scénarios de test sont représentés par des instances de la classe unittest.TestCase

  • Pour créer vos propres scénarios de test, vous devez écrire des sous-classes de TestCase

  • Un test est une méthode dont le nom est préfixé par test_

  • Le cœur de chaque test est un appel aux méthodes assert*()

  • Si le test échoue, une exception est levée avec un message explicatif, et unittest identifie ce scénario de test comme un échec. Toute autre exception est traitée comme une erreur

Code

import unittest

def is_even(nbr):
    """
    Cette fonction teste si un nombre est pair.
    """
    return nbr % 2 == 0

class MyTest(unittest.TestCase):
    def test_is_even(self):
        self.assertTrue(is_even(2))
        self.assertFalse(is_even(1))
        self.assertEqual(is_even(0), True)

if __name__ == '__main__':
    unittest.main()
            
Execution

                .
--------------------
Ran 1 test in 0.001s

OK
            

Principales assertions avec unittest

Méthode Test python équivalent
assertEqual(a, b) a == b
assertNotEqual(a, b) a != b
assertTrue(x) bool(x) == True
assertFalse(x) bool(x) == False
assertIs(a, b) a is b
assertIsNot(a,b) a is not b
assertIsInstance(a, b) isinstance(a, b)


Notons aussi l'existence de la méthode assertRaises qui vous permettra de vous assurer qu'une méthode lève bien le bon type d'exception en cas de problème

Exemple

                import unittest

class TestStringMethods(unittest.TestCase):

    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')

    def test_isupper(self):
        self.assertTrue('FOO'.isupper())
        self.assertFalse('Foo'.isupper())

    def test_split(self):
        s = 'hello world'
        self.assertEqual(s.split(), ['hello', 'world'])
        # check that s.split fails when the separator is not a string
        with self.assertRaises(TypeError):
            s.split(2)

if __name__ == '__main__':
    unittest.main()
            

Sortie avec l'option -v

test_isupper (__main__.TestStringMethods) ... ok test_split (__main__.TestStringMethods) ... ok test_upper (__main__.TestStringMethods) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.001s OK
Organiser le code de test
  • Le code de mise en place des tests peut être factorisé avec la méthode setUp()

  • Si la méthode setUp() lève une exception, le sytème considère le test en erreur

  • De même, on peut fournir une méthode tearDown() qui nettoie après l'exécution de la méthode de test

  • Si setUp() a réussi, tearDown() est exécutée, que la méthode de test ait réussi ou non.

  • Les décorateurs @unittest.skip("reason") and @unittest.skipIf(condition) peuvent être utilisés pour éviter un test.

                import unittest

class WidgetTestCase(unittest.TestCase):
    def setUp(self):
        self.widget = Widget('The widget')

    def test_default_widget_size(self):
        self.assertEqual(self.widget.size(), (50,50),  'incorrect default size')

    def test_widget_resize(self):
        self.widget.resize(100,150)
        self.assertEqual(self.widget.size(), (100,150), 'wrong size after resize')

    def tearDown(self):
        self.widget.dispose()
            
unittest.mock
  • Cette librairie va permettre de simuler des classes, méthodes ...

  • Vous pouvez ainsi à partir des objets Mock et MagicMock créer tous les attributs et méthodes nécessaires à une simulation

                class MyClass(object):
    def __init__(self):
        self.data = None

    def readData(self, source):
        self.data = source.read()
        source.close()
                
                
                import unittest
from mock import Mock

from mymodule import MyClass

def TestMyClass(unittest.TestCase):

    def testConstructor(self):
        "Test the default state"
        myclass = MyClass()
        self.assertEqual(myclass.data, None)

    def testReadData(self):
        #### Simulation de l'appel à une ressources
        myclass = MyClass()
        source = Mock()
        source.read.return_value = 'some data'
        myclass.readData(source)

        self.assertEqual(myclass.data, 'some data')
        self.assertTrue(source.read.called)
        self.assertTrue(source.close.called)
                
                
Les tests de performance
  • Une première approche grossière mais intéressante : la mesure du temps d'exécution
  • import datetime
    start_time = datetime.datetime.now()
    # insert code snippet here
    end_time = datetime.datetime.now()
    elapsed = end_time - start_time
    print(f'Temps d\'exécution : {elapsed:.2}ms')
    Temps d'exécution : 0.04ms
  • Pour être un peu plus précis : la librairie cProfile
  • import cProfile
    import numpy as np
    cProfile.run("20+10")
    3 function calls in 0.000 seconds
    
                    Ordered by: standard name
    
                    ncalls  tottime  percall  cumtime  percall filename:lineno(function)
                    1    0.000    0.000    0.000    0.000 <string>:1(<module>)
                    1    0.000    0.000    0.000    0.000 {built-in method builtins.exec}
                    1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects} 
  • Avec des outils externes : votre IDE, py-espion , pyroscope