TDD na prática com Python
Fundamentos do TDD e setup do ambiente
Test-Driven Development (TDD) é uma metodologia de desenvolvimento que inverte a ordem tradicional de programação: primeiro escrevemos o teste, depois implementamos o código. O ciclo fundamental é conhecido como Red-Green-Refactor:
- Red: Escreva um teste que falhe (ainda não há implementação)
- Green: Implemente o mínimo necessário para o teste passar
- Refactor: Melhore o código mantendo todos os testes verdes
Para começar, configure o ambiente com pytest, o framework de testes mais popular do ecossistema Python:
pip install pytest pytest-cov pytest-mock
A estrutura de projeto recomendada para TDD segue esta organização:
meu_projeto/
├── src/
│ └── calculadora.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_calculadora.py
│ └── test_utils.py
├── requirements.txt
└── setup.py
Primeiro ciclo: escrevendo o teste antes do código
Vamos implementar uma função simples de soma seguindo TDD. Primeiro, criamos o teste que falha (Red):
# tests/test_calculadora.py
from src.calculadora import somar
def test_somar_dois_numeros_positivos():
resultado = somar(2, 3)
assert resultado == 5
Ao executar pytest, o teste falha porque a função somar não existe. Agora implementamos o mínimo para passar (Green):
# src/calculadora.py
def somar(a, b):
return a + b
Com o teste verde, podemos refatorar com segurança. Por exemplo, adicionar type hints e documentação:
def somar(a: float, b: float) -> float:
"""Retorna a soma de dois números."""
return a + b
Execute pytest novamente para confirmar que o teste continua passando.
Testes parametrizados e casos de borda
O @pytest.mark.parametrize permite testar múltiplos cenários com um único teste, ideal para cobrir casos de borda:
import pytest
from src.calculadora import somar
@pytest.mark.parametrize("a, b, esperado", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(1.5, 2.5, 4.0),
(1000000, 1, 1000001),
])
def test_somar_parametrizado(a, b, esperado):
assert somar(a, b) == esperado
def test_somar_com_string_levanta_erro():
with pytest.raises(TypeError):
somar("a", 1)
def test_somar_com_none_levanta_erro():
with pytest.raises(TypeError):
somar(None, 5)
A estratégia para cobertura sem redundância é testar: valores típicos, limites (zero, negativos), tipos inválidos e valores extremos.
Fixtures e organização de testes em TDD
Fixtures são funções que fornecem dados ou objetos reutilizáveis para os testes:
# tests/conftest.py
import pytest
@pytest.fixture
def numeros_positivos():
return [1, 2, 3, 4, 5]
@pytest.fixture(scope="module")
def calculadora_instance():
from src.calculadora import Calculadora
return Calculadora()
# tests/test_calculadora.py
def test_somar_lista(numeros_positivos):
from src.calculadora import somar_lista
assert somar_lista(numeros_positivos) == 15
def test_media_com_calculadora(calculadora_instance):
resultado = calculadora_instance.media([10, 20, 30])
assert resultado == 20.0
Os escopos de fixtures no ciclo TDD:
- function (padrão): recriada a cada teste
- class: uma vez por classe de teste
- module: uma vez por módulo
- session: uma vez por execução completa
Testes com dependências externas (mocking)
Quando sua função chama APIs externas, banco de dados ou arquivos, o mocking substitui essas dependências para testes isolados e rápidos:
# src/servico_clima.py
import requests
def obter_temperatura(cidade):
resposta = requests.get(f"https://api.clima.com/{cidade}")
dados = resposta.json()
return dados["temperatura"]
# tests/test_servico_clima.py
from unittest.mock import patch
from src.servico_clima import obter_temperatura
def test_obter_temperatura_com_mock():
with patch("src.servico_clima.requests.get") as mock_get:
mock_get.return_value.json.return_value = {"temperatura": 25}
resultado = obter_temperatura("São Paulo")
assert resultado == 25
mock_get.assert_called_once_with("https://api.clima.com/São Paulo")
Com pytest-mock, a sintaxe fica mais limpa:
def test_obter_temperatura_com_mocker(mocker):
mock_get = mocker.patch("src.servico_clima.requests.get")
mock_get.return_value.json.return_value = {"temperatura": 30}
resultado = obter_temperatura("Rio de Janeiro")
assert resultado == 30
Refatoração guiada por testes
Os testes atuam como rede de segurança para identificar e corrigir "code smells". Vamos refatorar um cálculo complexo passo a passo.
Código inicial com cheiro de duplicação:
def calcular_desconto(valor, tipo_cliente):
if tipo_cliente == "vip":
desconto = valor * 0.2
if valor > 1000:
desconto = valor * 0.3
elif tipo_cliente == "regular":
desconto = valor * 0.1
if valor > 500:
desconto = valor * 0.15
else:
desconto = 0
return valor - desconto
Testes existentes garantem segurança:
@pytest.mark.parametrize("valor, tipo, esperado", [
(1000, "vip", 800),
(1500, "vip", 1050),
(400, "regular", 360),
(600, "regular", 510),
(100, "novo", 100),
])
def test_calcular_desconto(valor, tipo, esperado):
assert calcular_desconto(valor, tipo) == esperado
Refatoração extraindo lógica de desconto:
def _taxa_desconto(tipo_cliente, valor):
taxas = {
"vip": (0.2, 0.3, 1000),
"regular": (0.1, 0.15, 500),
}
if tipo_cliente not in taxas:
return 0
taxa_normal, taxa_especial, limite = taxas[tipo_cliente]
return taxa_especial if valor > limite else taxa_normal
def calcular_desconto(valor, tipo_cliente):
taxa = _taxa_desconto(tipo_cliente, valor)
return valor - (valor * taxa)
Execute pytest após cada alteração para confirmar que nada quebrou.
TDD em projetos reais: integração contínua
Automatizar a execução dos testes com GitHub Actions garante que o ciclo TDD seja mantido em equipe:
# .github/workflows/test.yml
name: Testes
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- run: pytest --cov=src --cov-report=term-missing tests/
Para relatórios de cobertura com pytest-cov:
pytest --cov=src --cov-report=html tests/
Estratégias para manter disciplina TDD em sprints:
- Nunca escrever código de produção sem um teste falhando primeiro
- Manter os testes rápidos (segundos, não minutos)
- Revisar testes em code review com o mesmo rigor do código
- Usar TDD para bugs: primeiro escreva um teste que reproduza o bug
O TDD não é apenas sobre testar, mas sobre projetar software de forma incremental, com feedback constante e confiança para refatorar. A prática leva à fluência — comece com funções simples e gradualmente aplique a projetos mais complexos.
Referências
- Documentação oficial do pytest — Guia completo do framework de testes com exemplos e referência de fixtures, parametrização e plugins.
- Test-Driven Development com Python (Real Python) — Tutorial prático cobrindo ciclo TDD com exemplos reais em Python.
- pytest-cov: relatórios de cobertura — Documentação oficial do plugin para medição de cobertura de código.
- unittest.mock — mock object library — Documentação oficial da biblioteca de mocking para testes em Python.
- GitHub Actions para Python — Guia oficial para configurar integração contínua com testes Python.
- TDD com pytest (Python Testing with pytest) — Livro referência sobre testes em Python com foco em TDD e boas práticas.