Contenido

Introducción a las pruebas de código con PyTest

Una suave introducción a las pruebas con PyTest

Una prueba es código que ejecuta código. Cuando empiezas a desarrollar una nueva característica para tu proyecto Python, podrías formalizar sus requisitos como código. Cuando lo haces, no sólo documentas la forma en que se utilizará el código de tu implementación, sino que también puedes ejecutar todas las pruebas automáticamente para asegurarte siempre de que tu código se ajusta a tus requisitos. Una de estas herramientas, que te ayuda a hacer esto es pytest y es probablemente la herramienta de pruebas más popular en el universo Python.

Se trata de assert

Supongamos que ha escrito una función que valida una dirección de correo electrónico. Tenga en cuenta que aquí lo mantenemos simple y no utilizamos Expresiones Regulares o pruebas de DNS para validar las direcciones de correo electrónico. En su lugar, sólo nos aseguramos de que haya exactamente un signo @ en la cadena a comprobar y sólo caracteres latinos, números y caracteres ., - y _.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import string

def is_valid_email_address(s):
    s = s.lower()
    parts = s.split('@')
    if len(parts) != 2:
      # Not exactly one at-sign
      return False
    allowed = set(string.ascii_lowercase + string.digits + '.-_')
    for part in parts:
        if not set(part) <= allowed:
          # Characters other than the allowed ones are found
          return False
    return True

Ahora, tenemos algunas afirmaciones para nuestro código. Por ejemplo, afirmamos que estas direcciones de correo electrónico son válidas:

Por otro lado, esperaríamos que nuestra función devolviera False para direcciones de correo electrónico como:

Podemos comprobar que nuestra función se comporta efectivamente como esperamos:

1
2
3
4
5
6
print(is_valid_email_address('[email protected]'))               # True
print(is_valid_email_address('[email protected]'))  # True
print(is_valid_email_address('[email protected]'))     # True
print(is_valid_email_address('not [email protected]'))          # False
print(is_valid_email_address('john.doe'))                       # False
print(is_valid_email_address('john,[email protected]'))           # False

Estos ejemplos de direcciones de correo electrónico que se nos ocurren se denominan casos de prueba. Para cada caso de prueba, esperamos un resultado determinado. Una herramienta como pytest puede ayudar a automatizar las pruebas de estas afirmaciones. Escribir estas aserciones puede ayudarte a

  • documentar cómo se va a utilizar tu código
  • asegurarse de que los futuros cambios no rompen otras partes de su software
  • pensar en posibles casos límite de tus funcionalidades

Para ello, basta con crear un nuevo archivo para todas nuestras pruebas y poner unas cuantas funciones en él.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def test_regular_email_validates():
    assert is_valid_email_address('[email protected]')
    assert is_valid_email_address('[email protected]')
    assert is_valid_email_address('[email protected]')

def test_valid_email_has_one_at_sign():
    assert not is_valid_email_address('john.doe')

def test_valid_email_has_only_allowed_chars():
    assert not is_valid_email_address('john,[email protected]')
    assert not is_valid_email_address('not [email protected]')

Ejecución de pruebas

Ejemplo fácil

Así, tenemos dos archivos en el directorio de nuestro proyecto: validator.py y test_validator.py.

Ahora podemos ejecutar simplemente pytest desde la línea de comandos. Su salida debería ser algo así:

1
2
3
4
5
6
7
8
======================= test session starts =========================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 3 items

test_validator.py ...                                          [100%]

======================== 3 passed in 0.01s ==========================

Aquí pytest nos informa de que ha encontrado tres funciones de prueba dentro de test_validator.py y que todas ellas han sido superadas (como indican los tres puntos ...).

El indicador 100% nos da una buena sensación ya que estamos seguros de que nuestro validador funciona como se espera. Sin embargo, como se indica en la introducción, la función del validador está lejos de ser perfecta. Y también lo son nuestros casos de prueba. Incluso sin las pruebas de DNS, marcaríamos como válida una dirección de correo electrónico como [email protected], mientras que una dirección como [email protected] se marcaría como inválida.

Añadamos ahora estos casos de prueba a nuestro test_validator.py.

1
2
3
4
5
6
...
def test_valid_email_can_have_plus_sign():
    assert is_valid_email_address('[email protected]')

def test_valid_email_must_have_a_tld():
    assert not is_valid_email_address('[email protected]')

Si ejecutamos de nuevo pytest, vemos que las pruebas fallan:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items

test_validator.py ...FF                                                  [100%]

=================================== FAILURES ===================================
_____________________ test_valid_email_can_have_plus_sign ______________________

    def test_valid_email_can_have_plus_sign():
>       assert is_valid_email_address('[email protected]')
E       AssertionError: assert False
E        +  where False = is_valid_email_address('[email protected]')

test_validator.py:17: AssertionError
_______________________test_valid_email_must_have_a_tld_______________________

    def test_valid_email_must_have_a_tld():
>       assert not is_valid_email_address('[email protected]')
E       AssertionError: assert not True
E        +  where True = is_valid_email_address('[email protected]')

test_validator.py:20: AssertionError
=========================== short test summary info ============================
FAILED test_validator.py::test_valid_email_can_have_plus_sign - AssertionErro...
FAILED test_validator.py::test_valid_email_must_have_a_tld - AssertionError: ...
========================= 2 failed, 3 passed in 0.05s ==========================

Observe que tenemos dos FF además de nuestros tres puntos ... para indicar que dos funciones de prueba fallaron.

Además, obtenemos una nueva sección FAILURES en nuestra salida que explica en detalle en qué punto falló nuestra prueba. Esto es bastante útil para la depuración.

Diseño de pruebas

Nuestro pequeño ejemplo de validador es un testimonio de la importancia de diseñar pruebas.

Primero escribimos nuestra función validadora y luego ideamos algunos casos de prueba para ella. Pronto nos dimos cuenta de que estos casos de prueba no son en absoluto completos. Por el contrario, nos faltaban algunos aspectos esenciales de la validación de una dirección de correo electrónico.

Puede que hayas oído hablar del Desarrollo Dirigido por Pruebas (TDD), que aboga por todo lo contrario: Conseguir que los requisitos sean correctos escribiendo primero los casos de prueba y no empezar a implementar una función antes de sentir que se han cubierto todos los casos de prueba. Esta forma de pensar siempre ha sido una buena idea, pero ha cobrado aún más importancia con el tiempo, ya que los proyectos de software han aumentado su complejidad.

Pronto escribiré otra entrada en el blog sobre TDD para tratarla en profundidad.

Configuración

Normalmente, la configuración de un proyecto es mucho más complicada que un simple archivo con una función de validación en él.

Es posible que tengas una estructura de paquetes de Python para tu proyecto, o que tu código dependa de dependencias externas como una base de datos.

Fixtures

Es posible que hayas utilizado el término fixture en diferentes contextos. Por ejemplo, para el Django web framework, los fixtures se refieren a una colección de datos iniciales que se cargan en la base de datos. Sin embargo, en el contexto de pytest, los fixtures sólo se refieren a las funciones ejecutadas por pytest antes y/o después de las funciones de prueba reales.

Montaje y desmontaje

Podemos crear estas funciones utilizando el decorador pytest.fixture(). Por ahora lo hacemos dentro del archivo test_validator.py.

1
2
3
4
5
6
7
import pytest

@pytest.fixture()
def database_environment():
    setup_database()
    yield
    teardown_database()

Ten en cuenta que la configuración de la base de datos y su desmontaje se realizan en el mismo fixture. La palabra clave yield indica la parte donde pytest ejecuta las pruebas reales.

Para que el fixture sea realmente utilizado por una de tus pruebas, simplemente añade el nombre del fixture como argumento, así (todavía en test_validator.py):

1
2
def test_world(database_environment):
    assert 1 == 1

Obtención de datos de Fixtures

En lugar de utilizar yield, una función de fijación también puede devolver valores arbitrarios:

1
2
3
4
5
import pytest

@pytest.fixture()
def my_fruit():
    return "apple"

De nuevo, solicitar ese fixture desde una función de prueba se hace proporcionando el nombre del fixture como parámetro:

1
2
def test_fruit(my_fruit):
    assert my_fruit == "apple"

Archivos de configuración

pytest puede leer su configuración específica del proyecto desde uno de estos archivos:

  • pytest.ini
  • tox.ini
  • setup.cfg

El archivo a utilizar depende de las otras herramientas que pueda utilizar en su proyecto. Si has empaquetado tu proyecto, deberías usar el archivo setup.cfg. Si usas tox para probar tu código en diferentes entornos, puedes poner la configuración de pytest en el archivo tox.ini. El archivo pytest.ini se puede utilizar si no quieres utilizar ninguna herramienta adicional, sino pytest.

El archivo de configuración es prácticamente el mismo para cada uno de estos tres tipos de archivos:

Using pytest.ini and tox.ini

1
2
[pytest]
addopts = ​-rsxX -l --tb=short --strict​

Si está utilizando el archivo setup.cfg, la única diferencia es que debe anteponer a la sección [pytest] el prefijo tool: de esta manera:

1
2
[tool:pytest]
addopts = ​-rsxX -l --tb=short --strict​

conftest.py

Cada carpeta que contiene archivos de prueba puede contener un archivo conftest.py que es leído por pytest. Este es un buen lugar para colocar sus accesorios personalizados ya que estos podrían ser compartidos entre diferentes archivos de prueba.

Los archivos conftest.py pueden alterar el comportamiento de pytest en función del proyecto.

Aparte de los fixtures compartidos, puedes colocar hooks externos y plugins o modificadores para el PATH utilizado por pytest para descubrir pruebas y código de implementación.

CLI / PDB

Durante el desarrollo, principalmente cuando escribes tus pruebas antes de la implementación, pytest puede ser una herramienta beneficiosa para la depuración.

Vamos a echar un vistazo a las opciones de línea de comandos más útiles.

Ejecutar sólo una prueba

Si quiere ejecutar sólo una prueba en particular, puede hacer referencia a esa prueba mediante el archivo test_ en el que se encuentra y el nombre de la función:

1
pytest test_validator.py::test_regular_email_validates

Sólo para recoger

A veces sólo se quiere tener una lista de la colección de pruebas en lugar de ejecutar todas las funciones de prueba.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
pytest --collect-only

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items

<Module test_validator.py>
  <Function test_regular_email_validates>
  <Function test_valid_email_has_one_at_sign>
  <Function test_valid_email_has_only_allowed_chars>
  <Function test_valid_email_can_have_plus_sign>
  <Function test_valid_email_must_have_a_tld>

========================== 5 tests collected in 0.01s ==========================

Salir al primer error

Puede forzar a pytest a dejar de ejecutar más pruebas después de una fallida:

1
pytest -x

Ejecutar sólo la última prueba fallida

Si quiere ejecutar sólo las pruebas que fallaron la última vez, puede hacerlo utilizando la bandera --lf:

1
pytest --lf

Ejecute todas las pruebas, pero ejecute primero las últimas que hayan fallado

1
pytest --ff

Mostrar los valores de las variables locales en la salida

Si configuramos una función de prueba más compleja con algunas variables locales, podemos ordenar a pytest que muestre estas variables locales con la bandera -l.

Reescribamos nuestra función de prueba así:

1
2
3
4
5
...
def test_valid_email_can_have_plus_sign():
    email = '[email protected]'
    assert is_valid_email_address('[email protected]')
...

Entonces,

1
pytest -l

nos dará este resultado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items

test_validator.py ...FF                                                  [100%]

=================================== FAILURES ===================================
_____________________ test_valid_email_can_have_plus_sign ______________________

    def test_valid_email_can_have_plus_sign():
        email = '[email protected]'
>       assert is_valid_email_address('[email protected]')
E       AssertionError: assert False
E        +  where False = is_valid_email_address('[email protected]')

email      = '[email protected]'

test_validator.py:18: AssertionError
_______________________test_valid_email_must_have_a_tld_______________________

    def test_valid_email_must_have_a_tld():
>       assert not is_valid_email_address('[email protected]')
E       AssertionError: assert not True
E        +  where True = is_valid_email_address('[email protected]')

test_validator.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_validator.py::test_valid_email_can_have_plus_sign - AssertionErro...
FAILED test_validator.py::test_valid_email_must_have_a_tld - AssertionError: ...
========================= 2 failed, 3 passed in 0.09s ==========================

Uso de pytest con un depurador

Hay un depurador de línea de comandos, llamado pdb, que está integrado en Python. Puedes usar pytest para depurar el código de tu función de prueba.

Si inicias pytest con --pdb, se iniciará una sesión de depuración pdb justo después de que se produzca una excepción en tu prueba. La mayoría de las veces esto no es particularmente útil, ya que podrías querer inspeccionar cada línea de código antes de la excepción lanzada.

Otra opción es la bandera --trace para pytest que establecerá un punto de interrupción en la primera línea de cada función de prueba. Esto puede ser un poco incómodo si tienes muchas pruebas. Así que, para propósitos de depuración, una buena combinación es --lf --trace que iniciaría una sesión de depuración con pdb al principio de la última prueba que falló:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pytest --lf --trace

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example, configfile: pytest.ini
collected 2 items
run-last-failure: rerun previous 2 failures

test_validator.py
>>>>>>>>>>>>>>>>>>>> PDB runcall (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>
> /Users/bascodes/Code/blogworkspace/pytest-example/test_validator.py(17)test_valid_email_can_have_plus_sign()
-> email = '[email protected]'
(Pdb)

CI / CD

En los proyectos de software modernos, el software se desarrolla de acuerdo con los principios del Desarrollo Dirigido por Pruebas y se entrega a través de una tubería de Integración Continua / Despliegue Continuo que incluye pruebas automatizadas.

Una configuración típica es que los commits a la rama main/master son rechazados a menos que todas las funciones de prueba pasen.

Si quieres saber más sobre el uso de pytest en un entorno CI/CD, permanece atento porque estoy planeando un nuevo artículo sobre ese tema.

Documentación

La documentación oficial de pytest está aquí: https://docs.pytest.org

Una nota rápida:

Este es un reenvío del artículo original de Bas Steins hecho con su permiso. Visita su sitio para ver más artículos y/o síguelo en Twitter: @bascodes