Esta guía trata sobre el framework de pruebas más popular entre los desarrolladores Python.

Escribir código funcional es solo la mitad del trabajo: debes asegurarte de que produzca resultados correctos.

Puedes probar tu programa manualmente: ejecutarlo una y otra vez en diferentes condiciones y verificar si todo funciona correctamente. Pero, por supuesto, es mejor automatizar el proceso y escribir código que verifique otro código. Para simplificar esta tarea, se creó Pytest.

¿Qué es Pytest?

Pytest es un framework para probar código en Python. Fue desarrollado en 2004, pero aún se actualiza regularmente y permite no solo escribir pruebas, sino también crear un entorno para ellas, así como configurar los parámetros de ejecución.

Según una investigación de JetBrains, Pytest lo usa uno de cada dos programadores Python.

Gráfico de barras que muestra la popularidad de diferentes frameworks de pruebas unitarias en Python. Pytest lidera con un 52%.
Popularidad de los Frameworks de Prueba (según estadísticas de JetBrains)

Ventajas y Desventajas de Pytest

El éxito de Pytest en comparación con sus competidores (como Unittest) se explica fácilmente por sus ventajas:

  • Código conciso: El sintaxis de Pytest no tiene estructuras engorrosas como en Unittest. Una prueba simple puede consistir en solo dos líneas.
  • Informes de errores detallados: Si una prueba no funciona correctamente, Pytest te explicará qué sucede.
  • Operador assert universal: No necesitas recordar sus diferentes tipos, como en Unittest.
  • Fixtures: Permiten crear contexto para un grupo de pruebas.
  • Etiquetas: Puedes configurar el comportamiento de las pruebas: establecer condiciones de ejecución, pasar diferentes datos de entrada a la misma prueba, etc.
  • Puede ejecutar pruebas de otros frameworks: Pytest es compatible con Unittest, Doctest y Nose.
  • Muchos plugins: Si te falta alguna función “fuera de la caja”, hay más de mil plugins escritos para Pytest.

Sin embargo, el framework también tiene desventajas:

  • Implícito y mágico: La otra cara de la simplicidad y la concisión es que muchos procesos ocurren “detrás del capó”. Para comprenderlos en detalle, tendrás que estudiar la documentación.
  • No está incluido en la biblioteca estándar: Pytest debe instalarse por separado. Si tienes una versión antigua (inferior a 3.7) de Python, deberás conectar la versión correspondiente del framework. Puedes encontrar la lista aquí.
  • Otros frameworks no son compatibles con Pytest: Una consecuencia inevitable de su estatus de líder: Pytest puede ejecutar pruebas de otros frameworks, pero ninguno de los otros frameworks puede ejecutar pruebas de Pytest.

Cómo instalar Pytest

Pytest se incluye en la mayoría de los paquetes de Python. Su última versión está disponible para Python 3.7+ y PyPy 3. Para instalarlo en tu entorno virtual, usa el comando:

pip install -U pytest

Otra forma es usar el gestor de paquetes de tu IDE. Busca el módulo llamado pytest y cárgalo.

Cómo escribir pruebas

Primero, necesitas el código que vas a probar. Crea un archivo main.py y la función sum2. Recibirá dos argumentos y devolverá su suma:

def sum2(x, y):
    return x + y

Ahora, verifica si funciona correctamente. Para ello, crea un archivo tests.py, importa sum2 y escribe test_sum2:

from main import sum2

def test_sum2():
    assert sum2(15, 8) == 23

Para ejecutar las pruebas, introduce el comando pytest en la consola. Una alternativa es usar la interfaz de tu IDE. Por ejemplo, PyCharm permite ejecutar el archivo completo o una función de prueba individualmente.

Obtendrás este resultado:

tests.py::test_sum2 PASSED    [100%]

Ahora cambia tu prueba: espera obtener 0 en lugar de 23:

def test_sum2():
    assert sum2(15, 8) == 0

Recibirás un mensaje indicando que la prueba no se ha superado:

FAILED                                               [100%]
tests.py:3 (test_sum2)
23 != 0

Expected :0
Actual   :23
<Click to see difference>

def test_sum2():
>       assert sum2(15, 8) == 0
E       assert 23 == 0
E        +  where 23 = sum2(15, 8)

tests.py:5: AssertionError

Restricciones de Nomenclatura

Para que Pytest reconozca las funciones como pruebas, los archivos y las propias pruebas deben nombrarse de una manera específica:

  • El nombre del archivo debe comenzar con test o terminar con test.py.
  • El nombre de la función debe estar en minúsculas y comenzar con test_.

Cómo funciona assert

A la palabra clave assert se le puede pasar cualquier condición. Si es verdadera (resultado True), la prueba se aprueba; si es falsa (resultado False), no se aprueba.

De esta manera, puedes escribir pruebas mínimas:

def test_true():
    assert True

def test_false():
    assert False

Resultado:

tests.py::test_true PASSED      [50%]
tests.py::test_false FAILED     [100%]
tests.py:9 (test_false)
def test_false():
>       assert False
E       assert False

tests.py:11: AssertionError

Puedes agregar un mensaje de depuración después de la condición con una coma. Pytest lo mostrará si la prueba falla:

def test_message():
    assert False, 'La prueba siempre falla'

Resultado:

tests.py::test_message FAILED       [100%]
tests.py:0 (test_message)
def test_message():
>       assert False, 'La prueba siempre falla'
E       AssertionError: La prueba siempre falla
E       assert False

tests.py:2: AssertionError

Si una prueba no tiene assert, se considera aprobada:

def test_pass():
    pass  # instrucción de relleno, no hace nada

Resultado:

tests.py::test_pass PASSED       [100%]

Nota: Una prueba puede tener varios operadores assert, pero no es recomendable. Es mejor seguir la regla “Una prueba – una entidad, una función – un assert“.

Ejecución de pruebas

El comando de terminal pytest ejecuta todas las pruebas del directorio actual. Para controlar las condiciones de ejecución, especifica después de él la ruta al archivo o incluso a una función específica.

  • Comando para ejecutar el archivo tests.py: pytest tests.py.
  • Comando para ejecutar solo la función test_sum2: pytest tests.py::test_sum2.

Para una ejecución más flexible, puedes agregar banderas. Su lista se encuentra en la documentación de Pytest.

Además de los comandos de terminal, puedes usar la interfaz gráfica de tu IDE. Las pruebas descritas en este artículo se ejecutan a través de las herramientas de PyCharm.

Fixtures en Pytest

Los fixtures son funciones que crean un entorno alrededor de las pruebas. Son útiles cuando necesitas pasar los mismos datos de entrada a varias pruebas.

Supongamos que tienes varias funciones en main.py:

# Agrega 2 a cada elemento de la colección
def plus2(nums):
    result = []
    for num in nums:
        result.append(num + 2)
    return result

# Multiplica por 2 cada elemento de la colección
def multiply2(nums):
    result = []
    for num in nums:
        result.append(num * 2)
    return result

# Eleva al cuadrado cada elemento de la colección
def exponent2(nums):
    result = []
    for num in nums:
        result.append(num ** 2)
    return result

Escribe una prueba para cada una de ellas en el archivo tests.py. Como matriz de prueba, usa una lista de números primos del 1 al 50. Créala usando un bucle for-else:

from main import *

def test_plus2():
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    assert plus2(prime_nums) == [3, 4, 5, 7, 9, 13, 15, 19, 21, 25, 31, 33, 39, 43, 45, 49]

def test_multiply2():
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    assert multiply2(prime_nums) == [2, 4, 6, 10, 14, 22, 26, 34, 38, 46, 58, 62, 74, 82, 86, 94]

def test_exponent2():
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    assert exponent2(prime_nums) == [1, 4, 9, 25, 49, 121, 169, 289, 361, 529, 841, 961, 1369, 1681, 1849, 2209]

Por ahora, todas las funciones de prueba usan la misma estructura engorrosa que crea una lista de números primos. Sácala a una fixture separada. Para ello, importa explícitamente el módulo pytest:

import pytest

Para declarar una función como fixture, usa el decorador @pytest.fixture() antes de ella:

@pytest.fixture()
def get_prime_nums():
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    return prime_nums

Ahora, pasa esta fixture a todas las pruebas donde sea necesaria. Al acceder a la fixture, no necesitas usar paréntesis: como si fuera una variable, no una función.

Las pruebas resultantes son:

def test_plus2(get_prime_nums):
    prime_nums = get_prime_nums
    assert plus2(prime_nums) == [3, 4, 5, 7, 9, 13, 15, 19, 21, 25, 31, 33, 39, 43, 45, 49]

def test_multiply2(get_prime_nums):
    prime_nums = get_prime_nums
    assert multiply2(prime_nums) == [2, 4, 6, 10, 14, 22, 26, 34, 38, 46, 58, 62, 74, 82, 86, 94]

def test_exponent2(get_prime_nums):
    prime_nums = get_prime_nums
    assert exponent2(prime_nums) == [1, 4, 9, 25, 49, 121, 169, 289, 361, 529, 841, 961, 1369, 1681, 1849, 2209]

Finalizador

Si deseas que se ejecute otro script después de ejecutar una prueba, también puedes hacerlo a través de fixtures. Para ello, usa yield en lugar de return. El código escrito después de yield se ejecutará al finalizar la prueba.

Cambia la fixture get_prime_nums() en nuestro ejemplo con números primos y agrega un finalizador:

@pytest.fixture()
def get_prime_nums():
    print('\nTrabajo de la fixture')
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    yield prime_nums
    print('\nTrabajo del finalizador')

Al ejecutar las pruebas, obtienes este resultado:

tests.py::test_plus2 
Trabajo de la fixture
PASSED                                              [ 33%]
Trabajo del finalizador

tests.py::test_multiply2 
Trabajo de la fixture
PASSED                                          [ 66%]
Trabajo del finalizador

tests.py::test_exponent2 
Trabajo de la fixture
PASSED                                          [100%]
Trabajo del finalizador

Si una prueba no se ha superado (es decir, assert recibió False), el código del finalizador se ejecuta de todos modos.

Alcance de las Fixtures

El alcance de las fixtures se puede configurar. Por defecto, es la función. Esto significa que cuando una función de prueba termina su trabajo, la fixture se finaliza y destruye. En la siguiente llamada, la fixture se crea de nuevo. Esto se ve claramente en el ejemplo anterior con el finalizador.

El alcance de la fixture se especifica en su decorador con el argumento scope='alcance'. Hay cinco niveles:

  • 'function' – para la función;
  • 'class' – para la clase;
  • 'module' – para el módulo (es decir, archivo .py);
  • 'package' – para el paquete;
  • 'session' – para toda la sesión de prueba.

Cambia el alcance de la fixture get_prime_nums a module.

@pytest.fixture(scope='module')
def get_prime_nums():
    print('\nTrabajo de la fixture')
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    yield prime_nums
    print('\nTrabajo del finalizador')

Veamos cómo cambia el trabajo de las pruebas:

tests.py::test_plus2 
Trabajo de la fixture
PASSED         [ 33%]
tests.py::test_multiply2 PASSED        [ 66%]
tests.py::test_exponent2 PASSED       [100%]
Trabajo del finalizador

A diferencia del ejemplo anterior, la fixture se llama solo una vez, en la primera función que la usa. Luego, el resultado del trabajo se almacena en caché. El finalizador también se activa solo una vez, cuando termina el archivo.

Jerarquías de Fixtures

A una prueba se le pueden pasar tantas fixtures como se desee, indicándolas con comas. También se pueden pasar a otras fixtures, también en cualquier cantidad.

Por ejemplo, la fixture get_prime_nums se puede dividir en varias (aunque en nuestro caso no tiene sentido práctico):

@pytest.fixture()
def get_min_num():
    return 1

@pytest.fixture()
def get_max_num():
    return 50

@pytest.fixture()
def get_prime_nums(get_min_num, get_max_num):
    prime_nums = []
    for num in range(get_min_num, get_max_num):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    return prime_nums

Siempre indicas explícitamente qué fixtures usas en una función u otra fixture. Esto permite rastrear cómodamente las dependencias de datos y controlarlas.

Nota: Las fixtures con un alcance más amplio no se pueden integrar con fixtures de un nivel inferior.

Autouso de Fixtures

A veces puede ser útil que una fixture se ejecute siempre, incluso si la función no la llama. Por ejemplo, cuando necesitas iniciar sesión en un sistema antes de ejecutar las pruebas.

En estos casos, puedes especificar el parámetro autouse en el decorador: @pytest.fixture(autouse=True).

Ten cuidado al usar autouse. Estas fixtures pueden crear dependencias implícitas y cambiar los datos de una manera impredecible. Especialmente si hay muchas y están en una jerarquía compleja.

Etiquetas de Pruebas

Pytest permite configurar la ejecución de pruebas aplicándoles etiquetas. Se pueden usar no solo con funciones de prueba, sino también con clases completas. Para agregar una etiqueta, escribe un decorador: @pytest.mark.*nombre de la etiqueta*.

En Pytest, puedes hacer que se ejecuten solo las pruebas etiquetadas. Para ello, usa el comando de terminal con el argumento -m: pytest -m *nombre de la etiqueta*. Puedes hacer lo contrario: ejecutar todas las pruebas, excepto las etiquetadas. En este caso, el comando tiene este aspecto: pytest -m 'not nombre de la etiqueta'.

Una prueba o clase puede tener tantas etiquetas como sea necesario. Puedes ver su lista con el comando pytest --markers y en la documentación. Hablaremos de las principales.

Omitir una Prueba

Para omitir una prueba, coloca la etiqueta skip. Como argumento, puedes pasar un parámetro opcional reason='razón de la omisión'. Por ejemplo:

@pytest.mark.skip(reason='Omisión de prueba')
def test_skipped():
    pass

Resultado:

SKIPPED (Omisión de prueba)                        [100%]
Skipped: Omisión de prueba

Omitir una Prueba bajo Condición

La etiqueta skipif recibe dos argumentos. El primero es una condición. Si se cumple (resultado True), la prueba se omite; si no (resultado False), la prueba se ejecuta normalmente. En el segundo argumento, al igual que con skip, puedes pasar una cadena con la razón de la omisión:

x = 1
@pytest.mark.skipif(x > 0, reason='Omisión de prueba')
def test_skipped_if():
    pass

El resultado es el mismo que en el caso anterior.

Fallo Esperado de una Prueba

Una prueba con la etiqueta xfail puede dar dos resultados. Si la prueba se aprueba, Pytest la marcará como XPASS; si falla como se espera, como XFAIL. Ninguna de las opciones provocará un fallo en el conjunto general de pruebas:

@pytest.mark.xfail(reason='Fallo intencionado')
def test_xfailed():
    assert False

Resultado:

XFAIL (Fallo intencionado)                         [100%]
@pytest.mark.xfail(reason='Fallo intencionado')
    def test_xfailed():
>       assert False
E       assert False

tests.py:49: AssertionError

xfail tiene varios argumentos. Al igual que en skipif, puedes especificar una condición (y esperar un fallo solo con ella) y pasar el parámetro reason. Además, xfail permite:

  • Agregar una excepción en raises=*nombre de la excepción*;
  • No ejecutar la prueba en run=False (entonces se contará automáticamente como XFAIL);
  • Hacer que el fallo de la prueba provoque el fallo de todo el conjunto de pruebas en strict=True.

Más información sobre las capacidades de xfail en la documentación.

Parametrización

La etiqueta parametrize permite llamar a la misma prueba con diferentes datos de entrada. Esto es útil cuando quieres comprobar varios casos.

Por ejemplo, tienes una función que escribe si un número es positivo o negativo:

def positive_or_negative(x):
    if x > 0:
        return 'positive'
    elif x < 0:
        return 'negative'
    else:
        return 'zero'

Primero, verifica si procesa correctamente los números positivos: enteros, decimales y muy pequeños. Sin parametrización, tendrías que escribir tres pruebas del mismo tipo:

from main import positive_or_negative

def test_positive_or_negative_if_positive_int():
    assert positive_or_negative(165) == 'positive'

def test_positive_or_negative_if_positive_float():
    assert positive_or_negative(1.2) == 'positive'

def test_positive_or_negative_if_positive_small():
    assert positive_or_negative(0.0000001) == 'positive'

Resultado:

tests.py::test_positive_or_negative_if_positive_int PASSED   [ 33%]
tests.py::test_positive_or_negative_if_positive_float PASSED [ 66%]
tests.py::test_positive_or_negative_if_positive_small PASSED [100%]

Luego, tendrías que escribir tres pruebas similares para números negativos y otra para cero. En total, siete pruebas para una función pequeña. No es eficiente. Aquí es donde ayuda la parametrización.

La etiqueta parametrize recibe dos argumentos: el nombre de la variable y la lista de sus valores. El nombre de la variable se pasa a la prueba (de la misma manera que la fixture). Queda así:

import pytest
from main import positive_or_negative

@pytest.mark.parametrize('x', [165, 1.2, 0.0000001])
def test_positive_or_negative_if_positive(x):
    assert positive_or_negative(x) == 'positive'

Resultado:

tests.py::test_positive_or_negative_if_positive[165] 
tests.py::test_positive_or_negative_if_positive[1.2] 
tests.py::test_positive_or_negative_if_positive[1e-07] 

PASSED            [33%]
PASSED            [66%]
PASSED            [100%]

Puedes pasar varias variables en una línea con comas. Entonces, cada elemento de la lista se define con una tupla, donde enumeramos los valores de estas variables secuencialmente:

import pytest
from main import positive_or_negative

@pytest.mark.parametrize('x, expected_result',
                         [(165, 'positive'),
                          (1.2, 'positive'),
                          (0.0000001, 'positive'),
                          (-165, 'negative'),
                          (-1.2, 'negative'),
                          (-0.0000001, 'negative'),
                          (0, 'zero')])
def test_positive_or_negative(x, expected_result):
    assert positive_or_negative(x) == expected_result

Resultado:

tests.py::test_positive_or_negative[165-positive] 
tests.py::test_positive_or_negative[1.2-positive] 
tests.py::test_positive_or_negative[1e-07-positive] 
tests.py::test_positive_or_negative[-165-negative] 
tests.py::test_positive_or_negative[-1.2-negative] 
tests.py::test_positive_or_negative[-1e-07-negative] 
tests.py::test_positive_or_negative[0-zero] 

PASSED               [14%]
PASSED               [28%]
PASSED               [42%]
PASSED               [57%]
PASSED               [71%]
PASSED               [85%]
PASSED               [100%]

Si se pasan varias etiquetas parametrize a una prueba, ejecutará todas sus posibles combinaciones. Ejemplo para mayor claridad:

import pytest

@pytest.mark.parametrize('x', [1, 0, -1])
@pytest.mark.parametrize('y', [1, 0, -1])
def test_coordinate_zone(x, y):
    print(x, y)

Resultado:

tests.py::test_coordinate_zone[1-1] 
tests.py::test_coordinate_zone[1-0] 
tests.py::test_coordinate_zone[1--1] 
tests.py::test_coordinate_zone[0-1] 
tests.py::test_coordinate_zone[0-0] 
tests.py::test_coordinate_zone[0--1] 
tests.py::test_coordinate_zone[-1-1] 
tests.py::test_coordinate_zone[-1-0] 
tests.py::test_coordinate_zone[-1--1]

PASSED                                [11%]1 1
PASSED                                [22%]0 1
PASSED                               [33%]-1 1
PASSED                                [44%]1 0
PASSED                                [55%]0 0
PASSED                               [66%]-1 0
PASSED                               [77%]1 -1
PASSED                               [88%]0 -1
PASSED                             [100%]-1 -1

Etiquetas Personalizadas

Además de las etiquetas integradas, puedes usar tus propias etiquetas. Esto es útil cuando necesitas dividir todas las pruebas en varios grupos y ejecutarlas por separado.

Para crear tu propia etiqueta, simplemente usa su nombre:

import pytest

@pytest.mark.my_mark
def test_1():
    pass

def test_2():
    pass

@pytest.mark.my_mark
def test_3():
    pass

@pytest.mark.my_mark
def test_4():
    pass

def test_5():
    pass

Usa el comando pytest --no-summary -m my_mark tests.py para ejecutar solo las pruebas con la etiqueta my_mark. Obtendrás este resultado:

collected 5 items / 2 deselected / 3 selected  

tests.py ..  [100%]
===== 3 passed, 2 deselected, 3 warnings in 0.02s =====

Como puedes ver, de cinco archivos, solo se ejecutaron tres, aquellos con la etiqueta necesaria.

También obtuvimos tres advertencias. Pytest nos llama la atención sobre el hecho de que la etiqueta my_mark no está registrada en el archivo pytest.ini. Pregunta: ¿Estás seguro de que usaste tu propia etiqueta y no te equivocaste al escribir una integrada?

Puedes leer cómo registrar tu propia etiqueta en la documentación.

Resumen

Pytest es el framework de prueba más popular entre los programadores Python. Permite escribir menos código repetitivo que Unittest y puede funcionar sin clases de prueba. Estas son sus herramientas básicas:

  • La palabra clave assert es responsable del resultado de la prueba. Si la condición especificada después de ella es verdadera, la prueba se aprueba; si es falsa, falla.
  • Las fixtures son funciones adicionales en las que puedes configurar el entorno de las pruebas. Pueden usar otras fixtures, creando jerarquías completas.
  • Las etiquetas son decoradores que permiten modificar el comportamiento de las pruebas: omitirlas, esperar resultados específicos, pasar diferentes datos de entrada, etc. Puedes crear tus propias etiquetas personalizadas.

Categorizado en:

Python,