Testing en Python

Hoy me gustaría hablar de como estoy trabajando en Python desde el punto de vista del testing. Primero, os contaré las dos principales alternativas para ejecutar los tests y luego entraré un poco más en detalle en como hacerlos.

Runners

Existen múltiples maneras de ejecutar tests en Python pero solo me voy a centrar en las que para mí son las mejores opciones:

Mamba

Mamba es un librería creada por Nestor Salceda. Si alguna vez has usado RSpec en Ruby o Jest en JS es posible que te suene este tipo de tests. Sus características son:

  • Sintaxis BDD (Behavior-Driven Development).
  • Nos permite agrupar tests por clases o funcionalidad con description.
  • Permite expresar los tests como casos de uso más cercano al lenguaje que puede entender una persona no técnica haciendo uso de it.
from mamba import description, it
    with description('MyClass') as self:
        with it('#execute_1'):
       	    ...
        with it('#execute_2'):
	    ...
        with it('#execute_3'):
	    ...
    with description('MyOtherClass') as self:
        with it('#execute_1'):
	    ...
        with it('#execute_2'):
	    ...
        with it('#execute_3'):
	    ...

En mis proyectos personales es mi opción preferida, me encanta esta manera de escribir tests y siempre que puedo la uso.

Pytest

Cuando estoy trabajando no tengo la posibilidad de usar Mamba ya que hemos decidido en el equipo usar pytest, posiblemente la opción más conocida dentro de Python a la hora de hacer tests.

Entre sus principales características destacaría:

  • Permite parametrización.
  • Permite usar fixtures.
  • Permite ejecutar grupos de tests en sequencial.

Es la manera más sencilla de empezar a hacer tests y la verdad que tanto la documentación, los ejemplos y la potencia de la librería es más que suficiente para cualquier proyecto. Como además es lo que uso más en mi día a día, los siguientes ejemplos están hechos con pytest.

¿Vale muy bien, pero como hacemos tests en Python?

Como decía anteriormente voy a utilizar pytest como runner por lo que tendré que seguir un par de convenciones si quiero que estos se ejecuten (a continuación podrás ver ejemplos concretos de todo esto):

  • El fichero de los tests debe terminar en _test.py o empezar por test_xxx.py.
  • Debemos crear una clase que empiece por Test….
  • Los tests deben ser funciones que empiecen por test___(self):.
class MyClass:
    def add_two_numbers(self, x, y):
        return x + y

class TestMyClass:
    def test_add_two_numbers(self):
        # Arrange
        my_class = MyClass()

        # Act
        result = my_class.add_two_numbers(1 , 2)

        # Assert
        assert result == 3

En este sencillo test hemos podido ver como sería el ejemplo más básico de test en Python, ¿pero qué ocurre si necesitamos hacer cosas más complejas como por ejemplo utilizar algún matcher para validar el resultado, o si necesitamos usar un Stub, Spy o Mock?.

Test más complejos

Como decía en el apartado anterior es muy posible que llegados a un punto en nuestro proyecto necesitemos hacer cosas más complejas, ya sea tanto en la parte de las aserciones como en la propia construcción de los tests. Para poder lograrlo voy a recomendar un par de librerías:

  • Expects y Doublex Expects de Jaime Gil.
    • Esta librería nos va a permitir realizar aserciones mucho más potentes en nuestros tests.
  • Doublex de David Villa.
    • Esta librería nos va a permitir hacer uso de Stubs, Spies y Mocks en nuestros tests (si no sabes a qué me refiero échale un ojo a este artículo de Martin Fowler)

A continuación voy a explicar y usar diferentes tipos de mocks los cuales irán de más sencillo o más complicado. Así mismo en función del nivel de acople entre el test y la implementación usaremos unos u otros, siempre que sea posible usaremos un primero un Stub, si no es posible un Spy y finalmente un Mock

Stub

Empecemos por el caso más sencillo. Imaginemos que tenemos una clase con varios colaboradores, como pueden ser:

  • Un client http.
  • Un repositorio MySQL.
  • Un logger.

Si quisiéramos crear un test de integración que realice las peticiones http y mysql reales pero no así el logger, una solución muy sencilla es utilizar un Stub:

class TestMyClass:
    def test_my_class(self):
        http_client = MyHttpClient()
        mysql_client = MySQLClient()

        my_class = MyClass(http_client, mysql_client, Stub())

        response = myclass.execute()

        expect(response.message).to(equal("success"))

Por entender un poco el porqué de este código es importante explicar que los Stubs nos van a permitir falsear un objeto completamente sin necesidad de tener que declarar ninguno de sus métodos o propiedades. Los Stubs son muy útiles cuando estamos haciendo un test y no queremos ni necesitamos el objeto para nada ya que su función no es relevante para el test.

Es importante indicar que un Stub nos permite programar las respuestas de un objeto si fuera necesario.

Spy

Imaginemos ahora que nuestro cliente MySQL ya tiene sus propios tests, tanto de integración como unitarios, y lo que queremos validar en nuestro test es que se le está llamando correctamente:

class TestMyClass:
    def test_my_class(self):
        http_client = Spy()
        mysql_client = Spy()

        my_class = MyClass(http_client, mysql_client, Stub())

        response = myclass.execute()

        expect(http_client.post).to(have_been_called)
        expect(mysql_client.save).to(have_been_called)
        expect(response.message).to(equal("success"))

En este caso debemos hacer uso de los Spies los cuales nos van a permitir validar las llamadas a un objeto, podremos incluso validar con qué parámetros se le esta llamando si fuera necesario. Los Spies son útiles cuando lo que queremos es testar la colaboración entre objetos como ocurre en este ejemplo.

Mock

Por último, tenemos los Mocks que son el concepto más complejo. Este tipo de objetos son muy útiles cuando:

  • Queremos definir que métodos se deben llamar, cuáles no y en que orden.
    • Si estos pre-requesitos no se cumplen se lanzará una excepción.
  • Necesitamos definir cómo se comporta un objeto y no es suficiente con saber si este ha sido llamado o no.
  • Necesitamos ir un paso más allá y poder definir con qué parámetros se le puede llamar, qué respuesta debe darnos para cada método en particular.
class TestMyClass:
    def test_my_class(self):
        my_object = MyObject()
        with Mock as http_client:
            http_client.post(ANY_ARG).returns(my_object)
        mysql_client = Spy()

        my_class = MyClass(http_client, mysql_client, Stub())

        response = myclass.execute()

        expect(http_client.post).to(have_been_satisfied)
        expect(mysql_client.save).to(have_been_called_with, my_object)
        expect(response.message).to(equal("success"))

Si necesitas pararte un poco aquí para entender las diferencias entre Stub, Spy y Mock este artículo de Martin Fowler lo explica mucho mejor que yo xD.

Mimic

Ya por último si eres de esas personas que no pueden vivir sin los tipos (no estás solo jejeje) te habrás dado cuenta que haciendo uso de estos Stubs, Spies y Mocks estas perdiendo el tipado, no pasa nada todo tiene solución en esta vida.

La propia librería Doublex nos proporciona los Mimics que nos van a permitir mantener el tipado de los objetos, aquí un ejemplo:

class TestMyClass:
    def test_my_class(self) -> None:
        my_object = MyObject()
        with Mimic(Mock, MyHttpClient) as http_client:
	    http_client.post(ANY_ARG).returns(my_object) # type => MyHttpClient
        mysql_client = Mimic(Spy, MySQLClient) # type => MySQLClient
        logger = Mimic(Stub, Logger) # type => Looger

        my_class = MyClass(http_client, mysql_client, logger)

        response = myclass.execute()

        expect(http_client.post).to(have_been_satisfied)
        expect(mysql_client.save).to(have_been_called_with, my_object)
        expect(response.message).to(equal("success"))

Conclusión

Espero que esta aproximación a los tests os haya servido como una pequeña introducción sobre cómo hacer tests en Python. A mí personalmente es un tema que me apasiona y si puedo en el futuro me gustaría volver a hablar sobre ello nuevamente.