Parametrização do Pytest
O @pytest.mark.parametrize() decorador permite que uma função de teste seja executada várias vezes com valores de entrada diferentes. Use-a quando a lógica de asserção for a mesma, mas os exemplos variarem. A parametrização pode reduzir a repetição e permite que o pytest relate cada entrada como seu próprio item de teste.
O decorador geralmente usa dois argumentos necessários:
-
argnames: uma cadeia de caracteres separada por vírgulas ou uma lista ou tupla de cadeias de caracteres, com os nomes dos argumentos a serem passados para a função de teste. Os exemplos incluem"item","test_input, expected",["test_input", "expected"]e("test_input", "expected"). -
argvalues: uma iterável de valores ou conjuntos de parâmetros a serem usados para esses argumentos. Com um nome de argumento, cada item é passado como um valor de teste, mesmo que esse valor seja uma tupla. Com vários nomes de argumento, cada item deve fornecer um valor para cada nome de argumento, geralmente como tupla, lista ou chamadapytest.param(...).
O argumento opcional ids permite personalizar a ID de teste que o pytest usa para rotular cada item de teste gerado. Opções mais avançadas, como indirect e scope, são úteis para casos como acessórios parametrizados ou recursos caros. Saiba mais sobre elas nas instruções sobre parametrização e nas nas referências @pytest.mark.parametrize do pytest.
Note
Pytest passa valores de parâmetro para testes as-is; não os copia. Se um teste alterar um valor de parâmetro mutável, como uma lista ou dicionário, essa mutação poderá ser visível em casos gerados posteriormente que recebem o mesmo objeto.
Quando usar a parametrização
Dois cenários comuns em que talvez você queira usar @pytest.mark.parametrize() incluem:
- Quando um teste faz loops sobre entradas e repete a mesma declaração
- Quando várias funções de teste declaram o mesmo comportamento com entradas diferentes
Vamos examinar cada exemplo primeiro sem usar @pytest.mark.parametrize()e, em seguida, com ele para mostrar como ele pode tornar os testes mais fáceis de manter e diagnosticar.
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 padrão pode ser difícil de diagnosticar quando uma entrada falha:
- Comentários incompletos: A primeira asserção com falha interrompe o loop, portanto, os valores posteriores não são avaliados nessa execução.
- Resultado de teste único: O Pytest relata um teste coletado em vez de um caso de teste por entrada.
- Execuções repetidas: Corrigir a primeira falha pode revelar outra falha apenas ao rodar o teste novamente.
Vamos modificar o teste para incluir dois itens que falharão na asserção:
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 a primeira falha, embora haja dois itens inválidos na lista. Os exemplos de comando usam python -m pytest, que funciona quando Python está disponível como python. Se o shell usar python3 no macOS ou Linux ou py no Windows, substitua esse 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 ===============================
A saída de introspecção de declarações do Pytest mostra o método vinculado str.isdigit e o valor da string que provocou a falha. Cabeçalhos de sessão, detalhes da plataforma, endereços de memória e tempos de execução podem variar, de modo que os exemplos omitem ou os reduzam com ... e 0x.... O detalhe relevante é que o loop é interrompido na primeira falha ('No') e nunca é avaliado 'Yes'.
Este é um caso útil para @pytest.mark.parametrize(). Antes de atualizarmos 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 fazem a mesma declaração também são bons candidatos para @pytest.mark.parametrize(). 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 mais granulares porque uma falha pode ser associada a uma única entrada. Embora possa parecer incomum ter vários testes semelhantes, esse padrão é comum em conjuntos de testes de produção que tentam relatar falhas com precisão.
No entanto, os testes repetitivos vêm com os seguintes problemas:
- O código é repetitivo, o que cria uma carga de manutenção.
- Funções semelhantes são fáceis de atualizar inconsistentemente.
- A adição de uma nova entrada requer a cópia de outro corpo de teste, para que casos importantes possam ser ignorados.
A parametrização mantém a vantagem de geração de relatórios de itens de teste distintos sem replicar o corpo de teste.
Como usar a parametrização
Agora que você está ciente de alguns casos de uso de @pytest.mark.parametrize(), vamos atualizar o teste que usava um loop for com itens que falham.
Importe pyteste aplique @pytest.mark.parametrize() diretamente acima da função de teste:
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.
Neste exemplo, o decorador recebe dois argumentos. O primeiro argumento, "item"nomeia o argumento que o pytest passa para a função de teste. O nome deve corresponder ao item parâmetro em test_string_is_digit(item). O segundo argumento é a lista de valores que o pytest usa para os casos de teste gerados.
Relatórios de erros detalhados
Nos bastidores, o pytest coleta um item de teste para cada valor na lista de parâmetros. Isso significa que casos de sucesso e falha são relatados separadamente. Vamos ver o que acontece ao executar o teste:
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 ==========================
Há alguns itens notáveis no relatório de teste. Primeiro, o pytest relata cinco itens coletados de uma única função de teste: três aprovados e dois com falha. As falhas são relatadas separadamente, incluindo o valor de entrada que causou cada falha. O cabeçalho test_string_is_digit[No] mostra o conjunto de parâmetros e o parâmetro local item = 'No' aparece acima da exibição de origem. As linhas de introspecção do método bound (endereços de memória mostrados como 0x...) confirmam qual valor disparou a falha de asserção:
___________________________ test_string_is_digit[No] ___________________________
item = 'No'
[...]
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 aprovados, os relatórios de linha de comando são mínimos por padrão. Veja como o teste ficará após uma atualização para corrigir as falhas (continue editando a mesma test_items.py considerando que import pytest já está presente):
@pytest.mark.parametrize("item", ["0", "1", "10", "33", "9"])
def test_string_is_digit(item):
assert item.isdigit()
A execução dos testes produz uma saída mínima:
python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items
test_items.py ..... [100%]
============================== 5 passed in 0.01s ===============================
Aumentar o detalhamento mostra cada caso de teste gerado e sua ID de parâmetro gerada:
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 ===============================
Como usar vários nomes de argumento
Os exemplos que vimos até agora usam um nome de argumento: "item". Você também pode incluir vários nomes de argumento no primeiro argumento. Ao usar uma cadeia de caracteres, separe os nomes com vírgulas. Você também pode passar os nomes como uma lista ou tupla, como ["item", "attribute"] ou ("item", "attribute").
Um caso de uso para vários nomes de argumento é passar um valor de entrada e o valor esperado para compará-lo. No segundo argumento, cada conjunto de parâmetros precisa de um valor para cada nome de argumento. Por exemplo, se os nomes dos argumentos forem "test_input, expected_value", os valores de argumento poderão ser [("3+5", 8), ("3*4", 12)].
O próximo exemplo usa a função hasattr() de Python, que retorna um valor booliano dependendo se um objeto tem um atributo nomeado:
>>> hasattr(dict(), "keys")
True
>>> hasattr("string", "append")
False
Cada caso de teste precisa de um objeto e um nome de atributo, para que possamos usar @pytest.mark.parametrize() da maneira a seguir. Continue no mesmo arquivo test_items.py para que import pytest já esteja pronto:
@pytest.mark.parametrize("item, attribute", [("", "format"), (list(), "append")])
def test_attributes(item, attribute):
assert hasattr(item, attribute)
O @pytest.mark.parametrize() decorador ainda usa uma única cadeia de caracteres para o primeiro argumento, mas essa cadeia de caracteres agora contém dois nomes de argumento separados por uma vírgula. Os nomes se tornam argumentos para a função de teste. Neste caso, eles são item e attribute. Uma lista ou tupla de nomes funciona da mesma maneira.
O segundo argumento é uma lista com dois conjuntos de parâmetros. Cada conjunto de parâmetros contém um item valor e um attribute valor. Se um conjunto de parâmetros tiver poucos ou muitos valores para os nomes de argumentos, o pytest gerará um erro de coleção antes de executar os casos de teste.
O Pytest cria uma ID de teste para cada conjunto de parâmetros. Para valores simples, como números, cadeias de caracteres, boolianos e None, o pytest usa a representação de cadeia de caracteres usual do valor. Para outros objetos, o pytest volta para um nome com base no nome do argumento e no índice baseado em zero do conjunto de parâmetros. Nessa saída, a cadeia de caracteres vazia contribui com uma parte de ID vazia, de modo que o separador de traços entre as duas IDs ainda esteja visível em [-format]. Como list() não tem uma ID gerada simples, o pytest substitui item1:
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 ===============================
Se as IDs geradas forem difíceis de ler, você poderá passar o argumento opcional ids ou encapsular um conjunto de parâmetros pytest.param(..., id="name") para fornecer um nome mais claro. Por exemplo:
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)
A pytest.param("", "format", id="empty-string-format") chamada fornece o ID completo para o conjunto de parâmetros, substituindo o ID gerado automaticamente -format. Na saída detalhada, os testes gerados são chamados de test_attributes[empty-string-format] e test_attributes[item1-append]. IDs claras facilitam a leitura de mensagens de saída e de falha detalhadas.