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 3º 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 mostrei como foi construída a primeira solução para testes de mutações customizadas, utilizando uma fixture autouse
parametrizada com as mutações.
Mas havia uma limitação: nesse modelo a pessoa estudante precisa construir todos os seus testes dentro de uma função específica. É suficiente se queremos exercitar a criação de bons assert
s, mas limitante quando pensamos em fazer testes mais elaborados e melhor organizados.
Trocando de "função" para "módulo"
Logo de início, a ideia era que precisávamos parar de parametrizar uma função de testes (o que a fixture faz no exemplo do artigo anterior), e fazer a parametrização para um arquivo (módulo) inteiro. Ou seja, executar todo um arquivo de testes da pessoa estudante para cada mutação.
Assim a pessoa estudante poderia fazer, por exemplo, 5 funções de teste em um arquivo e, quando uma mutação for aplicada, pelo menos 1 das 5 funções de teste deve falhar. Se todos os testes passarem para uma das mutações, consideramos que ainda não foi atingida a qualidade que esperamos.
A missão era minha, e eu não fazia ideia de como implementar. 😅 Novamente entramos naquele ponto que não havia nada pronto ou óbvio para usarmos, e precisei partir para pesquisa e experimentação.
"Como rodar um arquivo de teste no Pytest?"
Essa provavelmente foi a primeira pesquisa que fiz no Google, esperando que surgisse alguma resposta para o que precisávamos. E não, obviamente não apareceu.
Ou será que apareceu? 👀
As respostas para essa pesquisa são de conteúdos para iniciantes, e elas citam comandos básicos da CLI do Pytest:
python -m pytest tests/test_file.py
E meu primeiro pensamento foi:
"Eu já sei disso! Me mostre algo que eu não sei! 😫"
E logo em seguida:
"Calma, realmente é bem simples solicitar ao Pytest a execução de um arquivo completo de testes. Se eu conseguir fazer isso dentro de uma execução do Pytest que já está em curso, consigo usar a parametrização! Será que é possível? 🤔"
Resposta: Sim, é possível! 🎉
O Pytest é um módulo do Python como qualquer outro. Temos o costume de acioná-lo pela CLI, mas essa é apenas uma interface para um código "chamável" do Python. Na própria documentação do Pytest há a indicação de como executá-lo sem a CLI:
import pytest
retcode = pytest.main()
Um retcode
igual a 0
(zero) significa que os testes passaram, falharam caso contrário.
Executando o Pytest dentro do Pytest
Não parece uma ideia muito agradável, e até a documentação da ferramenta faz um alerta sobre isso:
[...] fazer multiplas chamadas a
pytest.main()
a partir do mesmo processo (para re-executar testes, por exemplo) não é recomendado.
Parece que escreveram isso especialmente pra mim! 😂 Mas sem ousadia nunca venceremos obstáculos, não é mesmo?
Brincadeiras a parte, seguimos entendendo que esse é um uso controlado e (até o momento) com complexidade moderada.
Ajustando o exemplo anterior
No exemplo do artigo anterior toda a configuração de mutações era feita nos arquivos tests/sorter_mutations.py
(definição das mutações) e tests/conftest.py
(parametrização e patch
das mutações), mas este 2º não será mais necessário.
Como nosso objetivo é somente ter um PASSED
🟢 caso a chamada do pytest.main()
falhe para todas as mutações, podemos abandonar a complexidade da fixture parametrizada com XFAIL
s e seguir com uma opção mais direta: uma função de teste parametrizada que fará a chamada ao pytest.main()
.
Isolando essa nova função de teste em um arquivo dedicado, teremos o seguinte:
Que traduzido para código, fica assim:
from unittest.mock import patch
from tests import sorter_mutations
import pytest
mutated_functions = [
sorter_mutations.no_exception_mutation,
sorter_mutations.slice_input_mutation,
]
@pytest.mark.parametrize("mutation", mutated_functions)
def test_mutations_for_test_module(mutation):
with patch("tests.test_sorter.sort_this_by", mutation):
retcode = pytest.main(["tests/test_sorter.py"])
assert retcode != 0, "Mutação deveria falhar"
Dado que não melhoramos o último exemplo de "teste da pessoa estudante", ao executar python -m pytest
teremos a saída semelhante a seguinte:
Os 2 PASSED
🟢 indicados na imagem são:
- A própria função da pessoa estudante sem a mutação aplicada
- O teste com a mutação
slice_input_mutation
, que falhou como esperado na chamadapytest.main(["tests/test_sorter.py"])
E, como imaginávamos, a chamada pytest.main
com a mutação no_exception_mutation
retornou 0
e por isso nosso assert
acusou um problema: "Mutação deveria falhar" (mas não falhou).
Melhorando a solução
Particularmente fiquei muito orgulhoso com essa solução! 💜 Mas há melhorias importante antes de chegarmos na versão disponibilizada para as turmas.
Ocultar logs excessivos
Quando executamos o Pytest internamente, ele se comporta de fato como uma nova execução, gerando todos os logs como esperado. Em nosso exemplo o terminal ficou poluído com logs equivalentes a 3 rodadas do Pytest, e isso não é bom para a experiência, além de confundir a pessoa estudante.
O Pytest possui algumas opções para reduzir a verbosidade de logs, mas sentimos que seria melhor ocultar completamente a saída das chamadas internas. Com 4 linhas podemos fazer a saída de um comando ser redirecionada para a "lixeira" /dev/null
:
+import contextlib
+import os
from unittest.mock import patch
from tests import sorter_mutations
import pytest
mutated_functions = [
sorter_mutations.no_exception_mutation,
sorter_mutations.slice_input_mutation,
]
@pytest.mark.parametrize("mutation", mutated_functions)
def test_mutations_for_test_module(mutation):
with patch("tests.test_sorter.sort_this_by", mutation):
+ with open(os.devnull, "w") as student_output:
+ with contextlib.redirect_stdout(student_output):
retcode = pytest.main(["tests/test_sorter.py"])
assert retcode != 0, "Mutação deveria falhar"
Mutações devem falhar and
Original deve passar
Tivemos um desafio semelhante na solução anterior usando a fixture: além de garantir que as mutações devem falhar, devemos garantir que o teste "normal" ou "original" deve passar.
Aqui novamente a biblioteca pytest-dependency
e o módulo inspect
entram como grandes amigos! Coletamos todas as funções de teste no arquivo tests/test_sorter.py
e as adicionamos como dependências para o novo teste test_mutations_for_test_module
.
Uma função que coleta todos os nodeid's de testes funcionais para um arquivo pode ser escrita assim:
import inspect
from pathlib import Path
def get_test_functions_from(student_test_module):
test_file_path = Path(student_test_module.__file__).relative_to(Path.cwd())
return [
test_file_path + "::" + member[0]
for member in inspect.getmembers(student_test_module)
if inspect.isfunction(member[1]) and member[0].startswith("test_")
]
Nodeid é o identificador de um teste no Pytest, exigido pela pytest-dependency
. É o mesmo texto que aparece antes de PASSED
ou FAILED
quando executamos a CLI com -vv
. Exemplo:
Obs: Se a pessoa estudante utilizar parametrização em seus testes, essa função de coleta de nodeid's não será suficiente para a
pytest-dependency
funcionar corretamente. Por isso alteramos nosso fork novamente, garantindo a interpretação correta das dependências.
Nosso assert
, nossas regras
Já que foi necessário criar um assert
para garantir que o teste falhou para uma mutação, podemos aproveitar para criar uma mensagem de erro bem específica e didática. Porque só informar "Mutação deveria falhar"
se podemos chegar em algo como "Seus testes em '{arquivo}' deveriam falhar com a mutação '{mutação}' definida em '{arquivo de mutações}', mas passaram. Confira essa dica: {dica específica da mutação}"
?
Poderíamos ter um map/dicionário para definir a dica de cada mutação, mas escolhemos uma forma mais "preguiçosa": a docstring da própria mutação. Um exemplo seria:
# ...
@pytest.mark.parametrize("mutation", mutated_functions)
def test_mutations_for_test_module(mutation):
with patch("tests.test_sorter.sort_this_by", mutation):
with open(os.devnull, "w") as student_output:
with contextlib.redirect_stdout(student_output):
retcode = pytest.main(["tests/test_sorter.py"])
assert retcode != 0, (
"Seus testes em 'tests/test_sorter.py' deveriam falhar com a mutação,"
f" mas passaram. Confira essa dica: {mutation.__doc__}"
)
Primeiro a bagunça, depois a arrumação
Ufa... Muito código (e nem mostrei tudo) mas chegamos lá! 🎉
E nesse momento vem a dor de olhar todo aquele código criativo, mas ainda bagunçado. Só de olhar o "resultado final" já quero fugir de manutenções futuras, ainda mais pensando em múltiplos projetos e múltiplas turmas.
Por isso, aquela boa e velha refatoração sempre cai bem. Criando uma classe e algumas funções para isolar responsabilidades, temos um resultado mais palatável:
from unittest.mock import patch
import pytest
# Aproveitei nosso fork da pytest-dependency para
# posicionar as funções de apoio
from pytest_dependency import (
assert_fails_with_mutation,
get_skip_markers,
get_test_assessment_configs,
run_pytest_quietly,
)
from src.sorter import sort_this_by
from tests import test_sorter, sorter_mutations
# TA_CFG será um objeto para guardar dados que queremos obter facilmente
TA_CFG = get_test_assessment_configs(
target_asset=sort_this_by,
mutations_module=sorter_mutations,
student_test_module=test_sorter,
)
# Com essa configuração garantimos que só testaremos as mutações
# caso a pessoa estudante tenha feito testes que passam.
pytestmark = get_skip_markers(TA_CFG)
@pytest.mark.parametrize("mutation", TA_CFG.MUTATED_FUNCTIONS)
def test_mutations_for_test_module(mutation):
with patch(TA_CFG.PATCH_TARGET, mutation):
return_code = run_pytest_quietly([TA_CFG.STUDENT_TEST_FILE_PATH])
assert_fails_with_mutation(mutation, return_code, TA_CFG)
Muitas alegrias, até que...
Esse formato funcionou muito bem para nossos projetos sobre POO, Raspagem de Dados, Algoritmos e Estruturas de Dados, Flask... até que começamos a ensinar Django. 😅
Django é um framework incrível, mas ele abstrai muitos detalhes de implementação. Isso acontece principalmente em relação a comunicação com o banco de dados, e é mais "agravante" quando testes acessam o banco.
Tentamos bastante até entender que não seria viável, com a solução descrita nesse artigo, testar testes que precisavam acessar o banco de dados de uma aplicação Django. Precisávamos voltar ao passo da pesquisa, com boas doses de ousadia, criatividade e paciência.
Vou ser sincero aqui: ainda não tenho a resposta final! Fizemos uma prova de conceito com hooks do Pytest que parece promissora, mas ainda não chegamos lá.
Por isso, o próximo artigo pode demorar um pouco a sair, mas já estou ansioso para esse momento!
Top comments (0)