Python tester son code pour le rendre plus fiable
Introduction
Afin de rendre son code plus robuste et plus fiable, il est impératif de mettre en place un ensemble de tests permettant de valider le fonctionnement du code. Les tests sont les garants du résultat. Par ailleurs, pour qu'un code soit facilement testable, il faut qu'il soit modulable et atomique. Un code sera d'autant plus facilement testable que l'on pourra isoler de petites parties et controller spécifiquement l'execution. Il y a énormément de choses à dire autour des tests de code. Cet atelier permet d'aborder les bases et de pouvoir mettre en place un jeu de tests autour d'un module. Pour aller plus loin, vous pouvez vous intéresser au TDD (Test Driven Development), dont la mentalité est le test avant tout.
Les tests, qu'est ce que c'est ?
Pour appréhender ce qu'est un test, le plus simple est d'aller faire un peu de revue de code sur notre package bioinfo préféré: la biopython. Si vous récupérez le code de la biopython ou que vous naviguez sur sa documentation ici, vous remarquerez qu'il y a un dossier Tests
. Ce dossier contient l'ensemble des tests immplémentés pour les modules de la Biopython. Comme nous le verrons plus loin en plus d'aider au développement ils pourront être joués lors de l'installation du package. Les tests ont donc un intérêt lors du développement mais aussi pendant le déploiement du code.
Regardons de plus près le fichier de test: test_File.py
from __future__ import print_function
import os.path
import shutil
import sys
import tempfile
import unittest
from Bio import bgzf
from Bio import File
from Bio._py3k import StringIO
data = """This
is
a multi-line
file"""
...
class RandomAccess(unittest.TestCase):
def test_plain(self):
with File._open_for_random_access("Quality/example.fastq") as handle:
self.assertTrue("r" in handle.mode)
self.assertTrue("b" in handle.mode)
def test_bgzf(self):
with File._open_for_random_access("Quality/example.fastq.bgz") as handle:
self.assertIsInstance(handle, bgzf.BgzfReader)
def test_gzip(self):
self.assertRaises(ValueError,
File._open_for_random_access,
"Quality/example.fastq.gz")
Le fichier de tests comporte plusieurs classes qui héritent toute de unittest.TestCase
, classe TestCase
du module de test unittest
. Chaque classe implémente plusieurs méthodes commençant par test, qui correspond plus ou moins à un test. Néanmoins chaque test peut comporter plusieurs valeurs testées. En héritant de TestCase
, les classes créées vont bénéficier d'un grand ensemble de méthodes de test. Par exemple, la première méthode test_plain()
va éssayer 2 assertions. Quand j'ouvre le fichier fastq (de test) "Quality/example.fastq" via la méthode _open_for_random_access()
, est ce que le fichier est en mode 'r' et 'b' ?
Regardons maintenant la classe de test suivante.
class AsHandleTestCase(unittest.TestCase):
def setUp(self):
# Create a directory to work in
self.temp_dir = tempfile.mkdtemp(prefix='biopython-test')
def tearDown(self):
shutil.rmtree(self.temp_dir)
def _path(self, *args):
return os.path.join(self.temp_dir, *args)
def test_handle(self):
"Test as_handle with a file-like object argument"
p = self._path('test_file.fasta')
with open(p, 'wb') as fp:
with File.as_handle(fp) as handle:
self.assertEqual(fp, handle, "as_handle should "
"return argument when given a "
"file-like object")
self.assertFalse(handle.closed)
self.assertFalse(handle.closed,
"Exiting as_handle given a file-like object "
"should not close the file")
def test_string_path(self):
"Test as_handle with a string path argument"
p = self._path('test_file.fasta')
mode = 'wb'
with File.as_handle(p, mode=mode) as handle:
self.assertEqual(p, handle.name)
self.assertEqual(mode, handle.mode)
self.assertFalse(handle.closed)
self.assertTrue(handle.closed)
@unittest.skipIf(
sys.version_info < (3, 6),
'Passing Path objects to File.as_handle requires Python >= 3.6',
)
def test_path_object(self):
"Test as_handle with a pathlib.Path object"
from pathlib import Path
p = Path(self._path('test_file.fasta'))
mode = 'wb'
with File.as_handle(p, mode=mode) as handle:
self.assertEqual(str(p.absolute()), handle.name)
self.assertEqual(mode, handle.mode)
self.assertFalse(handle.closed)
self.assertTrue(handle.closed)
@unittest.skipIf(
sys.version_info < (3, 6),
'Passing path-like objects to File.as_handle requires Python >= 3.6',
)
def test_custom_path_like_object(self):
"Test as_handle with a custom path-like object"
class CustomPathLike:
def __init__(self, path):
self.path = path
def __fspath__(self):
return self.path
p = CustomPathLike(self._path('test_file.fasta'))
mode = 'wb'
with File.as_handle(p, mode=mode) as handle:
self.assertEqual(p.path, handle.name)
self.assertEqual(mode, handle.mode)
self.assertFalse(handle.closed)
self.assertTrue(handle.closed)
def test_stringio(self):
s = StringIO()
with File.as_handle(s) as handle:
self.assertIs(s, handle)
if __name__ == "__main__":
runner = unittest.TextTestRunner(verbosity=2)
unittest.main(testRunner=runner)
Plusieurs choses intéressantes sont à noter. L'héritage de TestCase
vous donne la possibilité de surcharger les méthodes setUp()
et tearDown()
qui seront utilisées respectivement avant les tests et après les tests. Vous pouvez donc via ces méthodes, configurer plus facilement votre environnement de test en spécifiant des attributs partagés.
Nous pouvons voir aussi que certaines méthodes comportent des décorateurs (`@unittest.skipIf`). Plusieurs décorateurs existent permettant par exemple de ne pas executer le test, ce qui est utile en phase de développement ou de résolution de bugs, ou encore de spéficier des contraintes pour son execution (module necessaire, version de Python, ...).
Il existe donc un grand nombre de méthodes d'assertion ou de control d'execution des tests. Afin de ne pas reprendre l'ensemble des possibilités ici, le plus simple est d'explorer la documentation de unittest.
Exécutons maintenant les tests. Grâce au __main__
implémenté dans le fichier nous pouvons lancer la suite de tests:
python test_File.py
test_custom_path_like_object (__main__.AsHandleTestCase)
Test as_handle with a custom path-like object ... skipped 'Passing path-like objects to File.as_handle requires Python >= 3.6'
test_handle (__main__.AsHandleTestCase)
Test as_handle with a file-like object argument ... ok
test_path_object (__main__.AsHandleTestCase)
Test as_handle with a pathlib.Path object ... skipped 'Passing Path objects to File.as_handle requires Python >= 3.6'
test_string_path (__main__.AsHandleTestCase)
Test as_handle with a string path argument ... ok
test_stringio (__main__.AsHandleTestCase) ... ok
test_bgzf (__main__.RandomAccess) ... ok
test_gzip (__main__.RandomAccess) ... FAIL
test_plain (__main__.RandomAccess) ... ok
test_one (__main__.UndoHandleTests) ... ok
test_read (__main__.UndoHandleTests)
Test read method ... ok
test_undohandle_read_block (__main__.UndoHandleTests) ... ok
======================================================================
FAIL: test_gzip (__main__.RandomAccess)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_File.py", line 85, in test_gzip
"Quality/example.fastq.gz")
AssertionError: ValueError not raised
----------------------------------------------------------------------
Ran 11 tests in 0.002s
FAILED (failures=1, skipped=2)
Nous pouvons voir que les tests se sont executés les uns après les autres dans l'orde alphabétique. Un test a échoué, le détail apparaît alors en fin de la suite de test.
Doctest: le mélange de la documentation et du test
Une autre façon de faire des tests est l'utilisation du module doctest
de python. Le but principal de doctest
est de pouvoir très facilement intégrer des cas de tests du code au plus près de l'implémentation. Cela limite le nombre de fichier crée en revanche cela peut aussi alourdir le code et rendre moins lisible la partie 'vrai code' de votre fichier. L'utilisation de doctest
n'est pas incompatible avec d'autre type de module, c'est simplement une philosphie différente.
Le fonctionnement de cas de tests imbriqués au milieu du code, se fait par la recherche de >>>
au milieu de commentaires. Les tests peuvent ensuite être joués de différentes manières. Pour bien comprendre le lancement des tests via doctest
, il faut s'imaginer l'ouverture d'un shell Python dans lequel vous lancez des commandes. Chaque retour de vos commandes peut être ensuite testé.
Comme toujours, une revue de code est bien plus efficace que de longues explications. Intéressons nous donc au module Bio.Seq de la biopython. Voici un extrait de sa méthode __init()__
:
def __init__(self, data, alphabet=Alphabet.generic_alphabet):
"""Create a Seq object.
Arguments:
- seq - Sequence, required (string)
- alphabet - Optional argument, an Alphabet object from
Bio.Alphabet
You will typically use Bio.SeqIO to read in sequences from files as
SeqRecord objects, whose sequence will be exposed as a Seq object via
the seq property.
However, will often want to create your own Seq objects directly:
>>> from Bio.Seq import Seq
>>> from Bio.Alphabet import IUPAC
>>> my_seq = Seq("MKQHKAMIVALIVICITAVVAALVTRKDLCEVHIRTGQTEVAVF",
... IUPAC.protein)
>>> my_seq
Seq('MKQHKAMIVALIVICITAVVAALVTRKDLCEVHIRTGQTEVAVF', IUPACProtein())
>>> print(my_seq)
MKQHKAMIVALIVICITAVVAALVTRKDLCEVHIRTGQTEVAVF
>>> my_seq.alphabet
IUPACProtein()
"""
# Enforce string storage
if not isinstance(data, basestring):
raise TypeError("The sequence data given to a Seq object should "
"be a string (not another Seq object etc)")
self._data = data
self.alphabet = alphabet # Seq API requirement
On peut voir que 3 tests ont été implémentés. Le premier qui test le retour de my_seq
, le second de print(my_seq)
et enfin my_seq.alphabet
. Maintenant, si nous regardons la fin du fichier, le __main__
de l'objet a été implémenté avec:
def _test():
"""Run the Bio.Seq module's doctests (PRIVATE)."""
print("Running doctests...")
import doctest
doctest.testmod(optionflags=doctest.IGNORE_EXCEPTION_DETAIL)
print("Done")
if __name__ == "__main__":
_test()
Ainsi, l'objet (module) peut-être directement utilisé comme un script si il est appelé directement (via __main__
). Dans ce cas, il y aura execution des tests doctest
via la méthode doctest.testmod()
. C'est ce que nous faisons si dessous:
python3.7 -m Bio.Seq
Running doctests...
/home/nlapalu/Workspace/Github/biopython/Bio/Seq.py:441: BiopythonDeprecationWarning: This method is obsolete; please use str(my_seq) instead of my_seq.tostring().
BiopythonDeprecationWarning)
/home/nlapalu/Workspace/Github/biopython/Bio/Seq.py:2626: BiopythonWarning: This table contains 2 codon(s) which code(s) for both STOP and an amino acid (e.g. 'TGA' -> 'W' or STOP). Such codons will be translated as amino acid.
BiopythonWarning)
Done
Pour avoir plus d'info, il faut demander le mode verbose
python3.7 -m Bio.Seq -v
/home/nlapalu/Workspace/Github/biopython/Bio/Seq.py:441: BiopythonDeprecationWarning: This method is obsolete; please use str(my_seq) instead of my_seq.tostring().
BiopythonDeprecationWarning)
Running doctests...
Trying:
from Bio.Seq import MutableSeq
Expecting nothing
ok
Trying:
from Bio.Alphabet import generic_dna
Expecting nothing
ok
Trying:
my_seq = MutableSeq("ACTCGTCGTCG", generic_dna)
Expecting nothing
ok
Trying:
my_seq
Expecting:
MutableSeq('ACTCGTCGTCG', DNAAlphabet())
ok
...
6 tests in __main__.UnknownSeq.transcribe
10 tests in __main__.UnknownSeq.translate
10 tests in __main__.UnknownSeq.ungap
7 tests in __main__.UnknownSeq.upper
10 tests in __main__._translate_str
1 tests in __main__.back_transcribe
1 tests in __main__.complement
1 tests in __main__.reverse_complement
1 tests in __main__.transcribe
15 tests in __main__.translate
449 tests in 103 items.
449 passed and 0 failed.
Test passed.
Done
Un des problème de doctest est sa sensibilité au retour du test. En effet, ce qui est vraiment testé est une chaine de caractères qui correspond à l'affichage interactif de votre commande. Il suffit donc d'un léger écart dans la chaîne de retour pour que le test soit faux (exemple un espace vide à la fin de votre test). Si vous souhiatez donc utiliser doctest
il faut essayer de faire des tests qui limiteront ces possibles problèmes. De même si vous souhaitez connaître toutes les subtilités du module, la documentation officielle est ici: doctest
Cohabitation unittest
et doctest
Nous venons de voir 2 façons de faire des tests via unittest
ou doctest
. Les 2 ont des pours et des contres. On veut vouloir par exemple plutôt faire du doctest
sur des objets de type entité et plutôt de l'unittest
sur du traitement. Bref, le mélange des 2 apparaît une bonne solution. Il est tout à fait possible de pouvoir appeler par exemple les tests générés via doctest
dans une classe TestCase
cela grâce à une méthode spéciale load_tests()
. Nous ne détaillerons pas plus ici cette possibilité, mais nous essayerons de nous en servir dans l'implémentation à réaliser.
Les différents modules de tests
Clairement il y a de gandes chances qu'il ne vous soit pas nécessaire d'utiliser d'autres modules de tests que unittest
et doctest
. Neanmoins sachez qu'il existe d'autres modules:
- py.test
- Nose
- tox
- unittest2
Plus d'info ici
Intégration dans le packaging de module
Le dernier point que nous aborderons est lié au packaging de vos modules. Nous ferons un atelier spécifique sur cet aspect, mais sachez que tous les tests que vous avez implémentés peuvent vous servir de pré-requis à l'installation de votre package. En effet, il est tres courant d'avoir une tache (commande) test
lors de l'installation de votre package. Elle est la plupart du temps accessoire et il faut la forcer en faisant: python setup.py test
. Si nous regardons le fichier de packaging setup.py
de la biopython, cette tache est bien présente:
setup(name='biopython',
version=__version__,
author='The Biopython Contributors',
author_email='biopython@biopython.org',
url='https://biopython.org/',
description='Freely available tools for computational molecular biology.',
long_description=readme_rst,
classifiers=[
'Development Status :: 5 - Production/Stable',
'Intended Audience :: Developers',
'Intended Audience :: Science/Research',
'License :: Freely Distributable',
# Technically the "Biopython License Agreement" is not OSI approved,
# but is almost https://opensource.org/licenses/HPND so might put:
# 'License :: OSI Approved',
# To resolve this we are moving to dual-licensing with 3-clause BSD:
# 'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Topic :: Scientific/Engineering',
'Topic :: Scientific/Engineering :: Bio-Informatics',
'Topic :: Software Development :: Libraries :: Python Modules',
],
cmdclass={
"install": install_biopython,
"build_py": build_py_biopython,
"build_ext": build_ext_biopython,
"test": test_biopython,
},
packages=PACKAGES,
ext_modules=EXTENSIONS,
package_data={
'Bio.Entrez': ['DTDs/*.dtd',
'DTDs/*.ent',
'DTDs/*.mod',
'XSDs/*.xsd'],
},
install_requires=REQUIRES,
)
La tache appelle la classe test_biopython
qui hérite de Command
, dont la méthode run()
permettra l'execution de l'ensemble des fichiers de tests:
class test_biopython(Command):
"""Run all of the tests for the package.
This is a automatic test run class to make distutils kind of act like
perl. With this you can do:
python setup.py build
python setup.py install
python setup.py test
"""
description = "Automatically run the test suite for Biopython."
user_options = []
def initialize_options(self):
"""No-op, initialise options."""
pass
def finalize_options(self):
"""No-op, finalise options."""
pass
def run(self):
"""Run the tests."""
this_dir = os.getcwd()
# change to the test dir and run the tests
os.chdir("Tests")
sys.path.insert(0, '')
import run_tests
run_tests.main([])
# change back to the current directory
os.chdir(this_dir)
Implémentation
Nous allons utiliser l'implémentation de la class System
réalisée dans l'atelier POO Python suite pour mettre en place une série de tests. Afin de couvrir les notions vues dans l'atelier, nous allons à la fois implémenter des tests unitaires avec unittest
et doctest
. Pour rappel, voici l'implémentation de la class System
:
class System(object):
nbSystems = 0
def __init__(self, reactions=()):
"""Initialize a new System object."""
self.__reactions = set(reactions)
self.__class__.nbSystems += 1
def __del__(self):
self.__class__.nbSystems -= 1
@property
def reactions(self):
return self.__reactions
@reactions.setter
def reactions(self, reactions=()):
self.__reactions = set(reactions)
@classmethod
def merge_systems(cls,systems=[]):
lreactions = []
for sys in systems:
lreactions.extend(sys.reactions)
return cls(set(lreactions))
@staticmethod
def count_reactions(systems=[]):
return sum([len(syst.reactions) for syst in systems])
if __name__ == "__main__":
syst = System(("reac1", "reac2"))
print(System.nbSystems)
print(syst.reactions)
syst2 = System(("reac1", "reac3"))
print(System.nbSystems)
print(syst2.reactions)
syst3 = System.merge_systems([syst,syst2])
print(System.nbSystems)
print(syst3.reactions)
print(System.count_reactions([syst,syst2,syst3]))
1
set(['reac1', 'reac2'])
2
set(['reac1', 'reac3'])
3
set(['reac1', 'reac2', 'reac3'])
7
Tout d'abord, vous allez implementer une classe de test TestSystem
qui héritera de unittest.TestCase
. Cette classe implémentera 2 méthodes qui vous permettront de tester merge_systems()
et count_reactions()
de la classe System
. Point important, il vous sera nécessaire de modifier l'implémentation de la classe System
pour pouvoir tester l'égalité de 2 instances de System
. Pour cela souvenez vous (atelier 1: POO) des méthodes dunder qui permettent de comparer des objets et des instances d'objets.
Par la suite, vous éliminez le code situé dans le __main__
de System
pour le placer sous forme de doctest
dans l'__init__
de System.
Nous verrons ensuite ensemble comment appeler les doctest
depuis votre fichier de tests, grâce à la méthode load_tests()
.
La solution est ici
Retour sur l'atelier
Vous êtes maintenant capable d'implémenter des tests sur votre code. Retenez bien que l'intérêt des tests est multiple:
- contrôle des bugs et de la régression du code
- maintenabilité du code
- aide au développement
- facilite l'intégration dans des environnements divers
Il faut néanmoins faire attention à ne pas sur-tester votre code. Maintenir et écrire des tests à un coût, il faut que cela soit justifié.
Pour compléter cet atelier sur les tests, nous aurions pu aussi aborder la notion de couverture de code. Cette métrique suplémentaire permet de vérifier que l'ensemble du code écrit est utilisé dans au moins un cas et que chacun de ces cas est vérifé par un ou des tests (à voir pour un prochain atelier).