Testando testes no Python - Parte 3: Pytest dentro do Pytest
Tags: python, braziliandevs, testing
Published: 2023-10-13 21:43:20

> Pessoas geralmente começam engatinhando, depois andam, evoluem para corrida, e algumas fazem coisas mais estranhas, como [Parkour](https://www.google.com/search?q=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](https://dev.to/vbuxbaum/series/24810), 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](https://www.betrybe.com/).


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

```bash
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](https://docs.pytest.org/en/7.1.x/how-to/usage.html#calling-pytest-from-python-code):

```python
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](https://docs.pytest.org/en/7.1.x/how-to/usage.html#calling-pytest-from-python-code:~:text=Calling%20pytest.main,is%20not%20recommended.):

> [...] 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:

![Nova estratégia para aplicar mutações](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wo9frfibfbx5wlqzrojh.png)

Que traduzido para código, fica assim:

```python
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"](https://dev.to/vbuxbaum/testando-testes-no-python-parte-2-fixtures-parametrizadas-270g#:~:text=E%20um%20poss%C3%ADvel%20teste%20implementado%20por%20uma%20pessoa%20estudante%20em%20tests/test_sorter.py%3A), ao executar `python -m pytest` teremos a saída semelhante a seguinte:

![Resultado do teste com pytest chamado internamente](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fclrwr36loqfwnm5whim.png)

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`](https://pt.stackoverflow.com/questions/118603/qual-%C3%A9-a-finalidade-do-caminho-dev-null-em-alguns-comandos):

```diff
+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: 

```python
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:

![Exemplo de nodeid](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xqixau04pmhtkl8ct73v.png)

> 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](https://github.com/betrybe/pytest-dependency/commit/a5b5a21255ddb62727fafb5f9caa923e3a8cc985), 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:

```python
# ...

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

```python
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](https://www.djangoproject.com/) é 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](https://docs.pytest.org/en/7.1.x/how-to/writing_hook_functions.html) 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!