Faire des tests en Python

Pourquoi faire des tests ?

  • Avant tout pour maîtriser sa peur.
  • À tout moment on peut lancer les tests pour se rassurer que le code marche toujours (ou pas :p).
  • Mais aussi pour assurer la qualité du logiciel produit.

Les tests en python

La librairie standard fournit une libraire de tests: unittest. L'api est très classique et très proche de l'api XUnit:

  • assertEqual
  • assertNotEqual
  • assertTrue
  • assertFalse
  • assertRaises
  • assertAlmostEqual

Les cas de test doivent hériter de unittest.TestCase et toute méthode commençant par test sera considéré comme un test.

Si le TestCase définit une méthode setUp, elle sera exécutée avant chaque test et sert à préparer l'environnement de test (DB, fichiers, instancier des objets).

Si le TestCase définit une méthode tearDown, elle sera exécutée après chaque test et sert à nettoyer l'environnement de test (effacer une DB).

Exemple

 1 import random
 2 import unittest
 3 
 4 class TestSequenceFunctions(unittest.TestCase):
 5 
 6     def setUp(self):
 7         self.seq = range(10)
 8 
 9     def test_shuffle(self):
10         # make sure the shuffled sequence does not lose any elements
11         random.shuffle(self.seq)
12         self.seq.sort()
13         self.assertEqual(self.seq, range(10))
14 
15         # should raise an exception for an immutable sequence
16         self.assertRaises(TypeError, random.shuffle, (1,2,3))
17 
18     def test_choice(self):
19         element = random.choice(self.seq)
20         self.assertTrue(element in self.seq)
21 
22 if __name__ == '__main__':
23     unittest.main()

Python 2.7

Python 2.7 a vu arriver de nombreuses nouveautés dans unittest, en commençant par de nouvelles méthodes d'assertions:

  • assertGreater / assertLess / assertGreaterEqual / assertLessEqual
  • assertRegexpMatches(text, regexp) - verifies that regexp search matches text
  • assertNotRegexpMatches(text, regexp)
  • assertIn(value, sequence) / assertNotIn - assert membership in a container
  • assertIs(first, second) / assertIsNot - assert identity
  • assertIsNone / assertIsNotNone
  • Et encore plus…

Les messages d'erreurs ont aussi étés grandement améliorés comme lors de la comparaison entre 2 strings qui montre maintenant la différence entre elles.

Ces nouveautés ont étés backportés dans les version 2.4, 2.5 et 2.6 de python dans le paquet unittest2.

Les nouveautés (assert_raises)

1 # access the exception object
2 with self.assertRaises(TypeError) as cm:
3     do_something()
4 
5 exception = cm.exception
6 self.assertEqual(exception.error_code, 3)

Les nouveautés (assert_equal)

1 list1 = range(5)
2 list2 = range(2, 7)
3 self.assertEqual(list1, list2)

Sortie de l'exécution:

 1 ======================================================================
 2 FAIL: test (__main__.TestCase)
 3 ----------------------------------------------------------------------
 4 Traceback (most recent call last):
 5   File "sources/test_equal.py", line 7, in test
 6     self.assertEqual(list1, list2)
 7 AssertionError: Lists differ: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9,... != [2, 3, 4, 5, 6, 7, 8, 9, 10, 1...
 8 
 9 First differing element 0:
10 0
11 2
12 
13 - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
14 ?  ------
15 
16 + [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
17 ?                                            ++++++++

Test discovery

Unittest jusqu'à python-2.7 ne proposait pas de système de découverte automatique de test. Depuis il est possible d'utiliser la découverte de test en ligne de commande avec:

python -m unittest discover

Nose

Unittest est suffisant pour faire la plupart des tests mais le lancement des tests est un point noir quand l'on veut lancer rapidement certains tests . De plus nose fournit des fonctionalités intéressantes comme:

  • Capture de la sortie standard et d'erreur par tests.
  • Des plugins pour avoir le code coverage, un résumé des tests en XML (compatible Xunit) ou pour lancer le déboggeur sur un test en erreur.
  • Une manière moins verbeuse d'écrire des tests.

Exemple

 1 class Sample(object):
 2 
 3     def test(self, a, b, c):
 4         print "Divider", b - c
 5         return a / (b - c)
 6 
 7 import unittest
 8 
 9 class TestCase(unittest.TestCase):
10 
11     def test_first(self):
12         self.assertEqual(Sample().test(1, 4, 2), 1/2)
13 
14     def test_second(self):
15         self.assertEqual(Sample().test(0, 2, 1), 0)
16 
17     def test_third(self):
18         self.assertEqual(Sample().test(4, 3, 3), None)
19 
20 if __name__ == '__main__':
21     unittest.main()

Exécution

  • Exécution avec python:

    $ python test_output.py
    Divider 2
    .Divider 1
    .Divider 0
    E
    ======================================================================
    ERROR: test_third (main.TestCase)
    ----------------------------------------------------------------------
    Traceback (most recent call last):
      File "test_output.py", line 18, in test_third
        self.assertEqual(Sample().test(4, 3, 3), None)
      File "test_output.py", line 5, in test
        return a / (b - c)
    ZeroDivisionError: integer division or modulo by zero
    ----------------------------------------------------------------------
    Ran 3 tests in 0.002s

    FAILED (errors=1)

  • Exécution avec nosetests

    $ nosetests
    ..E
    ======================================================================
    ERROR: test_third (test_output.TestCase)
    ----------------------------------------------------------------------
    Traceback (most recent call last):
      File "/Users/lothiraldan/Labo/presentations/PythonTest/sources/test_output.py", line 18, in test_third
        self.assertEqual(Sample().test(4, 3, 3), None)
      File "/Users/lothiraldan/Labo/presentations/PythonTest/sources/test_output.py", line 5, in test
        return a / (b - c)
    ZeroDivisionError: ZeroDivisionError: integer division or modulo by zero
    -------------------- >> begin captured stdout << ---------------------
    Divider 0
    --------------------- >> end captured stdout << ----------------------
    ----------------------------------------------------------------------
    Ran 3 tests in 0.005s

    FAILED (errors=1)

Écrire des tests avec nose

1 EMAIL_REGEXP = r'[\S.]+@[\S.]+'
2 
3 def test_email_regexp():
4    # a regular e-mail address should match
5    assert re.match(EMAIL_REGEXP, 'test@nowhere.com')
6 
7    # no domain should fail
8    assert not re.match(EMAIL_REGEXP, 'test@')

Sortie avec nosetests:

 1 ======================================================================
 2 FAIL: nose_example.test_email_regexp
 3 ----------------------------------------------------------------------
 4 Traceback (most recent call last):
 5   File "/Library/Python/2.5/site-packages/nose-1.1.2-py2.5.egg/nose/case.py", line 197, in runTest
 6     self.test(*self.arg)
 7   File "/Users/lothiraldan/Labo/presentations/PythonTest/sources/nose_example.py", line 13, in test_email_regexp
 8     assert not re.match(EMAIL_REGEXP, 'test@nowhere')
 9 AssertionError
10 
11 ----------------------------------------------------------------------
12 Ran 1 test in 0.001s
13 
14 FAILED (failures=1)

Des librairies intéressantes:

  • Mock (http://www.voidspace.org.uk/python/mock/) : Vous permet de "Mocker" certaines parties de votre programme pour mieux isoler vos tests.
  • Unittest templates (https://bitbucket.org/lothiraldan/unittest-templates) : Vous permet de réutiliser vos méthodes de tests pour tester plusieurs cas.
  • Fusil (https://bitbucket.org/haypo/fusil/wiki/Home) : Outil de fuzzing écrit en python.
  • Plus (Web testing, Gui testing) : http://pycheesecake.org/wiki/PythonTestingToolsTaxonomy