Parametrização em Pytest
O recurso de parametrização no pytest pode inicialmente parecer complexo, mas sua finalidade é simples quando você entende o problema que ele resolve. Essencialmente, a parametrização permite que você execute a mesma função de teste com entradas diferentes com eficiência, facilitando a execução de declarações detalhadas e variadas com menos código.
Ao chamar parametrizar, o primeiro argumento é uma cadeia de caracteres que contém um ou mais nomes de argumento, por exemplo, "test\_input_". O segundo argumento contém uma lista de valores de argumento, por exemplo, ["27", "6+9", "0", "O"]. Os últimos quatro argumentos têm valores padrão e são opcionais.
Você pode encontrar a referência da API pytest em inglês para parametrizar aqui: pytest. Metafunc.parametrize.
Quando usar a parametrização
Dois cenários comuns em que talvez você queira usar a parametrização incluem:
- Ao testar para loops
- Quando vários testes declaram o mesmo comportamento
Vamos examinar cada exemplo primeiro sem usar parametrizar e, em seguida, com ele para mostrar como ele pode melhorar nossos testes.
Loops for
Aqui está um exemplo de uma função de teste com um for loop:
def test_string_is_digit():
items = ["1", "10", "33"]
for item in items:
assert item.isdigit()
Esse teste é problemático porque, se falhar, poderá levar a vários problemas, incluindo:
- Relatórios de teste ambíguos: O relatório de teste não esclarece se apenas um item falhou ou se há várias falhas.
- Exibição de teste único: Todos os itens são vistos como um único teste, o que obscurece o desempenho de item individual.
- Correções incertas: Se uma falha for corrigida, não haverá como saber se todos os problemas são resolvidos sem executar novamente todo o teste.
Vamos modificar o teste para incluir especificamente dois itens que devem falhar:
def test_string_is_digit():
items = ["No", "1", "10", "33", "Yes"]
for item in items:
assert item.isdigit()
A execução do teste mostra apenas uma falha, embora haja dois itens inválidos nessa 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 ===============================
Esse é um ótimo caso de uso para parametrizar. Antes de vermos como atualizar o teste, vamos explorar outra situação comum que não envolve for loops.
Testes que declaram o mesmo comportamento
Um grupo de testes que faz a mesma declaração também é um bom candidato para parametrização. Se o teste anterior fosse reescrito com um teste para cada item, ele permitiria um melhor relatório de falhas, mas seria 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()
Esses testes são melhores no sentido de que uma falha pode ser facilmente associada a uma única entrada. E embora possa parecer incomum ter vários testes semelhantes, é comum ver em conjuntos de testes de produção que tentam ser granulares.
Embora os testes sejam melhores porque eles podem relatar exatamente o que falha (ou passa) eles também vêm com os seguintes problemas:
- O código é repetitivo, o que cria uma carga de manutenção
- Há potencial para erros de digitação e outros erros ao atualizar os testes
- Como eles são repetitivos, os engenheiros podem não incluir todos os casos de uso e entradas
Como usar a parametrização
Agora que você está ciente de alguns dos casos de uso para parametrização, vamos atualizar o teste que usou um loop for que inclui itens com falha.
Para usar parametrização, você deve importar pytest como uma biblioteca e usá-la como decorador na função. Este é o teste atualizado:
import pytest
@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
assert item.isdigit()
Antes de executar os testes, vamos examinar as alterações.
O decorador pytest.mark.parametrize() define dois argumentos. O primeiro argumento é uma cadeia de caracteres chamada "item". Essa cadeia de caracteres é usada como o argumento nomeado para a função de teste que você vê na próxima linha na definição da função de teste. O segundo argumento é a lista de valores de teste.
Relatórios de erros detalhados
Nos bastidores, o pytest considera cada item nessa lista como um teste separado. Isso significa que os testes aprovados e os que falharam são reportados separadamente. Vamos ver o que acontece ao executar o teste com 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 ==========================
Há alguns itens notáveis no relatório de teste. Primeiro, vemos que, de um único teste, pytest está relatando cinco testes no total: três aprovados e dois reprovados. As falhas são relatadas separadamente, incluindo qual é a entrada com falha.
$ 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
É difícil não detectar o valor que causou a falha, pois ele é relatado em muitos lugares.
Usar o sinalizador de saída detalhado
Quando os testes são executados na linha de comando, o relatório de teste é mínimo quando os testes são aprovados. Veja como o teste ficaria após uma atualização para corrigir as falhas:
@pytest.mark.parametrize("item", ["0", "1", "10", "33", "9"])
def test_string_is_digit(item):
assert item.isdigit()
E a execução dos testes produz uma saída 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 ===============================
Ao usar parametrize, aumentar a verbosidade mostra os valores que o pytest executa para cada teste.
$ 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 ===============================
Como usar vários nomes de argumento
Os exemplos que vimos até agora têm apenas um nome de argumento no primeiro argumento. Estamos usando "item" , mas você pode incluir vários nomes de argumento na cadeia de caracteres que especifica o primeiro argumento separado por vírgulas.
Um caso de uso para usar vários nomes de argumento é se você quiser passar um conjunto de valores esperados para testar em relação ao seu valor de entrada. Em seu segundo argumento, cada item em seu conjunto precisa ter uma quantidade de valores igual ao número de nomes de entrada. Por exemplo, se os nomes de entrada forem "test\_input, expected\_value", seu segundo argumento poderá ser semelhante a este: [("3+5", 8), ("3*4", 12)]
Esse teste verifica se um objeto tem um atributo usando a função Python hasattr() . Ele retorna um booliano dependendo se o objeto possui o atributo associado.
>>> hasattr(dict(), "keys")
True
>>> hasattr("string", "append")
False
Como hasattr() requer dois argumentos, podemos usar a parametrização da seguinte maneira:
@pytest.mark.parametrize("item, attribute", [("", "format"), (list(), "append")])
def test_attributes(item, attribute):
assert hasattr(item, attribute)
O decorador de parametrização ainda usa uma única cadeia de caracteres para o primeiro argumento, mas com dois nomes de argumento separados por uma vírgula, que se tornam argumentos para a função de teste. Nesse caso, é item e attribute.
Em seguida, há uma lista de dois pares de itens. Cada um desses pares representa um item e um attribute para testar.
Quando o pytest não pode criar uma representação de cadeia de caracteres dos objetos que estão sendo passados, ele cria um. Você pode ver isso ao executar o teste:
$ 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 ===============================