Pessoas geralmente começam engatinhando, depois andam, evoluem para corrida, e algumas fazem coisas mais estranhas, como Parkour.
Pessoas Devs geralmente começam codando, depois testam, evoluem para o TDD, e algumas fazem coisas mais estranhas, como testes de testes.
Boas vindas! 🤩 Esse é o 2º artigo de uma curta série, contando um pouco mais sobre "testes de testes". 🧪
Vou discutir as motivações, alternativas, e detalhar as formas que fazemos em projetos de Python na Trybe.
Retomando de onde paramos
No artigo anterior, contei sobre o motivo de fazermos testes de testes em projetos dos cursos da Trybe, e resumi 3 principais alternativas e seus respectivos prós e contras.
Para o nosso cenário entendemos que o ideal (considerando todas as variáveis e limitações) era termos o teste de mutações customizadas.
Então chegou hora da verdade: a ideia era bonita mas precisava funcionar. E aí é que entram as habilidades do Capi, que ficou responsável pela implementação.
Na época, Capi era a pessoa mais sênior do time e não havia ninguém melhor para ser o pai (ou 'py'?) da criação. Ele gentilmente me concedeu a honra de escrever sobre a solução, e ainda revisar antes da publicação. 💜
Então aqui vou explicar como a solução funciona mas, se você quer saber melhor como foi o processo criativo para chegar lá, já sabe quem procurar! 😉
Entenda o caso: testar ordenação por critérios
A primeira versão surgiu em um contexto que, durante um dos projetos da nossa formação, a pessoa estudante precisava implementar testes para uma função chamada sort_this_by
que ordena uma lista de dicionários (dict
s). Logo, precisávamos testar os testes de cada estudante contra mutações dessa função de ordenação.
Os dicionários possuíam o mesmo conjunto de 6 chaves (como o exemplo a seguir) mas o critério de ordenação poderia ser apenas 3 dessas chaves. Além disso: ao selecionar a chave "min_score" como critério, a ordenação deveria ser crescente (e decrescente caso contrário); e uma exceção específica era levantada caso o parâmetro criteria
não fosse um dos 3 valores válidos.
{
"name": "Python",
"age": 34, # ordenação descrescente
"max_score": 100, # ordenação descrescente
"min_score": 80, # ordenação crescente
"hobby": "coding",
"short_name": "py",
}
Os detalhes da função sort_this_by
não vão fazer diferença na explicação, mas é importante entender que havia um pouco de complexidade e casos de testes a serem cobertos. Para termos um código funcional sem complicar demais, considere a seguinte implementação no arquivo src/sorter.py
:
def sort_this_by(unordered_list, criteria):
return sorted(unordered_list, key=lambda x: x[criteria])
E um possível teste implementado por uma pessoa estudante em tests/test_sorter.py
:
from src.sorter import sort_this_by
def test_sort_this_by():
elements = [
{"name": "John", "age": 23},
{"name": "Mary", "age": 19},
{"name": "Jane", "age": 20},
]
criteria = "age"
expect = [
{"name": "Mary", "age": 19},
{"name": "Jane", "age": 20},
{"name": "John", "age": 23},
]
res = sort_this_by(elements, criteria)
assert res == expect
Criando as mutações
Como precisamos executar os testes da pessoa estudante "trocando" a função sort_this_by
por uma mutação, precisamos criar essas mutações. Posso criar quantas e quais mutações desejar, mas isso sempre vai depender da complexidade da função original. Se a função original possui 5 code-paths (ifs, raises, excepts, returns, etc), provavelmente terei aproximadamente 5 mutações.
Penso em algumas mutações que forçariam a pessoa a exercitar certas habilidades de testes, e crio elas em um arquivo dedicado a isso. Em muitos casos, inclusive aproveitamos a função original para não precisar reescrever a lógica completa:
from src.sorter import sort_this_by
def no_exception_mutation(elements, criteria):
"""Mutação que nunca levanta exceção"""
try:
return sort_this_by(elements, criteria)
except Exception:
return elements
def slice_input_mutation(elements, criteria):
"""Mutação que altera lista inicial"""
return sort_this_by(elements[1:], criteria)
# def other_mutation(elements, criteria):
# ...
Agora, preciso que o teste test_sort_this_by
seja executado uma vez para cada uma dessas mutações.
Parametrizando o teste com uso de uma fixture
Fixtures são a forma que o Pytest oferece para evitarmos duplicação de código em nossos testes, principalmente pensando em setup e teardown de recursos. Além da documentação, essa live do @dunossauro também é uma ótima opção para aprofundar no tema 😉
O Pytest já oferece uma forma de executar uma mesma função de teste para múltiplos valores: o marcador parametrize.
Entretanto, utilizar o marcador em nosso caso não é boa alternativa por 2 motivos. O 1º deles é simples de explicar: seria necessário inserir o marcador no arquivo da pessoa estudante. Isso aumenta a chance de erros, além de expor detalhes da nossa implementação com a qual o cliente (estudante) não deveria se preocupar. O 2º motivo só fará sentido em breve, então vou me permitir adiar essa explicação. 😅
Felizmente o Pytest também nos permite criar fixtures parametrizadas, que vão ser uma "mão na roda" nesse caso. Se inserirmos essa fixture no conftest.py
, e ainda marcá-la como autouse
para que ela seja aplicada automaticamente para todos os testes em seu contexto, conseguimos o que não tínhamos com o marcador parametrize. Então vamos à implementação do nosso tests/conftest.py
:
import pytest
from tests import sorter_mutations
mutated_functions = (
sorter_mutations.no_exception_mutation,
sorter_mutations.slice_input_mutation,
)
@pytest.fixture(autouse=True, params=mutated_functions)
def validate_mutations(request):
print(f"\n>>> Parâmetro mutação: {request.param}", end=' ')
Executando os testes, teremos a seguinte saída:
Uhuuul! ✅ Tudo passou! 🌟 Mas espera... 🤔 Como são mutações, os testes deveriam falhar não é mesmo?
Na verdade, não estamos fazendo nada com as mutações além de um simples print
. Precisamos alterar nossa fixture para que ela faça a "mágica" que precisamos.
A "mágica"
🪄 A "mágica" é um simples patch
! Assim como, durante testes de aplicações, podemos fazer o patch
de uma dependência, podemos fazer isso dentro do próprio teste. Genial, Capi! 💜
A alteração no tests/conftest.py
então será:
import pytest
+from unittest.mock import patch
from tests import sorter_mutations
mutated_functions = (
sorter_mutations.no_exception_mutation,
sorter_mutations.slice_input_mutation,
)
@pytest.fixture(autouse=True, params=mutated_functions)
def validate_mutations(request):
- print(f"\n>>> Parâmetro mutação: {request.param}", end=' ')
+ with patch("tests.test_sorter.sort_this_by", request.param):
+ yield
Se executamos o teste novamente, temos o seguinte resultado:
Ou seja: o teste da "pessoa estudante" foi resistente (falhou) em uma mutação, mas não na outra. Para que o teste possa ser considerado robusto, ambas as mutações deveriam causar FAIL
❌.
Ainda temos ajustes a fazer, mas aqui chegamos na nossa grande vitória: temos nosso próprio teste de mutações customizadas! 🎉
E agora posso retomar ao 2º motivo que faria o marcador parametrize ser ruim para nosso caso: já imaginou como seria encaixar a funcionalidade do
patch
lá dentro? 😬
Ajustes para tudo rodar bem
Como estamos falando de um teste que será usado para pontuar estudantes de forma automatizada, precisamos de 2 ajustes principais:
1️⃣ O teste da pessoa estudante precisa passar com a função original;
2️⃣ Devemos transformar o conjunto de todos FAIL
❌ das mutações em um PASS
✅, para controlar se a pessoa estudante deve receber a pontuação ou não.
O ponto 1️⃣ é resolvido adicionando a função sort_this_by
aos parâmetros da fixture validate_mutations
(que poderia mudar de nome):
import pytest
from unittest.mock import patch
+from src.sorter import sort_this_by
from tests import sorter_mutations
mutated_functions = (
sorter_mutations.no_exception_mutation,
sorter_mutations.slice_input_mutation,
+ sort_this_by
)
@pytest.fixture(autouse=True, params=mutated_functions)
def validate_mutations(request):
with patch("tests.test_sorter.sort_this_by", request.param):
yield
Para o item 2️⃣ usamos o marcador XFAIL
🟨 do Pytest junto com os poderes do plugin pytest-dependency
: marcamos os testes das mutações como XFAIL
🟨 e como dependência para o teste da função original. Ou seja: o teste da função original só será executado se os testes de mutação falharem.
Mas aqui surgiu um empecilho: o plugin pytest-dependency
não entende XFAIL
🟨 como um resultado de sucesso. Então foi necessário criar um Fork do repositório com a melhoria (Capi até abriu um PR, mas até hoje não foi revisado pelo time do pytest-dependency
).
Para usar essa opção, basta adicionar a linha accept_xfail = true
no arquivo de configurações do Pytest (pytest.ini
ou pyproject.toml
),
Assim, nosso tests/conftest.py
fica semelhante ao seguinte:
import pytest
from unittest.mock import patch
from src.sorter import sort_this_by
from tests import sorter_mutations
mutated_functions = (
pytest.param(
sorter_mutations.no_exception_mutation,
marks=[pytest.mark.xfail(strict=True), pytest.mark.dependency],
),
pytest.param(
sorter_mutations.slice_input_mutation,
marks=[pytest.mark.xfail(strict=True), pytest.mark.dependency],
),
pytest.param(
sort_this_by,
marks=pytest.mark.dependency(
depends=[
"test_sort_this_by[no_exception_mutation]",
"test_sort_this_by[slice_input_mutation]",
]
),
),
)
@pytest.fixture(autouse=True, params=mutated_functions)
def validate_mutations(request):
with patch("tests.test_sorter.sort_this_by", request.param):
yield
Refatorando a solução final
Sim, eu sei. Muito complexo para ler, e ainda mais para dar manutenção. Imagine se eu quiser criar mais uma função de mutação! Ou até mesmo se quiser trocar o nome de uma mutação, ou da função original... E lembre-se que a intenção é usar isso em vários projetos diferentes.
Por isso investimos um pouco mais de energia em uma refatoração, extraindo toda a construção da mutated_functions
para uma função que recebe apenas 3 parâmetros:
- O módulo com todas as mutações;
- A função original a ser testada;
- A função de teste da pessoa estudante.
Não vale a pena detalhar essa nova função aqui porque estaria fugindo do foco, mas preciso dizer que o módulo inspect
foi uma grande ajuda!
Próximos passos
Esse modelo de teste de teste funcionou bem por muito tempo, em diversos projetos! E além de funções, funciona muito bem para mutações de classes.
Mas de todas as limitações, uma precisava ser resolvida com mais urgência: não queremos limitar que estudantes façam todos os testes de uma função (e principalmente de uma classe) em apenas 1 função de teste.
Precisávamos de uma forma para dar essa autonomia para a pessoa estudante, estimulando-a a escrever testes mais organizados com as ferramentas que desejar. No próximo artigo vou te contar como fizemos isso, mas adoraria saber: como você abordaria esse novo problema?
Nos vemos lemos em breve! 👋
Top comments (0)