Parametrize de Pytest

Completado

La característica de parametrización en pytest puede parecer inicialmente compleja, pero su propósito es sencillo una vez que comprenda el problema que soluciona. Básicamente, parametrize permite ejecutar la misma función de prueba con diferentes entradas de forma eficaz, lo que facilita la ejecución de aserciones detalladas y variadas con menos código.

Al llamar a parametrize, el primer argumento es una cadena que contiene uno o varios nombres de argumento, por ejemplo, "test\_input_". El segundo argumento contiene una lista de valores de argumento, por ejemplo, ["27", "6+9", "0", "O"]. Los cuatro últimos argumentos tienen valores predeterminados y son opcionales.

Puede encontrar la referencia de la API pytest del idioma inglés para parametrize aquí: pytest. Metafunc.parametrize.

Cuándo usar parametrizar

Entre los dos escenarios comunes en los que es posible que quiera usar parametrize se incluyen:

  • Al realizar pruebas de bucles para
  • Cuando varias pruebas declaran el mismo comportamiento

Revisemos primero cada ejemplo sin usar parametrize y, después, con él para mostrar cómo puede mejorar nuestras pruebas.

Bucles For

Este es un ejemplo de una función de prueba con un for bucle:

def test_string_is_digit():
    items = ["1", "10", "33"]
    for item in items:
        assert item.isdigit()

Esta prueba es problemática porque, si se produce un error, puede provocar varios problemas, entre los que se incluyen:

  • Informes de prueba ambiguos: El informe de prueba no aclara si solo se produjo un error en un elemento o si hay varios errores.
  • Vista de prueba única: Todos los elementos se ven como una sola prueba, lo que oculta el rendimiento de los elementos individuales.
  • Correcciones inciertas: Si se corrige un error, no hay forma de saber si todos los problemas se resuelven sin volver a ejecutar toda la prueba.

Vamos a modificar la prueba para incluir específicamente dos elementos que se espera que produzcan errores:

def test_string_is_digit():
    items = ["No", "1", "10", "33", "Yes"]
    for item in items:
        assert item.isdigit()

La ejecución de la prueba solo muestra un error aunque haya dos elementos no válidos en esa lista:

$ pytest test_items.py
=================================== FAILURES ===================================
_____________________________ test_string_is_digit _____________________________
test_items.py:4: in test_string_is_digit
    assert item.isdigit()
E   AssertionError: assert False
E    +  where False = <built-in method isdigit of str object at 0x103fa1df0>()
E    +    where <built-in method isdigit of str object at 0x103fa1df0> = 'No'.isdigit
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit - AssertionError: assert False
============================== 1 failed in 0.01s ===============================

Este es un buen caso de uso para parametrize. Antes de poder ver cómo actualizar la prueba, exploremos otra situación común que no implica for bucles.

Pruebas que verifican el mismo comportamiento

Un grupo de pruebas que realizan la misma aserción también son un buen candidato para parametrizar. Si la prueba anterior se reescribía con una prueba para cada elemento, permitiría un mejor informe de errores, pero sería repetitivo:

def test_is_digit_1():
    assert "1".isdigit()

def test_is_digit_10():
    assert "10".isdigit()

def test_is_digit_33():
    assert "33".isdigit()

Estas pruebas son mejores en el sentido de que un error se puede asociar fácilmente a una sola entrada. Y aunque parezca inusual tener varias pruebas similares, es común ver en conjuntos de pruebas de producción que estos intenten ser granulares.

Aunque las pruebas serían mejores porque pueden notificar exactamente lo que falla (o supera) también vienen con los siguientes problemas:

  • El código es repetitivo, lo que crea una carga de mantenimiento
  • Existe la posibilidad de errores y errores tipográficos al actualizar las pruebas.
  • Dado que son repetitivos, es posible que los ingenieros no incluyan todos los casos de uso y entradas.

Cómo usar parametrize

Ahora que conoce algunos de los casos de uso de parametrize, vamos a actualizar la prueba que usó un for bucle que incluye elementos con errores.

Para usar parametrize, debe importar pytest como una biblioteca y, a continuación, usarla como decorador en la función . Esta es la prueba actualizada:

import pytest

@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
    assert item.isdigit()

Antes de ejecutar las pruebas, vamos a repasar los cambios.

El pytest.mark.parametrize() decorador define dos argumentos. El primer argumento es una cadena denominada "item". Esa cadena se usa como argumento con nombre para la función de prueba que se ve en la siguiente línea de la definición de la función de prueba. El segundo argumento es la lista de valores de prueba.

Informes de errores detallados

En segundo plano, pytest considera cada elemento de esa lista como una prueba independiente. Esto significa que las pruebas superadas y con errores se notifican por separado. Veamos lo que sucede al ejecutar la prueba con pytest:

$ pytest test_items.py
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /private
collected 5 items

test_items.py F...F                                                      [100%]

=================================== FAILURES ===================================
___________________________ test_string_is_digit[No] ___________________________
test_items.py:5: in test_string_is_digit
    assert item.isdigit()
E   AssertionError: assert False
E    +  where False = <built-in method isdigit of str object at 0x102d45e30>()
E    +    where <built-in method isdigit of str object at 0x102d45e30> = 'No'.isdigit
__________________________ test_string_is_digit[Yes] ___________________________
test_items.py:5: in test_string_is_digit
    assert item.isdigit()
E   AssertionError: assert False
E    +  where False = <built-in method isdigit of str object at 0x102d45df0>()
E    +    where <built-in method isdigit of str object at 0x102d45df0> = 'Yes'.isdigit
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False
FAILED test_items.py::test_string_is_digit[Yes] - AssertionError: assert False
========================= 2 failed, 3 passed in 0.07s ==========================

Hay algunos elementos importantes en los informes de prueba. En primer lugar, vemos que desde una sola prueba pytest notifica cinco pruebas en total: tres pruebas aprobadas y dos fallidas. Los errores se notifican por separado, incluyendo cuál es la entrada fallida.

$ pytest test_items.py
___________________________ test_string_is_digit[No] ___________________________
[...]
E    +    where <built-in method isdigit of str object at 0x102d45e30> = 'No'.isdigit
[...]
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False

Es difícil no identificar el valor que provocó el error, ya que se notifica en muchos lugares.

Uso de la marca de salida detallada

Cuando se ejecutan las pruebas en la línea de comandos, los informes de pruebas cuando la prueba superada es mínima. A continuación se muestra cómo se vería la prueba después de una actualización para corregir las fallas:

@pytest.mark.parametrize("item", ["0", "1", "10", "33", "9"])
def test_string_is_digit(item):
    assert item.isdigit()

La ejecución de las pruebas genera una salida mínima:

$ pytest test_items.py 
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /private
collected 5 items

test_items.py .....                                                      [100%]

============================== 5 passed in 0.01s ===============================

Aumentar el nivel de detalle muestra los valores que pytest ejecuta para cada prueba al usar parametrizar:

$ pytest -v test_items.py
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0 
rootdir: /private
collected 5 items

test_items.py::test_string_is_digit[0] PASSED                            [ 20%]
test_items.py::test_string_is_digit[1] PASSED                            [ 40%]
test_items.py::test_string_is_digit[10] PASSED                           [ 60%]
test_items.py::test_string_is_digit[33] PASSED                           [ 80%]
test_items.py::test_string_is_digit[9] PASSED                            [100%]

============================== 5 passed in 0.01s ===============================

Uso de varios nombres de argumento

Los ejemplos que hemos visto hasta ahora solo tienen un nombre de argumento en el primer argumento. Hemos estado usando "item" , pero puede incluir varios nombres de argumento en la cadena que especifica el primer argumento separado por comas.

Un caso de uso para usar varios nombres de argumento es si desea pasar un conjunto de valores esperados para probar con el valor de entrada. En el segundo argumento, cada elemento del conjunto debe tener una cantidad de valores iguales al número de nombres de entrada. Por ejemplo, si los nombres de entrada son "test\_input, expected\_value", el segundo argumento podría tener un aspecto similar al siguiente: [("3+5", 8), ("3*4", 12)]

Esta prueba comprueba si un objeto tiene un atributo mediante la función de Python hasattr() . Devuelve un valor booleano en función de si el objeto posee el atributo asociado.

>>> hasattr(dict(), "keys")
True
>>> hasattr("string", "append")
False

Ya que hasattr() requiere dos argumentos, podemos usar parametrize de la siguiente manera:

@pytest.mark.parametrize("item, attribute", [("", "format"), (list(), "append")])
def test_attributes(item, attribute):
    assert hasattr(item, attribute)

El decorador parametrize sigue usando una sola cadena para el primer argumento, pero con dos nombres de argumento separados por una coma, que se convierten en argumentos para la función de prueba. En este caso, es item y attribute.

A continuación, se muestra una lista de dos pares de elementos. Cada uno de estos pares representa un item y un attribute para probar.

Cuando pytest no puede crear una representación de cadena de los objetos que se pasan, crea una. Puede ver esto al ejecutar la prueba:

$ pytest -v test_items.py
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0 
rootdir: /private
collected 2 items

test_items.py::test_attributes[-format] PASSED                           [ 50%]
test_items.py::test_attributes[item1-append] PASSED                      [100%]

============================== 2 passed in 0.01s ===============================