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:

  1. Red: Escreva um teste que falhe (ainda não há implementação)
  2. Green: Implemente o mínimo necessário para o teste passar
  3. 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