Comment créer des mocks HTTP en Python

Comment créer des mocks HTTP en Python
Alexandre P. dans Dev - mis à jour le 14-12-2024

Apprenez à utiliser des mocks avec pytest pour éviter les appels API impactants (paiement, suppression) et sécuriser vos tests unitaires efficacement.

Pour créer des mocks en Python, je m'appuie sur la librairie responses j'imagine par opposition au paquet requests.

pip install responses
# on n'oublie pas d'installer pytest si ce n'est pas fait
pip install pytest

Nous allons créer une petite lib de test qui fait des appels API, puis mettre en place les mocks pour tester cette lib. Voici l'arborescence:

.
├── libs
│   ├── resource.py
└── tests
    └── test_resource.py

Nous allons créer un contenu très simple pour notre lib resource.py:

# resource.py
import requests

def get_resource(id):
  headers = {
    'Accept': 'application/json'
  }
  response = requests.get('http://localhost:3000/resource/' + id, headers=headers)
  
  if response.status_code != 200:
    raise Exception('Unable to get resource ' + id)
  
  return response.json()


def create_resource(data):
  headers = {
    'Accept': 'application/json',
    'Authorization': 'Bearer blabla' 
  }
  response = requests.post('http://localhost:3000/resource', data=data, headers=headers)

  if response.status_code != 201:
    raise Exception('Unable to get resource ' + id)
  
  return response.json()

Maintenant pour mettre en place nos tests, respectons les conventions de Pytest, nous allons créer un dossier tests et un fichier test_resource.py au sein de ce dossier:

# test_resource.py
import os
import sys
import responses

parent_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
sys.path.append(parent_dir)

from libs.resource import get_resource, create_resource

Maintenant, avant d'aller plus loin, nous allons utiliser un décorateur qui va expliquer à responses que nous souhaitons mettre en place dans cette fonction, l'interception des appels HTTP. Un décorateur c'est une ligne qui se positionne juste avant la déclaration de votre fonction qui viendra altérer le fonctionnement de votre fonction, sans avoir à écrire des lignes entière de code.

@responses.activate
def test_get_resource():

Je vais ensuite déclarer la réponse du mock. C'est à dire, les données que je souhaite manipuler pendant mon test unitaire.

  response_mock = {
    'username': 'Foo',
    'email': 'foo@bar.com',
    'role': 'user'
  }

Puis, on va déclarer dans responses quel appel intercepter afin de donner notre mock en réponse personnalisée en retour:

responses.add(
    responses.get(
      url='http://localhost:3000/resource/3',
      json=response_mock,
      status=200,
      content_type='application/json'
    )
  )

Il ne reste plus qu'à déclencher l'appel et effectuer nos tests:

  resource = get_resource(str(3))
  assert resource['username'] == response_mock['username']

Voici le fonctionnement en gros, et nous appliquons la même logique pour la partie POST. Sans vous faire languir plus longtemps, je vous met directement le fichier entier afin de vous donner une idée du résultat final.

# test_resource.py
import os
import sys
import responses

parent_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
sys.path.append(parent_dir)

from libs.resource import get_resource, create_resource


@responses.activate
def test_get_resource():
  response_mock = {
    'username': 'Foo',
    'email': 'foo@bar.com',
    'role': 'user'
  }

  responses.add(
    responses.get(
      url='http://localhost:3000/resource/3',
      json=response_mock,
      status=200,
      content_type='application/json'
    )
  )

  resource = get_resource(str(3))
  assert resource['username'] == response_mock['username']

@responses.activate
def test_create_resource():
  response_mock = {
    'id': '45',
    'username': 'Blah',
    'email': 'blabla@bar.com',
    'role': 'admin'
  }

  responses.add(
    responses.post(
      url='http://localhost:3000/resource',
      json=response_mock,
      status=201,
      content_type='application/json'
    )
  )

  response = create_resource({
    'username': 'Blah',
    'email': 'blabla@bar.com',
    'role': 'admin'
  })

  assert response['id'] == '45'

Pour lancer les tests, on utilise :

python3 -m pytest -s

C'est tout bon ! J'espère que cet article vous a été utile et qu'il vous aidera à mettre en place vos mocks dans vos projets Python.

FAQ

À quoi sert la librairie responses par rapport à unittest.mock ?

Responses est spécialisée dans l'interception des appels HTTP effectués via requests, ce qui la rend plus simple à utiliser que unittest.mock pour ce cas précis. Elle permet de déclarer directement l'URL à intercepter, le code de statut et le corps de la réponse, sans avoir à patcher manuellement la librairie requests.

Est-ce que mes appels HTTP réels sont bloqués pendant les tests ?

Oui, dès que le décorateur @responses.activate est appliqué à une fonction de test, toute requête HTTP non déclarée via responses.add lèvera une erreur. C'est justement ce comportement qui sécurise les tests en empêchant des appels accidentels vers de vraies APIs.

Comment tester une requête POST avec un code de statut différent de 200 ?

Il suffit d'utiliser responses.post à la place de responses.get dans l'appel à responses.add, et de préciser le statut souhaité, par exemple 201 pour une création réussie. La logique reste identique quelle que soit la méthode HTTP.

Faut-il un serveur local qui tourne pour exécuter ces tests ?

Non, c'est précisément l'intérêt des mocks : les appels réseau sont interceptés avant d'atteindre un vrai serveur. Les tests fonctionnent donc entièrement hors ligne et de manière isolée.

Comment lancer les tests une fois le fichier de test prêt ?

Il suffit d'exécuter python3 -m pytest -s depuis la racine du projet. L'option -s permet d'afficher les éventuelles sorties standard pendant l'exécution des tests.

#python#tests unitaires#responses#mock#pytest

user picture

Alexandre P.

Développeur passionné depuis plus de 20 ans, j'ai une appétence particulière pour les défis techniques et changer de technologie ne me fait pas froid aux yeux.