Python moderne, partie 2 : écrire les tests unitaires & respecter les conventions Git commit
By BRAZA Faouzi
24 juin 2021
- Catégories
- DevOps & SRE
- Tags
- Git
- pandas
- Python
- Tests unitaires [plus][moins]
Ne ratez pas nos articles sur l'open source, le big data et les systèmes distribués, fréquence faible d’un email tous les deux mois.
L’application de bonnes pratiques en ingénierie logicielle apporte une plus-value certaine à vos projets. Par exemple l’écriture de tests unitaires vous permet de maintenir de larges projets en vous assurant que des parties spécifiques de votre code fonctionnent correctement. Écrire des messages Git commit cohérents, lisibles et compréhensibles par tous, améliore le suivi et la compréhension des changements apportés durant le processus de développement et de maintenance de votre projet, tout en fluidifiant la communication entre les différents collaborateurs.
Au cours de l’article précédent nous avons installé différentes version de python à l’aide de pyenv
, définit une version locale de Python pour notre projet avec pyenv
, initialisé et isolé notre projet dans un environnement virtuel avec poetry
. Dans cette article nous verrons comment écrire les tests unitaires et valider le format des messages Git. Le code utilisé dans cet article est disponible sur GitHub.
Cet article est le second d’une série de trois dans laquelle nous partageons nos meilleures pratiques concernant la gestion de projet Python :
- Partie 1 : création d’un projet avec pyenv et poetry
- Partie 2 : tests unitaires et respect des conventions Git commit
- Partie 3 : Intégration continue avec GitHub Actions et publication sur PiPy
Tester notre code
Notre projet consiste en une simple fonction Python qui retourne des méta-données liées aux DataFrame pandas. La fonction retourne le nombre de lignes et de colonnes ainsi que la fréquence de chaque type de données présent dans notre DataFrame. Par exemple à partir d’un quelconque DataFrame nous souhaiterons obtenir un résultat similaire à :
---- Data Summary ------
Values
Number of rows 230
Number of columns 9
float64 3
int64 4
object 2
Tout d’abord assurez vous d’activer votre environnement virtuel :
poetry shell
Ensuite ajoutons quelques dépendances avec poetry :
poetry add -D pynvim numpy pandas
Using version ^0.4.3 for pynvim
Using version ^1.20.2 for numpy
Using version ^1.2.3 for pandas
Updating dependencies
Resolving dependencies... (1.4s)
Writing lock file
Package operations: 8 installs, 0 updates, 0 removals
• Installing six (1.15.0)
• Installing greenlet (1.0.0)
• Installing msgpack (1.0.2)
• Installing numpy (1.20.2)
• Installing python-dateutil (2.8.1)
• Installing pytz (2021.1)
• Installing pandas (1.2.3)
• Installing pynvim (0.4.3)
L’argument -D
indique que la dépendances s’applique uniquement aux environments de développement.
J’utilise personnellement NeoVim. C’est pourquoi j’ai besoin d’ajouter le package
pynvim
qui permet l’utilisation de plugins Python pour NeoVim.
Pour résoudre notre problème ici nous pouvons :
- calculer les dimensions de notre DataFrame
- calculer la fréquence des différents types de données présentes
- agréger nos deux précédents résultats dans un seul DataFrame
Une fois le résultat obtenu, nous pouvons l’imprimer à l’écran. Voici ci-dessous ce que pourrait être le code :
import pandas as pd
def data_summary(df: pd.DataFrame) -> None:
"""
Function defined to return a DataFrame containing details
about the number of rows and columns and the column dtype
frequency of the passed pandas DataFrame
"""
def _shape(df: pd.DataFrame) -> None:
"""
Function defined to return a dataframe with details about
the number of row and columns
"""
return None
def _dtypes_freq(df: pd.DataFrame) -> None:
"""
Function defined to return a dataframe with details about
the pandas dtypes frequency
"""
return None
return None
def display_summary(df: pd.DataFrame) -> None:
"""
Function define to print out the result of the data summary
"""
result_df = True
message = '---- Data summary ----'
print(message, result_df, sep='\n')
Commençons maintenant par écrire nos tests unitaires avec unittest
, une librairie Python disponible par défaut. Vous vous souvenez sans doute que pytest
était choisi par défaut, par poetry
, comme dépendance développeur pour les tests. Aucune inquiétude toutefois, pytest
sait parfaitement exécuter des tests écrits avec la librairie unittest
.
Les tests unitaires sont des méthodes Python que vous devez rédiger sous forme de classes. En effet l’approche choisi par unittest
est une approche orientée objet. Choisissez des noms explicites et descriptifs pour vos classes et méthodes. Préfixez le nom de vos méthodes avec test_
. Ainsi, votre classe hérite d’un ensemble de méthodes d’assertion qu’apporte la classe unittest.TestCase
.
En pratique, chaque test doit couvrir une fonctionnalité de votre code, être autonome et s’assurer de reproduire les conditions de son succès. Pour cela il est souvent nécessaire d’initialiser nous même certaines données qui vont servir de support à nos tests. Plutôt que d’initialiser ces données dans chacun de nos test unitaires, il est possible de définir une méthode qui initialisera ces données pour l’ensemble de nos tests évitant ainsi d’alourdir le code. Avec unittest
vous pouvez utiliser la méthode SetUp()
pour initialier un ensemble de variables utilisable par la suite dans les tests.
Commençons par écrire notre test pour la fonction data_summary()
:
import unittest
import pandas as pd
from summarize_dataframe.summarize_df import data_summary
class TestDataSummary(unittest.TestCase):
def setUp(self):
# initialize dataframe to test
df_data = [[1, 'a'], [2, 'b'], [3, 'c']]
df_cols = ['numbers', 'letters']
self.df = pd.DataFrame(data=df_data, columns=df_cols)
# initialize expected dataframe
exp_col = ['Values']
exp_idx = ['Number of rows', 'Number of columns', 'int64', 'object']
exp_data = [[3], [2], [1], [1]]
self.exp_df = pd.DataFrame(data=exp_data, columns=exp_col, index=exp_idx)
def test_data_summary(self):
expected_df = self.exp_df
result_df = data_summary(self.df)
self.assertTrue(expected_df.equals(result_df))
if __name__ == '__main__':
unittest.main()
La méthode setUp()
initialise deux DataFrame pandas pour nos tests : self.exp_df
qui est le résultat attendu, et self.df
qui servira de donnée pour la fonction que nous souhaitons tester. L’idée est de déterminer si notre fonction retourne le résultat attendu. Exécutons nos tests avec poetry
:
poetry run pytest -v
============================================== test session starts ========================================================
platform linux -- Python 3.8.7, pytest-5.4.3, py-1.10.0, pluggy-0.13.1 -- /home/fbraza/.cache/pypoetry/virtualenvs/summarize-dataframe-SO-g_7pj-py3.8/bin/python
cachedir: .pytest_cache
rootdir: /home/fbraza/Documents/python_project/summarize_dataframe
collected 1 item
tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary FAILED [100%]
=============================================== FAILURES ===================================================================
__________________________________________ TestDataSummary.test_data_summary _______________________________________________
self = <tests.test_summarize_dataframe.TestDataSummary testMethod=test_data_summary>
def test_data_summary(self):
expected_df = self.exp_df
result_df = data_summary(self.df)
> self.assertTrue(expected_df.equals(result_df))
E AssertionError: False is not true
tests/test_summarize_dataframe.py:26: AssertionError
============================================== short test summary info ======================================================
FAILED tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary - AssertionError: False is not true
============================================== 1 failed in 0.32s ============================================================
Utiliser l’option -v
, pour rendre le compte rendu des tests plus explicite.
Comme on pouvait s’y attendre, notre test a échoué. En effet nous n’avons toujours par implémenté la logique de la fonction data_summary
. Faisons le maintenant :
import pandas as pd
def data_summary(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to output details about the number
of rows and columns and the column dtype frequency of
the passed pandas DataFrame
"""
def _shape(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to return a dataframe with details about
the number of row and columns
"""
row, col = df.shape
return pd.DataFrame(data=[[row], [col]], columns=['Values'], index=['Number of rows', 'Number of columns'])
def _dtypes_freq(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to return a dataframe with details about
the pandas dtypes frequency
"""
counter, types = {}, df.dtypes
for dtype in types:
tmp = str(dtype)
if tmp in counter.keys():
counter[tmp] += 1
else:
counter[tmp] = 1
values = [[value] for value in counter.values()]
return pd.DataFrame(data=values, columns=['Values'], index=list(counter.keys()))
result_df = pd.concat([_shape(df), _dtypes_freq(df)])
return result_df
def display_summary(df: pd.DataFrame) -> None:
"""
Function define to print out the result of the data summary
"""
result_df = True
message = '---- Data summary ----'
print(message, result_df, sep='\n')
Exécutons notre test :
poetry run pytest -v
=============================================== test session starts =======================
platform linux -- Python 3.8.7, pytest-5.4.3, py-1.10.0, pluggy-0.13.1 -- /home/fbraza/.cache/pypoetry/virtualenvs/summarize-dataframe-SO-g_7pj-py3.8/bin/python
cachedir: .pytest_cache
rootdir: /home/fbraza/Documents/python_project/summarize_dataframe
collected 1 item
tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary PASSED [100%]
=============================================== 1 passed in 0.28s =========================
Notre test est passé avec succès. Nous sommes donc capables avec data_summary
d’ingérer un DataFrame et de retourner certaines méta-données sous la forme d’un autre DataFrame. Or l’objectif est d’être capable de représenter le résultat comme décrit plus haut. Il nous reste donc à tester notre résultat. Pour cela nous allons utiliser des fonctionnalités venant de pytest
. Surprenant ? Pas vraiment. Tout d’abord pytest
propose une approche aisée pour tester la sortie standart (stdout
), de plus et comme souligné précédemment, unittest
est compatible avec pytest
. Voici le code pour notre dernier test :
import unittest
import pytest
import pandas as pd
from summarize_dataframe.summarize_df import data_summary, display_summary
class TestDataSummary(unittest.TestCase):
def setUp(self):
# initialize dataframe to test
df_data = [[1, 'a'], [2, 'b'], [3, 'c']]
df_cols = ['numbers', 'letters']
self.df = pd.DataFrame(data=df_data, columns=df_cols)
# initialize expected dataframe
exp_col = ['Values']
exp_idx = ['Number of rows', 'Number of columns', 'int64', 'object']
exp_data = [[3], [2], [1], [1]]
self.exp_df = pd.DataFrame(data=exp_data, columns=exp_col, index=exp_idx)
@pytest.fixture(autouse=True)
def _pass_fixture(self, capsys):
self.capsys = capsys
def test_data_summary(self):
expected_df = self.exp_df
result_df = data_summary(self.df)
self.assertTrue(expected_df.equals(result_df))
def test_display(self):
print('---- Data summary ----', self.exp_df, sep='\n')
expected_stdout = self.capsys.readouterr()
display_summary(self.df)
result_stdout = self.capsys.readouterr()
self.assertEqual(expected_stdout, result_stdout)
if __name__ == '__main__':
unittest.main()
Notez la présence du décorateur @pytest.fixture(autouse=True)
qui encapsule la méthode _pass_fixture
. Dans le jargon pytest
cette méthode est appelée “fixture”. De manière similaire à la méthode setUp()
, une “fixture” s’exécute avant chaque test auquel elle sera appliquée. Cette “fixture” va nous permettre de capturer les données issues de stdout
et de les réutiliser dans notre test. Une fois le test écrit nous pouvons implémenter la fonction en question dans notre code :
import pandas as pd
def data_summary(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to output details about the number
of rows and columns and the column dtype frequency of
the passed pandas DataFrame
"""
def _shape(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to return a dataframe with details about
the number of row and columns
"""
row, col = df.shape
return pd.DataFrame(data=[[row], [col]], columns=['Values'], index=['Number of rows', 'Number of columns'])
def _dtypes_freq(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to return a dataframe with details about
the pandas dtypes frequency
"""
counter, types = {}, df.dtypes
for dtype in types:
tmp = str(dtype)
if tmp in counter.keys():
counter[tmp] += 1
else:
counter[tmp] = 1
values = [[value] for value in counter.values()]
return pd.DataFrame(data=values, columns=['Values'], index=list(counter.keys()))
result_df = pd.concat([_shape(df), _dtypes_freq(df)])
return result_df
def display_summary(df: pd.DataFrame) -> None:
"""
Function define to print out the result of the data summary
"""
result_df = data_summary(df)
message = '---- Data summary ----'
print(message, result_df, sep='\n')
Pour exécuter un test en particulier, utilisez le namespace complet de votre méthode (tests/module.py::classe::methode
) :
poetry run pytest -v tests/test_summarize_dataframe.py::TestDataSummary::test_display
=============================================== test session starts ===============================================================
platform linux -- Python 3.8.7, pytest-5.4.3, py-1.10.0, pluggy-0.13.1 -- /home/fbraza/.cache/pypoetry/virtualenvs/summarize-dataframe-SO-g_7pj-py3.8/bin/python
cachedir: .pytest_cache
rootdir: /home/fbraza/Documents/python_project/summarize_dataframe
collected 1 items
tests/test_summarize_dataframe.py::TestDataSummary::test_display PASSED [100%]
=============================================== 2 passed in 0.19s =================================================================
Notre test est passé avec succès. Nous pouvons maintenant partager notre travail sur GitHub. Mais avant, voyons comment écrire nos messages Git commits en respectant et en garantissantx certaines conventions.
Renforcer les bonnes pratiques d’écriture de vos messages Git commit dans votre projet Python
Écrire des messages Git commit clairs, lisibles et compréhensibles n’est pas une tache aisée. Pour y remédier, plusieurs acteurs du monde open-source se sont réunis et ont proposé un référentiel commun : the Conventional Commits specification qui suggère un ensemble de règles communes pour la rédaction des messages Git commit.
Utiliser commitizen
Dans notre série d’articles consacrée à la gestion de monorepos JavaScript, nous avons décrit les outils nécessaires à l’intégration de ces conventions au sein d’un projet Node.js. Dans un monde Python, nous vous montrerons comment utiliser le package commitizen pour appliquer ces même conventions. Installons commitizen
à l’aide de poetry :
poetry add -D commitizen
Using version ^2.17.0 for commitizen
Updating dependencies
Resolving dependencies... (3.1s)
Writing lock file
Package operations: 11 installs, 0 updates, 0 removals
• Installing markupsafe (1.1.1)
• Installing prompt-toolkit (3.0.18)
• Installing argcomplete (1.12.2)
• Installing colorama (0.4.4)
• Installing decli (0.5.2)
• Installing jinja2 (2.11.3)
• Installing pyyaml (5.4.1)
• Installing questionary (1.6.0)
• Installing termcolor (1.1.0)
• Installing tomlkit (0.7.0)
• Installing commitizen (2.17.0)
Pour configurer commitizen
au sein de votre projet, exécutez la commande suivante cz init
. Plusieurs choix vous seront proposés :
cz init
? Please choose a supported config file: (default: pyproject.toml) (Use arrow keys)
» pyproject.toml
.cz.toml
.cz.json
cz.json
.cz.yaml
cz.yaml
Please choose a cz (commit rule): (default: cz_conventional_commits) (Use arrow keys)
» cz_conventional_commits
cz_jira
cz_customize
? Please enter the correct version format: (default: "$version")
? Do you want to install pre-commit hook? (Y/n)
Acceptez les choix par défaut, ils sont adaptés à notre cas. La dernière question vous demande si vous souhaitez installer et utiliser pre-commit hook. Répondez no
car nous reviendrons dessus plus tard. Jetez un œil à votre fichier pyproject.toml
et notez l’apparition d’une nouvelle section nommée [tool.commitizen]
. Elle contient la configuration que nous avons définie lors du questionnaire :
[...]
[tool.commitizen]
name = "cz_conventional_commits" # commit rule chosen
version = "0.0.1"
tag_format = "$version"
Pour évaluer si votre message Git commit respecte bien les conventions, utilisez la commande suivante :
cz check -m "all summarize_data tests now succeed"
commit validation: failed!
please enter a commit message in the commitizen format.
commit "": "all summarize_data tests now succeed"
pattern: (build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump)!?(\(\S+\))?:(\s.*)
Notre message a été rejeté car il ne respecte pas les règles définies par la convention “Conventional Commits”. La dernière ligne de ce message suggère d’utiliser un modèle de message particulier. Prenez le temps de lire la charte conventional commits. Par la suite, lancez la commande suivante cz info
pour afficher une courte documentation expliquant comment écrire un message Git commit.
cz info
The commit contains the following structural elements, to communicate intent to the consumers of your library:
fix: a commit of the type fix patches a bug in your codebase
(this correlates with PATCH in semantic versioning).
feat: a commit of the type feat introduces a new feature to the codebase
(this correlates with MINOR in semantic versioning).
BREAKING CHANGE: a commit that has the text BREAKING CHANGE: at the beginning of
its optional body or footer section introduces a breaking API change
(correlating with MAJOR in semantic versioning).
A BREAKING CHANGE can be part of commits of any type.
Others: commit types other than fix: and feat: are allowed,
like chore:, docs:, style:, refactor:, perf:, test:, and others.
[...]
Ici le modèle attendu est "[pattern]: [MESSAGE]"
. En ce basant sur ces pré-requis, écrivons à nouveau notre message Git :
cz check -m "test: all summarize_data tests now succeed"
Commit validation: successful!
Notre message est maintenant validé. Mais cette valiation est manuelle ce qui prend du temps et n’apporte pas la garanti que les messages soient tous bien rérigés. Cette validation doit être automatisée à chaque exécution de la commande git commit
. C’est là que pre-commit
entre en scène.
Évaluer la validité des messages Git commit avec pre-commit
Git expose plusieurs hooks qui permenttent l’exécution de scripts à certaines étapes de son fonctionnement. En l’occurrence nous voulons exécuter un script à chaque fois que nous utiliserons la commande git commit
.
pre-commit est un framework for gérer et maintenir des scripts rédigé dans plusieurs languages. Ces scripts sont utiles pour automatiser certaines tâches lors de la soumission de votre code. Si vous voulez en savoir plus sur les possibilités offertes par pre-commit
vous pouvez lire sa documentation.
Installons maintenant pre-commit
avec poetry
:
peotry add -D pre-commit
Pour automatiser la vérification de nos messages Git commit, nous devons créer un fichier de configuration .pre-commit-config.yaml
:
---
repos:
- repo: https://github.com/commitizen-tools/commitizen
rev: master
hooks:
- id: commitizen
stages: [commit-msg]
Installons ensuite le script qui permettra de vérifier la validité des message :
pre-commit install --hook-type commit-msg
Ce script, exploitant commitizen
, vérifiera nos messages à chaque fois que nous utiliserons la commande git commit
:
git commit -m "test: all summarize_data tests now succeed"
[INFO] Initializing environment for https://github.com/commitizen-tools/commitizen.
[INFO] Installing environment for https://github.com/commitizen-tools/commitizen.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
commitizen check.........................................................Passed
[INFO] Restored changes from /home/fbraza/.cache/pre-commit/patch1617970841.
[master 1e64d0a] test: all summarize_data tests now succeed
2 files changed, 48 insertions(+), 5 deletions(-)
rewrite tests/test_summarize_dataframe.py (98%)
Pour procéder à la vérification, pre-commit
installe son propre environnement. Notre message a été valide. Par conséquent nous pouvons maintenant sauvegarder notre travail sur Git et notamment les modifications apportées qux fichiers poetry.lock
, pyproject.toml
et summarize_df.py
:
git commit -m "build: add developer dependencies" -m "commitizen and pre-commit added to our dev dependencies"
commitizen check.........................................................Passed
[master 1c6457c] build: add developer dependencies
2 files changed, 585 insertions(+), 1 deletion(-)
git commit -m "feat: implementation of the summary function to summarize dataframe"
commitizen check.........................................................Passed
[master 5c053ad] build: add developer dependencies
1 file changed, 94 insertions(+)
Nous pouvons maintenant tout partager sur GitHub.
git push origin master
Conclusion
Plusieurs choses ont été abordées dans cet article :
- Tout d’abord nous avons couver l’écrire de tests unitaires. Commencez toujours, par l’écriture de vos tests. Les effets bénéfiques se feront vite sentir avec cette approche, notamment si vous modifiez souvent votre code. Avoir des tests vous permettra d’apporter ces modifications en douceur. Pour l’écriture de nos tests nous avons utilisé une approche orientée objet avec
unittest
. J’apprécie personnellement la facilité d’utilisation deunittest
et son approche objet. D’autres préfèrent utiliser le packagepytest
. La compatibilité entre les deux est vraiment intéressante et bienvenue. Vous pouvez ainsi écrire vos tests en utilisantunittest
,pytest
ou les deux et n’utiliser qu’une seule commande pour les exécuter avecpoetry
. - Nous avons également pu voir les pré-requis nécessaires à l’implémentation d’une démarche qualité pour l’écriture de nos messages Git commit. La solution que nous proposons ici repose sur l’utilisation de deux packages : commitizen et pre-commit. Le premier apporte les outils nécessaires à la vérification de vos messages alors que le second permet d’automatiser ce processus avec Git.
Dans l’article suivant nous poursuiverons notre aventure dans la gestion de projets Python. Nous automatiserons les test en utilisant tox
et les intégrerons dans une démarche CI/CD. Ensuite nous verrons comment préparer notre package et le publier sur PyPi avec poetry
.
Pense-bête
poetry
-
Ajouter des dépendances :
poetry add [package_name]
-
Ajouter des dépendances développeur :
poetry add -D [package_name]
poetry add --dev [package_name]
-
Exécuter les tests :
poetry run pytest
commitizen
-
Initialiser
commitizen
:cz init
-
Vérifier vos messages Git commit :
cz check -m "YOUR MESSAGE"
pre-commit
-
Créer un modèle de fichier de configuration :
pre-commit sample-config
-
Installer le Git hook :
pre-commit install --hook-type [hook_name]