Afinal pra quê testes?
Desenvolver software nunca foi fácil, não basta saber programar, tem que dominar um conjunto de conhecimentos e ferramentas para que se tenha um resultado eficaz em seu papel ao passo que outros programadores consigam atuar nesse projeto.
Uma das formas de garantir e comprovar o funcionasmento de um software é por meio de testes, e uma forma de aprimorar a qualidade daquilo que está sendo desenvolvido é por meio de testes automatizados, pois além de ajudarem a entender as funcionalidades, estes testes podem servir como evidência de funcionamento, bem como garantia de funcionamento e de retrocompatibilidade , além de controle de qualidade.
O python , da forma mais comun que conhecemos (CPython) traz no seu kit de ferramentas uma blibioteca chamada unittest, a qual nos permite escrever testes para os nossos projetos python. Mas hoje vou falar um pouco sobre pytest uma blibioteca popular e amigavél do python para testes automatizados, bem como alguns truques e macetes que aprendi recentemente usando fixtures , um recurso do pytest que nos permie modularizar melhor nossos testes.
Tá mais porquê não o unittest?
A blibioteca padrão unittest é eficaz naquilo que ela propõe em seu nome, permitir o desenvolvimento de testes unitários, seu código é simples. Se está desenvolvendo algo simples o qual testes unitários são mais que suficiente para a validação do seu software, não vejo porquê usar outra opção.
Todavia, mesmo sendo adepto de um anbiente mais simplificado existe um conjunto de features no pytest que são uma mão na roda, a primeira delas é o discovery de testes que o pytest consegue fazer de forma mais eficaz, o segundo é o uso de fixtures, um recurso bem interessante que na minha interpretação servem para nos auxiliar a fazer algumas coisas:
- Manipular dados compartilhados entre testes (Meio que uma forma de shared mocks)
- Permitir gerenciar side-effects em etapas de testes.
Por isso vamos usar o pytest, neste caso vamos usar um pouco da magia de fixtures para simular coisas interessantes
O que vamos testar
Como a idéia é apresentar alguns cenários diferentes do que podemos fazer com o pytest a proposta é que consigamos testar os seguintes casos:
- Uma implementação da blibioteca python-holidays criando um calendário customuizado
- Criar controle de estado e teardowns para testes com sqlite
Fixtures
Decidio os cenãrios que iremos aprender a testar , vou precisar explicar agora a ferramenta principal provida pelo pytest que iremos usar para atingir nossos objetios, as fixtures fixtures.
Parafraseando a documentação do pytest, fixtures tratam-se de uma forma de prover contextos definidos, reutilizaveis e consistentes entre os testes, podendo ser informações de anbiente , como credenciais de banco de dados ou conteúdo como expor datasets entre testes
Para definir uma fixture , o pytest pede que escreva uma função que esteja “decorada”, Como explico neste post por @pytest.fixture
, a qual irá disponibilizar o retorno dessa função como argumento para nossos testes poderem consumir ela de forma implicita.
Tanbém podemos usar de definição de escopo (argumento scope
) e yelds
para definir meios de “destruição” de uma fixture, assim podemos por exemplo usar uma fixture para criar a conexão com um banco de dados e no final do contexto, podemos encerrar essa conexão e realizar um drop das tabelas criadaas, uma ferramenta muito poderosa se usada com cuidado.
por fim fixtures podem consumir e serem consumidas por outros fixtures, muito interessante para simular side effects em testes
Cenário um: Uma fixture ordinária
Imagine que estamos desenvolvendo uma aplicação de estimativa de entregas para empresas do nordeste, essa ferramenta gera o cáculo de estimativa usando dias úteis, porém como uma empresa nordestina, ela considera os festejos juninos como feriado afinal as estradas ficam congestionadas e há a tradição de se dar folga aos funcionários por respeito às tradições locais. Para isso iremos desenvolver em python essa funcionalidade por meio da blibioteca python-holidays, que nos permite gerar calendários de feriados compativeis com o module datetime nativo do python, assim este serviço de estimativa será implementado em uma cloud function para economizar (afinal é algo que faz sentido ser processado por demanda)
Todavia queremos dar a liberdade no futuro de se definir o calendário a ser utilizazdo por meio de um outro serviço, para que fique dinâmico, permitindo ajustes conforme muda por exemplo a legislação e o calendário de feriados, assim, para nossos testes unitários não podemos então definir hardcoded o calendário, mas podemos usar fixture para definir um calendário global para nossa suite de testes.
O projeto se encontra aqui
Sem mais delongas vamos ao código de nossa funcionalidade
# /delivery/estimative_delivery_service.py
from datetime import date, timedelta
from holidays import HolidayBase
class EstimativeDeliveryService:
def __init__(self, holidays: HolidayBase):
self.holidays = holidays
def estimate_delivery(self, start_date: date, min: int, max: int) -> list[date]:
result = list()
count = max
day = start_date
while count > 0:
if day.weekday() < 5 and day not in self.holidays:
count -= 1
if count <= max - min:
result.append(day)
day = day + timedelta(days=1)
return result
Esse nosso service recebe um dicionário de HolidayBase, e implementa o método de cálculo de data usando um loop while, verificamos se a data atual é um final de semana ou feriado, se não for ele subtrai um do contador, se o contador estiver dentro do range de minimo e máximo ele adiciona a data atual na lisrta de resultados e por fim acrescenta um dia na data atual
Com isso feito vamos definir nossa fixture
# /tests/delivery/conftest.py
from datetime import date
import pytest
from holidays import country_holidays
from holidays.countries import BR
from holidays.holiday_base import HolidayBase
class CustonNordesteCalendar(BR):
def _populate(self, year):
super()._populate(year)
self[date(year, 6, 24)] = "Véspera de São João"
self[date(year, 6, 25)] = "São João"
@pytest.fixture(scope="module")
def default_holidays():
calendar = country_holidays("BR")
return calendar
@pytest.fixture(scope="module")
def custon_holidays(default_holidays):
return default_holidays + CustonNordesteCalendar()
Agora usando uma fixture vamos gerar um calendário específico para que seja setado apenas uma vez para todo módulo de teste que ele corresponde, chamaremos nossa fixture de custon_holidays
e ela retorna nosso calendário customizado que como vimos acima gera o calendário do brasil com nossos feriados extras
Tanbém definimos o calendário padrão do brasil com o default_holidays
, assinm podemos demonstrar tanbém que fitures podem consulmir outras fixtures
Agora podemos escrever alguns testes para demonstrar nossa funcionalidade
# tests/test_estimative_delivery_service.py
from datetime import date
from unittest.case import TestCase
import pytest
from delivery import EstimativeDeliveryService
_test = TestCase()
def test_calculate_delivery_in_ordinary_day_with_default_calendar(default_holidays):
service = EstimativeDeliveryService(default_holidays)
result = service.estimate_delivery(date(2024, 5, 10), 5, 8)
_expcted_result = [
date(2024, 5, 16),
date(2024, 5, 17),
date(2024, 5, 20),
date(2024, 5, 21),
]
_test.assertEqual(len(result), len(_expcted_result))
for item in result:
_test.assertIn(item, _expcted_result)
def test_calculate_delivery_in_ordinary_day_with_custon_calendar(custon_holidays):
service = EstimativeDeliveryService(custon_holidays)
result = service.estimate_delivery(date(2024, 5, 10), 5, 8)
_expcted_result = [
date(2024, 5, 16),
date(2024, 5, 17),
date(2024, 5, 20),
date(2024, 5, 21),
]
_test.assertEqual(len(result), len(_expcted_result))
for item in result:
_test.assertIn(item, _expcted_result)
def test_calculate_delivery_in_special_holidays_with_default_calendar(
default_holidays,
):
service = EstimativeDeliveryService(default_holidays)
result = service.estimate_delivery(date(2024, 6, 20), 5, 8)
_expcted_result = [
date(2024, 6, 26),
date(2024, 6, 27),
date(2024, 6, 28),
date(2024, 7, 1),
]
_test.assertEqual(len(result), len(_expcted_result))
for item in result:
_test.assertIn(item, _expcted_result)
def test_calculate_delivery_in_special_holidays_with_custon_calendar(custon_holidays):
service = EstimativeDeliveryService(custon_holidays)
result = service.estimate_delivery(date(2024, 6, 20), 5, 8)
_expcted_result = [
date(2024, 6, 28),
date(2024, 7, 1),
date(2024, 7, 2),
date(2024, 7, 3),
]
_test.assertEqual(len(result), len(_expcted_result))
for item in result:
_test.assertIn(item, _expcted_result)
Com os testes test_calculate_delivery_in_ordinary_day_*
conseguimos provar que em dias ordináros nosso serviço retorna as datas de forma igual, porém para test_calculate_delivery_in_special_holidays_*
provamos que há uma diferença nos dias retornados pois definimos que 24 e 24 de junho serão feriados
Cenário 2: Criando controle de ciclo de vida em fixtures
Agora que já sabemos usar fixtures para compartilhar estados e efeitos colaterais , podemos avançar neste tópico e fazer algo realmente interessante…
Quando trabalhamos com banco de dados e queremos relaizar testes em nossos projetos, é comun ver o uso de estruturas de mock ou de ferramentas como testcontainers, mas eu , particulamente em meus projeto não vejo muito sentido em realizar testes que não estejam contra algo que vou usar em produção ou o mais próximo que eu conseguir ter em um anbiente de CI/CD, claro , cada caso é um caso e nem sempre é viavél utilizar testes e2e em face de mocks e testcontainers, tudo na vida tem um tradeoff.
Tendo isso em mente eu vou demonstrar como utilizar o controle de ciclo de vida de fixtures para poder fazer testes contra bancos de dados e depois usar uma funçào de teardown para limpar todo o anbiente, assim permitindo testes sem estados residuais.
Para essa demonstração irei implementar uma tabela simples usando sqlite em memória mesmo, sem ORM, mas nbada que não possa ser adaptado para o seu cenário de banco de dados e CI/CD.
Seguindo ainda com o serviço de delivery, precisamos agora gerenciar os veículos da nossa empresa, visto que precisamos fazer o cálculo de estimativa de entrega com base na disponibilidade de entregadores e veículos.
Para isso iremos implementar uma estrutura simples SQL que nos permita ter uma tabela de veículos.
Nesta tabela teremos a lista de veículos da empresa com a placa do veículo, e o status atual do veículo que pode ser 3:
- Disponivél para entrega
- Realizando entrega
- Em manutenção
CREATE TABLE IF NOT EXISTS veichiles (
id INTEGER PRIMARY KEY,
plate TEXT NOT NULL UNIQUE,
status INT NOT NULL DEFAULT 1,
created_at NUMERIC,
updated_at NUMERIC,
)
Dessa forma toda vez que um veículo for acionado o mesmo mudará seu status, assim como saberemos em tempo real quais veículos estão disponivéis
Assim implementamos nossa classe de serviço a qual de fato iremos testar.
# delivery/storage/sqlite/veichile_sqlite_storage.py
from datetime import datetime
from sqlite3 import Connection
from delivery.domain.models import VeichileModel, VeichileStatus
from delivery.repository import VeichileRepository
class VeichileStorageSqlite(VeichileRepository):
def __init__(self, conn: Connection, table_name: str = "veichile"):
self.conn = conn
self.table_name = table_name
def create_table(self):
with self.conn:
cursor = self.conn.cursor()
cursor.execute(
f"""
CREATE TABLE IF NOT EXISTS {self.table_name} (
id INTEGER PRIMARY KEY,
plate TEXT NOT NULL UNIQUE,
status INT NOT NULL DEFAULT 1,
created_at NUMERIC,
updated_at NUMERIC
)
"""
)
def drop_table(self):
with self.conn:
cursor = self.conn.cursor()
cursor.execute(
f"""
DROP TABLE {self.table_name}
"""
)
def create_veichile(self, veichile: VeichileModel) -> VeichileModel:
created_at = datetime.now().timestamp()
with self.conn:
cursor = self.conn.cursor()
cursor.execute(
"""
INSERT INTO veichile (plate, status, created_at, updated_at)
VALUES (?, ?, ?, ?)
""",
(veichile.plate, veichile.status.value, created_at, created_at),
)
veichile_id = cursor.lastrowid
return VeichileModel(
id=veichile_id,
plate=veichile.plate,
status=veichile.status,
created_at=datetime.fromtimestamp(created_at),
updated_at=datetime.fromtimestamp(created_at),
)
def get_veichile_by_id(self, veichile_id: int) -> VeichileModel | None:
with self.conn:
cursor = self.conn.cursor()
cursor.execute("SELECT * FROM veichile WHERE id = ?", (veichile_id,))
if cursor.rowcount == 0:
return None
row = cursor.fetchone()
if not row:
return None
return VeichileModel(
id=row[0],
plate=row[1],
status=VeichileStatus(row[2]),
created_at=datetime.fromtimestamp(row[3]),
updated_at=datetime.fromtimestamp(row[4]),
)
def update_veichile_status(
self, veichile_id: int, status: VeichileStatus
) -> VeichileModel | None:
old_veichile = self.get_veichile_by_id(veichile_id)
if not old_veichile:
return None
now = datetime.now().timestamp()
with self.conn:
cursor = self.conn.cursor()
cursor.execute(
"""
UPDATE veichile
SET status = ?, updated_at = ?
WHERE id = ?
""",
(status.value, now, veichile_id),
)
if cursor.rowcount == 0:
return None
update_veichile = self.get_veichile_by_id(veichile_id)
return update_veichile
Em seguida iremos implementar as fixtures, uma para gerar a conexão e outra para apagar a tabela após o uso, dessa forma , adicionando elas no mesmo arquivo das fixtures que criamos anteriormente, claro isso pode ser quebrado desde que , as fixtures sejam ao menos importadas no arquivo de conftest.py da suite de teste desejada, entenda mais sobre aqui
# /tests/delivery/conftest.py
import sqlite3
from datetime import date
import pytest
from holidays import country_holidays
from holidays.countries import BR
from holidays.holiday_base import HolidayBase
from delivery.storage import VeichileStorageSqlite
class CustonNordesteCalendar(BR):
def _populate(self, year):
super()._populate(year)
self[date(year, 6, 24)] = "Véspera de São João"
self[date(year, 6, 25)] = "São João"
@pytest.fixture(scope="module")
def default_holidays():
calendar = country_holidays("BR")
return calendar
@pytest.fixture(scope="module")
def custon_holidays(default_holidays):
return default_holidays + CustonNordesteCalendar()
@pytest.fixture(scope="session")
def create_database():
db_name = ":memory:"
conn = sqlite3.connect(db_name)
yield conn
conn.close()
@pytest.fixture(scope="session")
def create_veichile_database(create_database):
conn = create_database
db = VeichileStorageSqlite(conn)
db.create_table()
yield db
db.drop_table()
Nas fixtures de create_database
e create_veichile_database
utilizamos o escopo de sessão, para que a limpeza de estado seja feita após o termino dos testes, vale ressaltar que em vez de retornar o valor assumido pela fixture, usamos o yeld, assim tudo que ocorrer após o yeld será o cleanup da fixture, podemos fazer o drop da tabela, bem como encerrar a conexão, aida que esta seja estabelecida em memória no nosso exemplo
Por fim escreveremos nosso teste o qual expõe como eviência o reaproveitamento de estado, no primeiro iremos criar um veículo, no segundo iremos consultar este veículo criado no teste anterior e atualizar o status, inicialmente não recomendo esse tipo de abordagem , pois obriga o teste a ser sequêncial e não permite que cada um seja executado de forma independente, gerando assim side-effects, então use com caltela
# tests/delivery/test_veichile_database.py
from unittest.case import TestCase
from delivery.domain.models import VeichileModel, VeichileStatus
_test = TestCase()
def test_insert_one_veichile(create_veichile_database):
database = create_veichile_database
search_veichile = database.get_veichile_by_id(1)
_test.assertIsNone(search_veichile)
veichile = VeichileModel(
id=1,
plate="CNC-1212",
status=VeichileStatus.AVAILABLE,
)
created_veichile = database.create_veichile(veichile)
_test.assertEqual(created_veichile.id, 1)
def test_update_veichile_status(create_veichile_database):
database = create_veichile_database
search_veichile = database.get_veichile_by_id(1)
_test.assertEqual(search_veichile.status, VeichileStatus.AVAILABLE)
updated_veichile = database.update_veichile_status(
search_veichile.id, VeichileStatus.UNAVAILABLE
)
get_updated_veichile = database.get_veichile_by_id(1)
_test.assertEqual(updated_veichile.id, search_veichile.id)
_test.assertNotEqual(updated_veichile.status, search_veichile.status)
_test.assertEqual(updated_veichile.status, VeichileStatus.UNAVAILABLE)
_test.assertEqual(get_updated_veichile.status, VeichileStatus.UNAVAILABLE)
Basta agora rodar o comando para executar nossos testes , concluir nosso commit, fechar nosso card no jira e dizer pro chefe que conseguimos fazer 80% de cobertura de testes em nosso projetos
Conclusões
Escrever testes não é uma tarefa simples, seu sofgtware sempre vai precisar estar desacoplado e estruturado de forma que possa testar partes. Ainda que nestes exemplos eu demonstrei uma implementação de teste statefull, esse tipo de prática deve ser evitado, ao testar mutabilidade certifique-se de apagar os dados gerados nas etapas anteriores, para que cada suite de teste seja independente e seus testes se tornem mais eficazes.
No final do dia, conhecer sua ferramenta e seus objetivos é o que vai te tornar um dev mais eficaz e produtivo (Além de doses cavalares de café/energético)
Ufa! Acho que já deu por hoje, tenha um ótimo dia