Testando testes no Python - Parte 2: fixtures parametrizadas
Tags: python, braziliandevs, testing
Published: 2023-10-09 18:34:31

> 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 2º 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, 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](https://github.com/barraponto), 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.

![Relação do arquivo com função original, e arquivo de testes da pessoa estudante](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5ctcbrl87sl5t692x5iv.png)

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.

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

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

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

```python
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_](https://docs.pytest.org/fixtures.html) 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](https://www.youtube.com/watch?v=sidi9Z_IkLU) 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_](https://docs.pytest.org/en/7.3.x/how-to/parametrize.html#pytest-mark-parametrize-parametrizing-test-functions).

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

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

![Resultado após fixture com print](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7e1pcxt44v34iuihf76q.png)

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

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

![Resultado após fixture com patch](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/db1xuqkbwo12rrsq6pg1.png)

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

```diff
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](https://docs.pytest.org/en/7.1.x/how-to/skipping.html) junto com os poderes do plugin [`pytest-dependency`](https://pytest-dependency.readthedocs.io/): 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](https://github.com/betrybe/pytest-dependency) com a melhoria (_Capi até [abriu um PR](https://github.com/RKrahl/pytest-dependency/pull/67), 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:

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

1. O módulo com todas as mutações;
2. A função original a ser testada;
3. 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`](https://docs.python.org/3/library/inspect.html) 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! 👋