Python moderne, partie 2 : écrire les tests unitaires & respecter les conventions Git commit

Python moderne, partie 2 : écrire les tests unitaires & respecter les conventions Git commit

BRAZA Faouzi

By BRAZA Faouzi

24 juin 2021

Catégories
DevOps & SRE
Tags
Git
pandas
Python
Tests unitaires
[plus]
Vous appréciez notre travail......nous recrutons !

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 :

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 de unittest et son approche objet. D’autres préfèrent utiliser le package pytest. La compatibilité entre les deux est vraiment intéressante et bienvenue. Vous pouvez ainsi écrire vos tests en utilisant unittest, pytest ou les deux et n’utiliser qu’une seule commande pour les exécuter avec poetry.
  • 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]
Partagez cet article

Canada - Maroc - France

Nous sommes une équipe passionnée par l'Open Source, le Big Data et les technologies associées telles que le Cloud, le Data Engineering, la Data Science le DevOps…

Nous fournissons à nos clients un savoir faire reconnu sur la manière d'utiliser les technologies pour convertir leurs cas d'usage en projets exploités en production, sur la façon de réduire les coûts et d'accélérer les livraisons de nouvelles fonctionnalités.

Si vous appréciez la qualité de nos publications, nous vous invitons à nous contacter en vue de coopérer ensemble.

Support Ukrain