DEV Community

Cover image for Testando testes no Python - Parte 3: Pytest dentro do Pytest
Vitor Buxbaum Orlandi
Vitor Buxbaum Orlandi

Posted on • Updated on

Testando testes no Python - Parte 3: Pytest dentro do Pytest

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 asserts, 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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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 XFAILs 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:

Nova estratégia para aplicar mutações

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"

Enter fullscreen mode Exit fullscreen mode

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:

Resultado do teste com pytest chamado internamente

Os 2 PASSED 🟢 indicados na imagem são:

  1. A própria função da pessoa estudante sem a mutação aplicada
  2. O teste com a mutação slice_input_mutation, que falhou como esperado na chamada pytest.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"

Enter fullscreen mode Exit fullscreen mode

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_")
    ]
Enter fullscreen mode Exit fullscreen mode

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:

Exemplo de nodeid

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__}"
    )
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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)