O popular e poderoso Pytest

Ok. Eu admito. O primeiro post de 2021 será só para cumprir tabela. Afinal, se você já programa em Python, provavelmente já ouviu falar do Pytest. E é pela sua popularidade, mas acima de tudo sua versatilidade, que eu me sinto na obrigação de pelo menos arranhar o assunto.

10 anos de blog, e escrevi quase nada sobre a ferramenta. Shame!

Um baita framework de testes

Já falamos sobre os benefícios de escrever testes:

Então podemos partir do princípio que testes são mais do que essenciais como ferramenta de design e desenvolvimento.

O Pytest se considera um framework que tem por objetivo te permitir escrever testes pequenos de maneira fácil, e ainda assim ser possível escalar o seu uso para testes funcionais complexos.

Livre e de código-aberto (MIT), é hoje uma alternativa mais popular que o próprio unittest (que faz parte da standard lib). Rico em plugins, com ele é possível escrever testes em diferentes formatos, para diferentes fins.

Mão na massa!

Vamos aproveitar o Poetry e iniciar um projeto:

$ poetry new my_calc
$ cd my_calc/

O Pytest já é listado como uma dependência de projetos criados via poetry. Portanto, um poetry install é o suficiente para instalá-lo:

$ poetry install

Ou com o pip:

$ pip install pytest

Vamos ativar o virtualenv com o Poetry, para que assim as instruções daqui para frente sejam as mesmas para os não-usuários da ferramenta:

$ poetry shell

Encontrando os testes

Por padrão, o Pytest espera que o seus arquivos de teste comecem com test_, ou terminem com _test.py. Customizações são possíveis, mas o padrão já nos atende.

Vamos começar o teste pela forma convencional, utilizando unittest.

# tests/test_my_calc.py

from unittest import TestCase


class TestSum(TestCase):

    def test_one_plus_one_is_equal_two(self):
        result = calc(1, "+", 1)
        self.assertEqual(2, result)

Ainda com a unittest, executamos o teste:

$ python -m unittest
E
======================================================================
ERROR: test_one_plus_one_is_equal_two (tests.test_my_calc.TestSum)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/klauslaube/Workspace/my_calc/tests/test_my_calc.py", line 7, in test_one_plus_one_is_equal_two
    result = calc(1, "+", 1)
NameError: name 'calc' is not defined

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)

Agora utilizando o utilitário de linha de comando, pytest:

$ pytest
======================================================================================== test session starts =========================================================================================
platform darwin -- Python 3.8.1, pytest-5.4.3, py-1.10.0, pluggy-0.13.1
rootdir: /Users/klauslaube/Workspace/my_calc
collected 1 item

tests/test_my_calc.py F                                                                                                                                                                        [100%]

============================================================================================== FAILURES ==============================================================================================
_______________________________________________________________________________ TestSum.test_one_plus_one_is_equal_two _______________________________________________________________________________

self = <tests.test_my_calc.TestSum testMethod=test_one_plus_one_is_equal_two>

    def test_one_plus_one_is_equal_two(self):
>       result = calc(1, "+", 1)
E       NameError: name 'calc' is not defined

tests/test_my_calc.py:7: NameError
====================================================================================== short test summary info =======================================================================================
FAILED tests/test_my_calc.py::TestSum::test_one_plus_one_is_equal_two - NameError: name 'calc' is not defined
========================================================================================= 1 failed in 0.10s ==========================================================================================

Como é possível notar, o output é um pouco mais verbose. Mas o ponto alto aqui é que, mesmo que o seu projeto esteja utilizando unittest como biblioteca de testes, o test runner do pytest ainda assim consegue identificar e executá-los.

Fazer o teste passar vai demandar a criação da função calc:

# my_calc/calc.py

def calc(num1, operation, num2):
    return 2

E importar a mesma nos testes:

# tests/test_my_calc.py

from unittest import TestCase
from my_calc.calc import cal

(...)

Executar novamente o pytest deve trazer um resultado positivo dessa vez.

Escrevendo os testes

E se quisermos abrir mão do uso da unittest? É perfeitamente possível. O Pytest (por padrão) busca por classes de teste com prefixo Test, e prefixo test para os métodos:

# tests/test_my_calc.py

from my_calc.calc import calc


class TestSum:

    def test_one_plus_one_is_equal_two(self):
        result = calc(1, "+", 1)
        assert 2 == result

Podemos ir além, e ao invés de métodos utilizar funções. Isso simplificará bastante o boilterplate de código necessário para escrever um teste simples:

# tests/test_my_calc.py

from my_calc.calc import calc


def test_one_plus_one_is_equal_two():
    result = calc(1, "+", 1)
    assert 2 == result

Antes de ir, vamos refatorar a solução:

# my_calc/calc.py

OPERATIONS = {
    "+": lambda x, y: x + y
}


def calc(num1, operation, num2):
    return OPERATIONS[operation](num1, num2)

Fixtures

Para apresentar o conceito de fixtures, vamos deixar a calculadora ainda mais "maluca" (para esconder a falta de criatividade de quem vos escreve). Vamos supor que há a seguinte demanda:

# tests/test_my_calc.py

from dataclasses import dataclass
from my_calc.calc import calc


@dataclass
class FakeWrappedValue:
    value: float = None

(...)

def test_wrapper_object_with_twenty_plus_one_is_equal_to_twenty_one():
    wrapped_value = FakeWrappedValue(value=20)
    result = calc(wrapped_value, "+", 1)
    assert 21 == result

Os testes vão obviamente falhar. Vamos corrigir a função calc:

def calc(num1, operation, num2):
    x = getattr(num1, "value", num1)

    return OPERATIONS[operation](x, num2)

Ainda precisamos garantir que num2 possa também receber um objeto com atributo value. Para fins didáticos, vamos escrever um novo teste:

# tests/test_my_calc.py

(...)

def test_one_plus_wrapper_object_with_twentyis_equal_to_twenty_one():
    wrapped_value = FakeWrappedValue(value=20)
    result = calc(1, "+", wrapped_value)
    assert 21 == resul

O testes falharão novamente. Corrigimos novamente a função:

def calc(num1, operation, num2):
    x = getattr(num1, "value", num1)
    y = getattr(num2, "value", num2)

    return OPERATIONS[operation](x, y)

Pronto! Testes passando novamente, e enfim podemos falar sobre fixtures. Segundo o Real Python, fixtures são:

(...) a way of providing data, test doubles, or state setup to your tests. Fixtures are functions that can return a wide range of values. Each test that depends on a fixture must explicitly accept that fixture as an argument.

Com auxílio do decorator @fixture, somos capazes de escrever uma função que pode ser reaproveitada por diferentes testes. Faremos isso com a instância de FakeWrappedValue:

# tests/test_my_calc.py

import pytest
from dataclasses import dataclass
from my_calc.calc import calc


@dataclass
class FakeWrappedValue:
    value: float = None


@pytest.fixture
def wrapped_value_with_twenty():
    return FakeWrappedValue(value=20)


def test_one_plus_one_is_equal_two():
    result = calc(1, "+", 1)
    assert 2 == result


def test_wrapper_object_with_twenty_plus_one_is_equal_to_twenty_one(wrapped_value_with_twenty):
    result = calc(wrapped_value_with_twenty, "+", 1)
    assert 21 == result


def test_one_plus_wrapper_object_with_twentyis_equal_to_twenty_one(wrapped_value_with_twenty):
    result = calc(1, "+", wrapped_value_with_twenty)
    assert 21 == result

Note a função wrapped_value_with_twenty decorada com pytest.fixture. Outro ponto importante é que as funções que utilizam esse parâmetro agora recebem ele como argumento. Por exemplo:

def test_wrapper_object_with_twenty_plus_one_is_equal_to_twenty_one(wrapped_value_with_twenty):
    result = calc(wrapped_value_with_twenty, "+", 1)
    assert 21 == result

Fixtures podem ajudar a abstrair complexidades que não fazem sentido serem evidenciadas no corpo do teste. Por exemplo (e perceba que esse é um exemplo infinitamente bobo), podemos esconder a "complexidade" de instanciar um objeto com value vazio:

(...)

@pytest.fixture
def empty_wrapped_value():
    return FakeWrappedValue(value=None)


def test_wrapper_object_with_empty_value_plus_one_is_equal_to_one(empty_wrapped_value):
    result = calc(empty_wrapped_value, "+", 1)
    assert 1 == result

Os testes vão quebrar. Ficará ao seu encargo resolver essa aí ;)

Escopos

Juntamente com as fixtures vem o conceito de scopes. É possível encontrar cinco tipos diferentes, e podemos começar pelo já explorado "function".

No exemplo de fixtures da seção anterior, utilizamos esse tipo de escopo. Por padrão, fixtures são definidas com o escopo de função, significando que a fixture será executada por test function.

O escopo class traz um comportamento um pouco diferente. Quando aplicado a uma classe, ele funciona como o setUpClass do unittest. Vamos mudar o arquivo de testes para vislumbrar a diferença entre esses dois tipos de escopo:

# tests/test_my_calc.py

from dataclasses import dataclass

import pytest
from my_calc.calc import calc


@dataclass
class FakeWrappedValue:
    value: float = None


@pytest.fixture
def wrapped_value_with_twenty():
    print("Function fixture")
    return FakeWrappedValue(value=20)


@pytest.fixture(scope="class")
def x_and_y(request):
    print("Class fixture")
    request.cls.x = 1
    request.cls.y = 1


@pytest.mark.usefixtures("x_and_y")
class TestAddingThingsUp:

    def test_one_plus_one_is_equal_two(self):
        result = calc(self.x, "+", self.y)
        assert 2 == result

    def test_wrapper_object_with_twenty_plus_one_is_equal_to_twenty_one(self, wrapped_value_with_twenty):
        result = calc(wrapped_value_with_twenty, "+", self.y)
        assert 21 == result

    def test_one_plus_wrapper_object_with_twentyis_equal_to_twenty_one(self, wrapped_value_with_twenty):
        result = calc(self.x, "+", wrapped_value_with_twenty)
        assert 21 == result

Alguns pontos importantes não podem passar em branco:

  • Note o uso do argumento scope="class" decorando a assinatura da fixture x_and_y
  • pytest.mark.usefixtures é quem ativa o uso da fixture na classe TestAddingThingsUp
  • Para visualizar o resultado dos print, é preciso executar o pytest com o parâmetro -s
  • Note que esse é outro exemplo bobo (:

Já que temos o comportamento do setUp, conseguimos ter o tearDown? Sim! Com um yield dentro da fixture, você consegue "agendar" código a ser executado como parte do off-load da mesma:

@pytest.fixture(scope="class")
def x_and_y(request):
    print("Class fixture")
    request.cls.x = 1
    request.cls.y = 1
    yield
    print("Tearing things down!!!")

Uma vez que fica claro o funcionamento dos scopes, os três restantes são quase intuitivos:

  • Module: Executa a fixture por módulo;
  • Package: Executa a fixture por pacote;
  • Session: Cada vez que você executa o Pytest.

Para fins didáticos, vamos utilizar outro argumento na construção das fixtures que exemplificarão como esses escopos funcionam. Estamos falando do autouse, e sem nenhuma surpresa, ele faz a fixture ser executada automaticamente:

@pytest.fixture(scope="module", autouse=True)
def saying_hello_per_module():
    print("Module fixture")


@pytest.fixture(scope="package", autouse=True)
def saying_hello_per_package():
    print("Package fixture")


@pytest.fixture(scope="session", autouse=True)
def saying_hello_per_session():
    print("Session fixture")

Agora com o pytest -s, podemos ver na prática o que acontece:

$ pytest -s

(...)

tests/test_my_calc.py Session fixture
Package fixture
Module fixture
Class fixture
.Function fixture
.Function fixture
.Tearing things down!!!

Baterias inclusas + plugins == sucesso!

Há muitos outros conceitos por trás do Pytest. Do arquivo conftest.py ao @parametrize, você vai encontrar funcionalidades incríveis que aumentarão a sua produtividade escrevendo testes.

E quando adicionamos os plugins do Pytest à equação, tudo fica mais divertido. Para começar, se você está escrevendo testes em Django e quer utilizar Pytest, o pytest-django é mais que obrigatório. Ele resolverá tudo o que precisa-se resolver (banco de dados, sessões, transações, etc) out of the box.

Outros grande plugins são:

A lista é imensa, e garanto que há um plugin por aí pronto para resolver os seus problemas.

Considerações finais

O Pytest faz parte daquele seleto grupo de bibliotecas que instalamos imediatamente após iniciar um projeto. É rico, poderoso, flexível, e faz do desenvolvimento orientado a testes uma experiência muito mais satisfatória, do que com a opção utilizando a standard lib.

Atualmente na Son of a Tailor, estamos aumentando o nosso coverage e gradativamente adotando as baterias inclusas do Pytest. Todos os testes, no entanto, utilizam a django.tests.TestCase. Mesmo assim, fomos capazes de trocar o test runner para o Pytest, e tirar proveito de seus plugins.

Sem nenhum problema.

Até a próxima!

Referências