Parametrización de Pytest
El @pytest.mark.parametrize() decorador permite que una función de prueba se ejecute varias veces con valores de entrada diferentes. Úselo cuando la lógica de aserción sea la misma, pero los ejemplos varían. La parametrización puede reducir la repetición y permite que pytest notifique cada entrada como su propio elemento de prueba.
El decorador normalmente toma dos argumentos necesarios:
-
argnames: una cadena separada por comas, o una lista o tupla de cadenas, con los nombres de los argumentos que se van a pasar a la función de prueba. Entre los ejemplos se incluyen"item","test_input, expected",["test_input", "expected"]y("test_input", "expected"). -
argvalues: un iterable de valores o conjuntos de parámetros que se van a usar para esos argumentos. Con un nombre de argumento, cada elemento se pasa como un valor de prueba, incluso si ese valor es una tupla. Cuando hay varios nombres de argumento, cada elemento debe proporcionar un valor para cada uno, normalmente como tupla, lista o llamada apytest.param(...).
El argumento opcional ids permite personalizar el identificador de prueba que pytest usa para etiquetar cada elemento de prueba generado. Las opciones más avanzadas, como indirect y scope, son útiles para casos como accesorios parametrizados o recursos costosos; dispone de más información sobre ellos en el procedimiento de parametrización de pytest y @pytest.mark.parametrize la referencia.
Note
Pytest pasa valores de parámetro a pruebas as-is; no los copia. Si una prueba muta un valor de parámetro mutable, como una lista o diccionario, esa mutación puede ser visible en casos generados posteriormente que reciban el mismo objeto.
Cuándo usar parametrizar
Entre los dos escenarios comunes en los que es posible que quiera usar @pytest.mark.parametrize() se incluyen:
- Cuando una prueba recorre en bucle las entradas y repite la misma aserción
- Cuando varias funciones de prueba declaran el mismo comportamiento con entradas diferentes
Revisemos primero cada ejemplo sin usar @pytest.mark.parametrize()y, después, con él para mostrar cómo puede facilitar el mantenimiento y el diagnóstico de las 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()
Este patrón puede ser difícil de diagnosticar cuando se produce un error en una entrada:
- Comentarios incompletos: La primera aserción con error detiene el bucle, por lo que los valores posteriores no se evalúan en esa ejecución.
- Resultado de una prueba única: Pytest notifica una prueba recopilada en lugar de un caso de prueba por entrada.
- Repeticiones repetidas: Corregir el primer error podría revelar otro error solo después de volver a ejecutar la prueba.
Vamos a modificar la prueba para incluir dos elementos que producirán un error en la aserción:
def test_string_is_digit():
items = ["No", "1", "10", "33", "Yes"]
for item in items:
assert item.isdigit()
La ejecución de la prueba muestra solo el primer error, aunque hay dos elementos no válidos en la lista. Los ejemplos de comandos usan python -m pytest, que funciona cuando Python está disponible como python. Si el shell usa python3 en macOS o Linux, o py en Windows, sustituya ese comando.
python -m pytest test_items.py
=================================== FAILURES ===================================
_____________________________ test_string_is_digit _____________________________
def test_string_is_digit():
items = ["No", "1", "10", "33", "Yes"]
for item in items:
> assert item.isdigit()
E AssertionError: assert False
E + where False = <built-in method isdigit of str object at 0x...>()
E + where <built-in method isdigit of str object at 0x...> = 'No'.isdigit
test_items.py:4: AssertionError
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit - AssertionError: assert False
============================== 1 failed in 0.01s ===============================
La salida de la introspección de aserciones de Pytest muestra el método enlazado str.isdigit y el valor de cadena que desencadenó el error. Los encabezados de sesión, los detalles de la plataforma, las direcciones de memoria y los tiempos de ejecución pueden variar, por lo que los ejemplos omiten o acortan con ... y 0x.... El detalle relevante es que el bucle se detiene en el primer error ('No') y nunca evalúa 'Yes'.
Este es un caso útil para @pytest.mark.parametrize(). Antes de actualizar la prueba, vamos a explorar otra situación común que no implica for bucles.
Pruebas que verifican el mismo comportamiento
Un grupo de pruebas que hacen la misma afirmación también son buenos candidatos para @pytest.mark.parametrize(). 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 más granulares porque un error se puede asociar a una sola entrada. Aunque puede parecer inusual tener varias pruebas similares, este patrón es común en los conjuntos de pruebas de producción que intentan notificar errores con precisión.
Sin embargo, las pruebas repetitivas vienen con los siguientes problemas:
- El código es repetitivo, lo que crea una carga de mantenimiento.
- Las funciones similares son fáciles de actualizar incoherentemente.
- Agregar una nueva entrada requiere copiar otro cuerpo de prueba, por lo que se pueden pasar por alto los casos importantes.
La parametrización mantiene la ventaja de generación de informes de elementos de prueba independientes sin copiar el mismo cuerpo de prueba.
Cómo usar parametrize
Ahora que conoce algunos casos de uso para @pytest.mark.parametrize(), vamos a actualizar la prueba que usó un for bucle con elementos con errores.
Importe pytesty, a continuación, aplique @pytest.mark.parametrize() directamente encima de la función de prueba:
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.
En este ejemplo, el decorador recibe dos argumentos. El primer argumento, "item", asigna un nombre al argumento que pytest pasa a la función de prueba. El nombre debe coincidir con el item parámetro en test_string_is_digit(item). El segundo argumento es la lista de valores que pytest usa para los casos de prueba generados.
Informes de errores detallados
En segundo plano, pytest recopila un elemento de prueba para cada valor de la lista de parámetros. Esto significa que los casos exitosos y fallidos se notifican por separado. Veamos lo que sucede al ejecutar la prueba:
python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items
test_items.py F...F [100%]
=================================== FAILURES ===================================
___________________________ test_string_is_digit[No] ___________________________
item = 'No'
@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
> assert item.isdigit()
E AssertionError: assert False
E + where False = <built-in method isdigit of str object at 0x...>()
E + where <built-in method isdigit of str object at 0x...> = 'No'.isdigit
test_items.py:5: AssertionError
__________________________ test_string_is_digit[Yes] ___________________________
item = 'Yes'
@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
> assert item.isdigit()
E AssertionError: assert False
E + where False = <built-in method isdigit of str object at 0x...>()
E + where <built-in method isdigit of str object at 0x...> = 'Yes'.isdigit
test_items.py:5: AssertionError
=========================== 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 el informe de prueba. En primer lugar, pytest informa de cinco elementos recopilados desde una única función de prueba: tres aprobados y dos fallidos. Los errores se notifican por separado, incluido el valor de entrada que provocó cada error. El encabezado test_string_is_digit[No] muestra el conjunto de parámetros y el parámetro local item = 'No' aparece encima de la vista de origen. Las líneas de introspección del método enlazado (direcciones de memoria que se muestran como 0x...) confirman qué valor desencadenó el error de aserción:
___________________________ test_string_is_digit[No] ___________________________
item = 'No'
[...]
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 superan las pruebas, los informes de línea de comandos son mínimos de forma predeterminada. Esta es la forma en que la prueba se vería después de una actualización para corregir los errores (siga editando lo mismo test_items.py , por lo que import pytest ya está presente):
@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:
python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items
test_items.py ..... [100%]
============================== 5 passed in 0.01s ===============================
Al aumentar la verbosidad, se muestra cada caso de prueba generado y su ID de parámetro correspondiente.
python -m pytest -v test_items.py
============================= test session starts ==============================
...
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
En los ejemplos que hemos visto hasta ahora se usa un nombre de argumento: "item". También puede incluir varios nombres de argumento en el primer argumento. Cuando se usa una cadena, separe los nombres con comas. También puede pasar los nombres como una lista o tupla, como ["item", "attribute"] o ("item", "attribute").
Un caso de uso para múltiples nombres de argumento consiste en pasar un valor de entrada y el valor esperado como parámetros para probar contra ellos. En el segundo argumento, cada conjunto de parámetros necesita un valor para cada nombre de argumento. Por ejemplo, si los nombres de argumento son "test_input, expected_value", los valores de argumento pueden ser [("3+5", 8), ("3*4", 12)].
En el ejemplo siguiente se usa la función hasattr() de Python de Python, que devuelve un valor booleano en función de si un objeto tiene un atributo con nombre:
>>> hasattr(dict(), "keys")
True
>>> hasattr("string", "append")
False
Cada caso de prueba necesita un objeto y un nombre de atributo, por lo que podemos usar @pytest.mark.parametrize() de la siguiente manera. Continúe en el mismo test_items.py archivo, por lo que import pytest ya está en su lugar:
@pytest.mark.parametrize("item, attribute", [("", "format"), (list(), "append")])
def test_attributes(item, attribute):
assert hasattr(item, attribute)
El @pytest.mark.parametrize() decorador sigue usando una sola cadena para el primer argumento, pero esa cadena ahora contiene dos nombres de argumento separados por una coma. Los nombres se convierten en argumentos para la función de prueba. En este caso, son item y attribute. Una lista o tupla de nombres funciona de la misma manera.
El segundo argumento es una lista con dos conjuntos de parámetros. Cada conjunto de parámetros contiene un item valor y un attribute valor. Si un conjunto de parámetros tiene demasiados o demasiados valores para los nombres de argumento, pytest genera un error de colección antes de ejecutar los casos de prueba.
Pytest compila un identificador de prueba para cada conjunto de parámetros. Para valores simples como números, cadenas, booleanos y None, pytest usa la representación de cadena habitual del valor. Para otros objetos, pytest vuelve a un nombre basado en el nombre del argumento y en el índice de base cero del conjunto de parámetros. En esta salida, la cadena vacía contribuye a una parte de identificador vacía, por lo que el separador de guiones entre los dos identificadores sigue siendo visible en [-format]. Dado que list() no tiene un identificador generado simple, pytest sustituye item1a :
python -m pytest -v test_items.py::test_attributes
============================= test session starts ==============================
...
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 ===============================
Si los identificadores generados son difíciles de leer, puede pasar el argumento opcional ids o encapsular un parámetro establecido en pytest.param(..., id="name") para proporcionar un nombre más claro. Por ejemplo:
import pytest
@pytest.mark.parametrize(
"item, attribute",
[
pytest.param("", "format", id="empty-string-format"),
(list(), "append"),
],
)
def test_attributes(item, attribute):
assert hasattr(item, attribute)
La pytest.param("", "format", id="empty-string-format") llamada proporciona el identificador completo de ese conjunto de parámetros, reemplazando el identificador generado -format automáticamente. En la salida detallada, las pruebas generadas se denominan test_attributes[empty-string-format] y test_attributes[item1-append]. Los identificadores claros facilitan el escaneo de los mensajes de salida verbosos y de error.