DEV Community

Cover image for Testando testes no Python - Parte 2: fixtures parametrizadas
Vitor Buxbaum Orlandi
Vitor Buxbaum Orlandi

Posted on

Testando testes no Python - Parte 2: fixtures parametrizadas

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 (dicts). 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

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

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

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

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):
#     ...
Enter fullscreen mode Exit fullscreen mode

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

Executando os testes, teremos a seguinte saída:

Resultado após fixture com print

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

Se executamos o teste novamente, temos o seguinte resultado:

Resultado após fixture com patch

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

Enter fullscreen mode Exit fullscreen mode

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

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