Cómo usar funciones como Mocks para tests en Python

Cómo usar funciones como Mocks para tests en Python

Una de las preguntas frecuentes sobre el Testing es: ¿cómo evito llamar a servicios “pesados” durante la ejecución de pruebas?

Si tuviera que llamar a servicios como Redis, Docker, MySQL, RabbitMQ u otros por cada prueba, probablemente tendría que instalarlos en mi servidor de pruebas, lo cual consumiría recursos y aumentaría el tiempo de ejecución. No creo que al resto de los desarrolladores les guste esa idea.

Además, es útil para evitar llamar direcciones URL reales. Ya que no tenemos control sobre esas direcciones, podrían estar disponibles o no en cualquier momento del día.

Podemos aprovechar los Mocks en Python para verificar, por ejemplo, que nuestro código realiza una llamada a una dirección URL externa y forzar la ejecución de casos alternos. Como cuando el servicio no está disponible o cuando devuelve una respuesta no exitosa.

Para el proyecto de ejemplo que mostraré, utilizaré Poetry como gestor de paquetes, la biblioteca requests para llamadas a una URL externa y pytest con unittest como bibliotecas de testing.

Los siguientes ejemplos de código han sido probados en Python 3.11

Usando Poetry para configurar el proyecto

Poetry es un administrador de paquetes muy fácil de usar. Puedes descargarlo en https://python-poetry.org/ . Acá el listado de dependencias a utilizar.

pyproject.toml
[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.31.0"
[tool.poetry.group.dev.dependencies]
black = "^23.3.0"
pytest = "^7.4.0"
pytest-mock = "^3.11.1"

Tenemos: black, un formateador de código, pytest y pytest-mock para tener una manera sencilla de realizar mocks en Python, estarán dentro de las dependencias de desarrollo. No las necesitamos en nuestro código desplegado a producción.

Como dependencias de producción usaremos python 3.11 y requests, la librería para hacer llamadas HTTP a cualquier URL.

Creando un script Python para llamar una API

Como cualquier script en Python, realizaremos algo muy sencillo. Procederé a realizar los 3 tipos de llamadas API más comunes: una llamada GET, una llamada POST y una llamada POST con datos.

Si ejecuto este script, funcionará de todas maneras mientras la URL de JsonPlaceholder se encuentre disponible.

main.py
import requests
todos_single_url = "https://jsonplaceholder.typicode.com/todos/1"
posts_url = "https://jsonplaceholder.typicode.com/posts"
def call_single_endpoint():
request = requests.get(todos_single_url)
return request.json()
def call_post_endpoint():
request = requests.post(posts_url)
return request.json()
def call_post_endpoint_with_data(data):
request = requests.post(posts_url, data)
return request.json()

Usando mocks para sobrescribir el comportamiento de la función

Vamos a usar frecuentemente el método patch de unittest.mock. Con este decorador, podremos hacer un parche del método que queramos y convertirlo en un mock. En el proceso adquirirá varias funciones útiles que nos permitirán saber si fue llamado, con qué parámetros e incluso colocar un valor falso de retorno si queremos.

La función a la cual debemos hacer un mock en este caso es requests.get. Asegúrate de importar solo get desde requests. Debemos poner la dirección completa de la función, incluyendo el paquete.

Las funciones dentro de los mocks son tan diversas que algunas veces puedes usar assert o ya los assertions vienen dentro de los mocks.

Comprobar si la función fue llamada sólo una vez, modo decorador

Para este caso, usaremos el keyword especial de Python assert . El mock viene con una función que retorna verdadero o falso, llamado called_once .

Observa como usamos un decorador, arriba de nuestro test test_call_single_endpoint_mock. De este modo se evita sangrar el código.

tests.py
from unittest.mock import patch
from python_mocking import call_single_endpoint
@patch("requests.get")
def test_call_single_endpoint_mock(mocked_requests_get):
call_single_endpoint()
assert mocked_requests_get.called_once

Comprobar si la función fue llamada sólo una vez, usando with

Podemos además no hacer uso del decorador, en este caso no necesitaríamos patch. Es una línea extra con sangrado que tenemos que añadir. Personalmente, prefiero la versión con decorador, pero nunca está de más aprender las maneras alternativas.

tests.py
from python_mocking import call_single_endpoint
def test_call_single_endpoint_mock_without_decorator():
with patch("requests.get") as mocked_requests_get:
call_single_endpoint()
assert mocked_requests_get.called_once

Comprobar si la función fue llamada, usando el parámetro mocker

Puedes además usar mocker como parámetro para poder hacer un mock de la función que deseas sobrescribir. Con este método también evitamos un sangrado extra. La ventaja es que con este parámetro puedes hacer un mock de más funciones así evitas acumular decoradores.

tests.py
from python_mocking import call_single_endpoint
def test_call_single_endpoint_mock_with_mocker_param(mocker):
mocked_requests_get = mocker.patch("requests.get")
call_single_endpoint()
assert mocked_requests_get.called_once

Comprobar si la función fue llamada con parámetros específicos

Tenemos dos versiones de estos assertions. Podemos verificar si una función fue llamada con parámetros específicos. Para eso usamos la función del mock assert_called_with. Hay una variación de esta, la cual se asegura de que fue llamada una sola vez exactamente: assert_called_once_with.

tests.py
from unittest.mock import patch
from python_mocking import call_post_endpoint_with_data, posts_url
@patch("requests.post")
def test_assert_called_with(mocked_requests_post):
data = {"userId": 3}
call_post_endpoint_with_data(data)
mocked_requests_post.assert_called_with(posts_url, data)
@patch("requests.post")
def test_assert_called_once_with(mocked_requests_post):
data = {"userId": 3}
call_post_endpoint_with_data(data)
mocked_requests_post.assert_called_once_with(posts_url, data)

Agrupando tests

Si queremos tener un mejor orden para nuestros tests, podemos agruparlos dentro de una clase. Incluso puedes combinar tests agrupados y no agrupados dentro de un mismo archivo.

Para el siguiente ejemplo, le diremos al mock el objeto a retornar. Esto puede ser útil si después quieres forzar un flujo específico para comprobar cómo se comportan tus funciones a las que haces mock.

Recuerda que dentro de una clase, el primer parámetro de sus funciones siempre será self. Así que el segundo parámetro será tu función como mock.

tests.py
from unittest.mock import patch
from python_mocking import call_single_endpoint
class TestRequests:
@patch("requests.get")
def test_call_and_stubbed_response(self, mocked_requests_get):
mocked_requests_get.return_value.json.return_value = {"foo": "bar"}
call_single_endpoint()
assert mocked_requests_get.called_once
assert mocked_requests_get.return_value.json.return_value == {
"foo": "bar"}

Comprobar llamadas consecutivas de un método

Dentro de la función como mock, viene un método llamado side_effect. Útil cuando tu método es llamado más de una vez y quieres programar retornos consecutivos.

El método side_effect también es usado para programar Exceptions como respuesta.

tests.py
from unittest.mock import patch
from python_mocking import call_single_endpoint
class TestRequests:
@patch("requests.get")
def test_call_and_response_different_values(self, mocked_requests_get):
mocked_requests_get.return_value.json.side_effect = [
{"fruit": "orange"},
{"fruit": "apple"},
]
first_result = call_single_endpoint()
assert mocked_requests_get.return_value.json.call_count == 1
assert first_result == {"fruit": "orange"}
second_result = call_single_endpoint()
assert second_result == {"fruit": "apple"}
assert mocked_requests_get.return_value.json.call_count == 2

Comprobar que una llamada fue similar usando call

Si no queremos usar assert_called_with o assert_called_once_with , podemos también usar la función que viene dentro de unittest llamada call. Lo que hace call, es retornar una llamada a un Mock o MagickMock . Así se podrá comparar si las llamadas en algún punto del test son iguales a lo que el mock ha registrado.

tests.py
from unittest.mock import patch, call
from python_mocking import call_single_endpoint, todos_single_url
class TestRequests:
@patch("requests.get")
def test_call_args(self, mocked_requests_get):
call_single_endpoint()
assert mocked_requests_get.call_args == call(todos_single_url)

Comprobar que el parámetro n de una función llamada fue un valor específico

Esto es una variación de un test en el que solo queremos saber si el parámetro en una posición específica de una función tiene un valor específico. Dado que podemos obtener los parámetros de un mock como una lista, será fácil acceder a un índice específico.

En este ejemplo, podemos obtener todos los parámetros de una llamada a una función dentro de call_args.

tests.py
from unittest.mock import patch
from python_mocking import call_post_endpoint_with_data
class TestRequests:
@patch("requests.post")
def test_call_post_endpoint_with_data(self, mocked_requests_post):
mocked_requests_post.return_value.json.return_value = {"foo": "bar"}
call_post_endpoint_with_data({"userId": 2})
second_param_of_call = mocked_requests_post.call_args[0][1]
assert mocked_requests_post.called_once
assert second_param_of_call == {"userId": 2}

Resultado final

Este es código final de todos los tests que he realizado. No cubre el 100% de situaciones que una aplicación podría tener, pero espero haber mostrado los casos más usados.

El enlace al repositorio estará en la sección de Recomendaciones.

tests.py
from unittest.mock import patch, call
from python_mocking import (
call_single_endpoint,
call_post_endpoint,
call_post_endpoint_with_data,
posts_url,
todos_single_url,
)
@patch("requests.get")
def test_call_single_endpoint_mock(mocked_requests_get):
call_single_endpoint()
assert mocked_requests_get.called_once
def test_call_single_endpoint_mock_without_decorator():
with patch("requests.get") as mocked_requests_get:
call_single_endpoint()
assert mocked_requests_get.called_once
def test_call_single_endpoint_mock_with_mocker_param(mocker):
mocked_requests_get = mocker.patch("requests.get")
call_single_endpoint()
assert mocked_requests_get.called_once
@patch("requests.post")
def test_assert_called_with(mocked_requests_post):
data = {"userId": 3}
call_post_endpoint_with_data(data)
mocked_requests_post.assert_called_with(posts_url, data)
@patch("requests.post")
def test_assert_called_once_with(mocked_requests_post):
data = {"userId": 3}
call_post_endpoint_with_data(data)
mocked_requests_post.assert_called_once_with(posts_url, data)
class TestRequests:
@patch("requests.get")
def test_call_and_stubbed_response(self, mocked_requests_get):
mocked_requests_get.return_value.json.return_value = {"foo": "bar"}
call_single_endpoint()
assert mocked_requests_get.called_once_with(todos_single_url)
assert mocked_requests_get.return_value.json.return_value == {"foo": "bar"}
@patch("requests.get")
def test_call_args(self, mocked_requests_get):
call_single_endpoint()
assert mocked_requests_get.call_args == call(todos_single_url)
@patch("requests.get")
def test_call_and_response_different_values(self, mocked_requests_get):
mocked_requests_get.return_value.json.side_effect = [
{"fruit": "orange"},
{"fruit": "apple"},
]
first_result = call_single_endpoint()
assert mocked_requests_get.return_value.json.call_count == 1
assert first_result == {"fruit": "orange"}
second_result = call_single_endpoint()
assert second_result == {"fruit": "apple"}
assert mocked_requests_get.return_value.json.call_count == 2
@patch("requests.post")
def test_call_post_endpoint(self, mocked_requests_post):
mocked_requests_post.return_value.json.return_value = {"foo": "bar"}
call_post_endpoint()
assert mocked_requests_post.called_once
assert mocked_requests_post.return_value.json.return_value == {"foo": "bar"}
@patch("requests.post")
def test_call_post_endpoint_with_data(self, mocked_requests_post):
mocked_requests_post.return_value.json.return_value = {"foo": "bar"}
call_post_endpoint_with_data({"userId": 2})
second_param_of_call = mocked_requests_post.call_args[0][1]
assert mocked_requests_post.called_once
assert second_param_of_call == {"userId": 2}

Recomendaciones

Puedes ver el repositorio con el resultado final acá .

Para más información de unittest.mock consulta la documentación .

Mis posts no son generados por la IA, sin embargo, podrían estar corregidos por ella. El primer borrador siempre es de mi creación

Tags

Autor

Escrito por Helmer Davila

En otros lenguajes

Useful for avoid calling real APIs or services

How to use functions as Mocks in Python tests

C'est utile pour éviter les appels réels vers une API ou un service.

Comment utiliser des fonctions comme des mocks dans les tests de Python

Posts relacionados

Estaba intentando probar algunas variables globales con Python, especialmente para un script el cual contiene variables globales. Y después de intentarlo y fallar, pienso que puedo mostrarte la versión más simple de hacerlo.

Testing en Python: Hacer mock de una variable global