Desenvolvimento orientado a testes (TDD)

1. Fundamentos do TDD

O Desenvolvimento Orientado a Testes (TDD) é uma prática de engenharia de software que inverte a ordem tradicional de desenvolvimento. Em vez de escrever o código primeiro e depois testá-lo, no TDD o teste é escrito antes do código de produção. Essa abordagem foi popularizada por Kent Beck no final dos anos 1990 e refinada por Robert C. Martin (Uncle Bob) com suas "Três Leis do TDD".

O ciclo fundamental do TDD é conhecido como Red-Green-Refactor:

  1. Red: Escreva um teste que falhe (vermelho)
  2. Green: Escreva o código mínimo necessário para fazer o teste passar (verde)
  3. Refactor: Melhore o código mantendo todos os testes verdes

As Três Leis do TDD de Robert C. Martin estabelecem:
- Não escreva código de produção sem antes escrever um teste que falhe
- Não escreva mais de um teste unitário do que o suficiente para falhar (e falhas de compilação contam como falhas)
- Não escreva mais código de produção do que o suficiente para fazer o teste falhado passar

A principal diferença entre TDD e testes tradicionais é que no TDD o teste funciona como ferramenta de design, não apenas como verificação. O teste força o desenvolvedor a pensar na interface da API antes da implementação.

2. Configurando o Ambiente para TDD

Para começar com TDD em Python, utilizaremos o framework pytest. A configuração básica envolve:

# Estrutura de diretórios recomendada
meu_projeto/
├── src/
│   └── calculadora.py
├── tests/
│   └── test_calculadora.py
├── requirements.txt
└── pytest.ini

Instalação do ambiente:

pip install pytest pytest-watch coverage

Arquivo pytest.ini:

[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*

3. Escrevendo o Primeiro Teste (Red)

Vamos criar um teste para uma função somar(a, b). O teste deve especificar o comportamento esperado:

# tests/test_calculadora.py
def test_somar_dois_numeros_positivos():
    """Cenário: somar 2 e 3 deve retornar 5"""
    from src.calculadora import somar

    resultado = somar(2, 3)

    assert resultado == 5, f"Esperado 5, mas obteve {resultado}"

Execute o teste para ver a falha (Red):

pytest tests/test_calculadora.py

# Saída esperada:
# FAILED tests/test_calculadora.py::test_somar_dois_numeros_positivos - 
# ImportError: cannot import name 'somar' from 'src.calculadora'

4. Implementando o Mínimo para Passar (Green)

Primeiro, criamos uma implementação mínima que faz o teste passar:

# src/calculadora.py
def somar(a, b):
    return 5  # Falso verde: retorna valor constante

Execute novamente:

pytest tests/test_calculadora.py

# Saída esperada:
# 1 passed in 0.02s

Agora, substituímos o falso verde pela implementação real:

# src/calculadora.py
def somar(a, b):
    return a + b

5. Refatoração Segura

Com os testes passando, podemos refatorar com segurança. Exemplo de identificação de duplicação:

# Antes da refatoração
def somar(a, b):
    return a + b

def subtrair(a, b):
    return a - b

def multiplicar(a, b):
    return a * b

# Depois da refatoração - extraindo validação comum
def _validar_numeros(*args):
    for arg in args:
        if not isinstance(arg, (int, float)):
            raise TypeError(f"Argumento {arg} não é numérico")

def somar(a, b):
    _validar_numeros(a, b)
    return a + b

def subtrair(a, b):
    _validar_numeros(a, b)
    return a - b

Execute os testes novamente para confirmar que continuam passando:

pytest tests/

6. TDD em Cenários Reais

Lidando com dependências externas usando mocks

# tests/test_servico.py
from unittest.mock import Mock

def test_servico_envia_email():
    # Arrange
    repositorio_mock = Mock()
    repositorio_mock.buscar_usuario.return_value = {"nome": "João", "email": "joao@email.com"}
    servico_email_mock = Mock()

    from src.servico import ServicoNotificacao
    servico = ServicoNotificacao(repositorio_mock, servico_email_mock)

    # Act
    servico.notificar_usuario(1, "Bem-vindo!")

    # Assert
    servico_email_mock.enviar.assert_called_once_with(
        "joao@email.com", 
        "Bem-vindo!"
    )

Testes para exceções e casos de borda

# tests/test_calculadora.py
import pytest

def test_somar_com_string_lanca_type_error():
    from src.calculadora import somar

    with pytest.raises(TypeError, match="não é numérico"):
        somar("2", 3)

def test_somar_com_valores_extremos():
    from src.calculadora import somar

    resultado = somar(10**100, 10**100)
    assert resultado == 2 * 10**100

def test_somar_com_zero():
    from src.calculadora import somar

    assert somar(0, 5) == 5
    assert somar(5, 0) == 5

TDD em APIs web

# tests/test_api.py
def test_rota_soma_retorna_status_200():
    from src.api import app
    from fastapi.testclient import TestClient

    client = TestClient(app)

    response = client.post("/somar", json={"a": 3, "b": 4})

    assert response.status_code == 200
    assert response.json() == {"resultado": 7}

def test_rota_soma_com_dados_invalidos():
    from src.api import app
    from fastapi.testclient import TestClient

    client = TestClient(app)

    response = client.post("/somar", json={"a": "invalido", "b": 4})

    assert response.status_code == 422

7. Integração Contínua e TDD

Pipeline de CI/CD com GitHub Actions:

# .github/workflows/test.yml
name: Testes TDD
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Configurar Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'
      - name: Instalar dependências
        run: |
          pip install pytest pytest-cov
      - name: Executar testes com cobertura
        run: |
          pytest tests/ --cov=src/ --cov-report=term-missing

8. Armadilhas e Boas Práticas

Erros comuns a evitar:

  1. Testes acoplados à implementação: Teste comportamentos, não métodos internos
  2. Pular o passo Red: Escrever o teste depois do código quebra o propósito do TDD
  3. Esquecer de refatorar: O ciclo só se completa com a refatoração

Como manter testes rápidos e independentes:

# tests/conftest.py - Fixtures compartilhadas
import pytest

@pytest.fixture
def dados_usuario_padrao():
    return {"id": 1, "nome": "João", "email": "joao@teste.com"}

# tests/test_usuario.py
def test_criar_usuario_com_dados_validos(dados_usuario_padrao):
    from src.usuario import criar_usuario

    usuario = criar_usuario(dados_usuario_padrao)

    assert usuario.nome == "João"
    assert usuario.email == "joao@teste.com"

Quando TDD não é adequado:

  • Prototipação rápida para validação de conceito
  • Código de UI altamente experimental
  • Sistemas legados sem cobertura de testes (comece com testes de caracterização primeiro)

Referências