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!
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.
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
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.
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)
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í ;)
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:
scope="class"
decorando a assinatura da fixture x_and_y
pytest.mark.usefixtures
é quem ativa o uso da fixture na classe TestAddingThingsUp
print
, é preciso executar o pytest
com o parâmetro -s
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:
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!!!
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.
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!