Python Admin

Dominando TDD em Python: Desenvolvendo Guiado por Testes na Prática em Projetos Reais Já leu

O que é TDD e por que você deveria se importar Test-Driven Development (TDD) é uma metodologia onde você escreve testes antes de escrever o código de produção. Parece contraditório, mas a lógica é simples: se você não consegue escrever um teste que falha para uma funcionalidade, é porque você não sabe exatamente o que precisa implementar. Essa prática força você a pensar no comportamento esperado antes de codificar. A adoção de TDD traz benefícios concretos. Seu código fica mais modular porque precisa ser testável. Você ganha confiança para refatorar sem medo de quebrar coisas. A documentação fica viva — os testes mostram exemplos reais de como usar o código. E o mais importante: você detecta bugs antes de colocá-los em produção. Não é sobre ter 100% de cobertura de testes; é sobre colocar a qualidade como prioridade desde o primeiro commit. O Ciclo Red-Green-Refactor O coração de TDD é um ciclo de três fases que você repete continuamente. Entender

O que é TDD e por que você deveria se importar

Test-Driven Development (TDD) é uma metodologia onde você escreve testes antes de escrever o código de produção. Parece contraditório, mas a lógica é simples: se você não consegue escrever um teste que falha para uma funcionalidade, é porque você não sabe exatamente o que precisa implementar. Essa prática força você a pensar no comportamento esperado antes de codificar.

A adoção de TDD traz benefícios concretos. Seu código fica mais modular porque precisa ser testável. Você ganha confiança para refatorar sem medo de quebrar coisas. A documentação fica viva — os testes mostram exemplos reais de como usar o código. E o mais importante: você detecta bugs antes de colocá-los em produção. Não é sobre ter 100% de cobertura de testes; é sobre colocar a qualidade como prioridade desde o primeiro commit.

O Ciclo Red-Green-Refactor

O coração de TDD é um ciclo de três fases que você repete continuamente. Entender e praticar esse ciclo é fundamental para dominar a metodologia.

Red: Escrever o teste que falha

Você começa escrevendo um teste para uma funcionalidade que ainda não existe. O teste deve falhar — é por isso que chamamos de "Red". O teste descreve o comportamento esperado em linguagem executável. Esse passo força você a pensar na interface do seu código antes de implementá-lo.

Green: Fazer o teste passar com o mínimo de código

Agora você escreve o código de produção. O objetivo aqui não é perfeição; é fazer o teste passar do jeito mais simples possível. Você pode usar atalhos, hardcoding, qualquer coisa — desde que o teste fique verde. Isso pode parecer estranho, mas há um propósito: você se foca apenas na lógica que resolve o problema.

Refactor: Melhorar o código sem quebrar testes

Com o teste passando, você pode refatorar com segurança. Remova duplicação, melhore nomes de variáveis, simplify a lógica. Os testes garantem que você não quebrou nada. É aqui que o código ganha qualidade e elegância.

Esse ciclo é iterativo. Você não escreve todos os testes de uma vez. Para cada pequeno pedaço de funcionalidade — uma função, um método — você executa Red-Green-Refactor. Isso torna o desenvolvimento incremental e controlado.

Exemplo Prático: Uma Calculadora Simples

Vamos aplicar TDD desenvolvendo uma calculadora que realiza operações básicas. Vou mostrar o ciclo completo para que você veja como funciona na prática.

Red: Escrevendo o primeiro teste

# test_calculadora.py
import pytest
from calculadora import Calculadora

def test_somar_dois_numeros():
    calc = Calculadora()
    resultado = calc.somar(2, 3)
    assert resultado == 5

Se você rodar pytest test_calculadora.py agora, vai falhar — o módulo calculadora nem existe. Esse é o estado Red.

Green: Implementação mínima

# calculadora.py
class Calculadora:
    def somar(self, a, b):
        return a + b

Agora pytest test_calculadora.py passa. Simples? Demais. Mas é proposital — você escreveu o mínimo. Vamos adicionar mais funcionalidades.

Expandindo com novos testes

# test_calculadora.py (expandido)
import pytest
from calculadora import Calculadora

def test_somar_dois_numeros():
    calc = Calculadora()
    resultado = calc.somar(2, 3)
    assert resultado == 5

def test_somar_numeros_negativos():
    calc = Calculadora()
    resultado = calc.somar(-5, 3)
    assert resultado == -2

def test_subtrair_dois_numeros():
    calc = Calculadora()
    resultado = calc.subtrair(10, 3)
    assert resultado == 7

def test_multiplicar_dois_numeros():
    calc = Calculadora()
    resultado = calc.multiplicar(4, 5)
    assert resultado == 20

def test_divisao_por_zero_lanca_erro():
    calc = Calculadora()
    with pytest.raises(ValueError):
        calc.dividir(10, 0)

def test_dividir_dois_numeros():
    calc = Calculadora()
    resultado = calc.dividir(10, 2)
    assert resultado == 5.0

Agora temos vários testes falhando. Vamos ao Green.

Green: Implementação completa

# calculadora.py (expandido)
class Calculadora:
    def somar(self, a, b):
        return a + b

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

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

    def dividir(self, a, b):
        if b == 0:
            raise ValueError("Não é permitido dividir por zero")
        return a / b

Todos os testes passam. Está funcional, mas não está otimizado.

Refactor: Melhorando a estrutura

Agora vamos refatorar. Observe que temos repetição de calc = Calculadora() em cada teste. Usamos fixtures do pytest:

# test_calculadora.py (refatorado)
import pytest
from calculadora import Calculadora

@pytest.fixture
def calc():
    """Fixture que fornece uma instância de Calculadora para cada teste"""
    return Calculadora()

def test_somar_dois_numeros(calc):
    assert calc.somar(2, 3) == 5

def test_somar_numeros_negativos(calc):
    assert calc.somar(-5, 3) == -2

def test_subtrair_dois_numeros(calc):
    assert calc.subtrair(10, 3) == 7

def test_multiplicar_dois_numeros(calc):
    assert calc.multiplicar(4, 5) == 20

def test_divisao_por_zero_lanca_erro(calc):
    with pytest.raises(ValueError, match="Não é permitido dividir por zero"):
        calc.dividir(10, 0)

def test_dividir_dois_numeros(calc):
    assert calc.dividir(10, 2) == 5.0

Melhor. Os testes continuam passando, mas estão mais limpos. Esse é o Refactor.

Armadilhas Comuns e Como Evitá-las

Teste muito vago ou muito específico

Um teste vago não verifica nada de valor. Um teste muito específico quebra com mudanças irrelevantes. O equilíbrio é testar o comportamento, não a implementação.

# ❌ Vago demais
def test_calculadora():
    calc = Calculadora()
    assert calc  # Isso verifica o quê?

# ✅ Comportamento específico
def test_somar_retorna_inteiro_para_inteiros():
    calc = Calculadora()
    resultado = calc.somar(2, 3)
    assert resultado == 5
    assert isinstance(resultado, int)

Não testar casos extremos

Testes devem cobrir caminhos felizes e caminhos de erro. Números negativos, zero, valores muito grandes — tudo isso importa.

# ✅ Cobrindo casos extremos
def test_somar_com_zero(calc):
    assert calc.somar(0, 5) == 5

def test_multiplicar_por_zero(calc):
    assert calc.multiplicar(100, 0) == 0

def test_somar_numeros_muito_grandes(calc):
    assert calc.somar(10**100, 10**100) == 2 * 10**100

Testes acoplados a detalhes de implementação

Se mudar a implementação, os testes não devem quebrar enquanto o comportamento permanece igual.

# ❌ Acoplado à implementação
def test_somar_usa_operador_mais():
    calc = Calculadora()
    # Isso verifica o operador usado, não o resultado
    assert "+" in inspect.getsource(calc.somar)

# ✅ Verifica comportamento
def test_somar_dois_e_tres_resulta_cinco():
    assert Calculadora().somar(2, 3) == 5

Exemplo Avançado: Testando uma Classe com Estado

Vamos aplicar TDD em algo mais realista — uma classe que gerencia uma conta bancária. Isso envolve estado, validações e regras de negócio.

Começando com os testes

# test_conta_bancaria.py
import pytest
from datetime import datetime
from conta_bancaria import ContaBancaria, SaldoInsuficiente

@pytest.fixture
def conta():
    return ContaBancaria(titular="João Silva", saldo_inicial=1000.0)

def test_criar_conta_com_saldo_inicial(conta):
    assert conta.saldo == 1000.0
    assert conta.titular == "João Silva"

def test_deposito_aumenta_saldo(conta):
    conta.depositar(500.0)
    assert conta.saldo == 1500.0

def test_saque_diminui_saldo(conta):
    conta.sacar(300.0)
    assert conta.saldo == 700.0

def test_saque_com_saldo_insuficiente_lanca_erro(conta):
    with pytest.raises(SaldoInsuficiente):
        conta.sacar(2000.0)

def test_saldo_nao_pode_ser_negativo(conta):
    assert conta.saldo >= 0

def test_historico_registra_transacoes(conta):
    conta.depositar(100.0)
    conta.sacar(50.0)
    historico = conta.obter_historico()
    assert len(historico) == 2
    assert historico[0]["tipo"] == "deposito"
    assert historico[0]["valor"] == 100.0
    assert historico[1]["tipo"] == "saque"
    assert historico[1]["valor"] == 50.0

def test_taxa_juros_aplicada_mensalmente(conta):
    # 1000 + 1% = 1010
    conta.aplicar_juros_mensais(taxa=0.01)
    assert conta.saldo == 1010.0

Implementação seguindo TDD

# conta_bancaria.py
from datetime import datetime
from typing import List, Dict

class SaldoInsuficiente(Exception):
    """Exceção levantada quando há tentativa de saque sem saldo suficiente"""
    pass

class ContaBancaria:
    def __init__(self, titular: str, saldo_inicial: float = 0.0):
        self.titular = titular
        self.saldo = saldo_inicial
        self._historico: List[Dict] = []

        if saldo_inicial > 0:
            self._historico.append({
                "tipo": "abertura",
                "valor": saldo_inicial,
                "data": datetime.now(),
                "saldo_anterior": 0.0,
                "saldo_novo": saldo_inicial
            })

    def depositar(self, valor: float) -> None:
        """Aumenta o saldo da conta"""
        if valor <= 0:
            raise ValueError("Depósito deve ser um valor positivo")

        saldo_anterior = self.saldo
        self.saldo += valor

        self._historico.append({
            "tipo": "deposito",
            "valor": valor,
            "data": datetime.now(),
            "saldo_anterior": saldo_anterior,
            "saldo_novo": self.saldo
        })

    def sacar(self, valor: float) -> None:
        """Diminui o saldo da conta"""
        if valor <= 0:
            raise ValueError("Saque deve ser um valor positivo")

        if valor > self.saldo:
            raise SaldoInsuficiente(
                f"Saldo insuficiente. Saldo: {self.saldo}, Tentativa: {valor}"
            )

        saldo_anterior = self.saldo
        self.saldo -= valor

        self._historico.append({
            "tipo": "saque",
            "valor": valor,
            "data": datetime.now(),
            "saldo_anterior": saldo_anterior,
            "saldo_novo": self.saldo
        })

    def obter_historico(self) -> List[Dict]:
        """Retorna o histórico de transações"""
        return self._historico.copy()

    def aplicar_juros_mensais(self, taxa: float) -> None:
        """Aplica juros ao saldo existente"""
        if taxa < 0:
            raise ValueError("Taxa de juros não pode ser negativa")

        saldo_anterior = self.saldo
        self.saldo = self.saldo * (1 + taxa)

        self._historico.append({
            "tipo": "juros",
            "valor": self.saldo - saldo_anterior,
            "data": datetime.now(),
            "saldo_anterior": saldo_anterior,
            "saldo_novo": self.saldo
        })

Executando os testes

$ pytest test_conta_bancaria.py -v

test_conta_bancaria.py::test_criar_conta_com_saldo_inicial PASSED
test_conta_bancaria.py::test_deposito_aumenta_saldo PASSED
test_conta_bancaria.py::test_saque_diminui_saldo PASSED
test_conta_bancaria.py::test_saque_com_saldo_insuficiente_lanca_erro PASSED
test_conta_bancaria.py::test_saldo_nao_pode_ser_negativo PASSED
test_conta_bancaria.py::test_historico_registra_transacoes PASSED
test_conta_bancaria.py::test_taxa_juros_aplicada_mensalmente PASSED

======================== 7 passed in 0.05s ========================

Todos passam. Agora temos confiança que o código funciona conforme esperado. Se você modificar a implementação depois, os testes garantem que o comportamento permanece correto.

Ferramentas e Ambiente

Para praticar TDD em Python, você precisa de poucos elementos, mas com as escolhas certas.

pytest: O framework mais utilizado

O pytest é o padrão de facto para testes em Python. É simples de começar, mas poderoso o bastante para casos complexos. A sintaxe é limpa — use assert direto, não precisa de métodos especiais como em outras linguagens.

pip install pytest

Para rodar os testes com mais verbosidade e ver qual falhou:

pytest test_calculadora.py -v --tb=short

Coverage: Medindo cobertura de código

Cobertura de testes mostra qual percentual do seu código está coberto por testes. Não é uma métrica perfeita, mas ajuda a identificar partes não testadas.

pip install pytest-cov
pytest --cov=calculadora test_calculadora.py

Estrutura recomendada para seu projeto

projeto/
├── src/
│   └── calculadora.py
├── tests/
│   ├── __init__.py
│   ├── test_calculadora.py
│   └── test_conta_bancaria.py
├── pytest.ini
└── requirements-dev.txt

Conclusão

TDD é uma mudança de mentalidade, não apenas uma técnica. Quando você escreve testes primeiro, você pensa diferente — seu código fica mais simples, mais testável, e consequentemente mais confiável. O ciclo Red-Green-Refactor é seu aliado. Comece pequeno: pegue uma funcionalidade simples e pratique o ciclo completo. Com o tempo, escrever testes primeiro vira natural.

Os três pontos principais que você leva daqui: Primeiro, TDD força você a pensar no design do código antes de implementar — isso resulta em APIs melhores. Segundo, testes funcionam como documentação viva e executável do comportamento esperado. Terceiro, o Refactor seguro é talvez o maior benefício — você muda o código com confiança porque os testes têm suas costas.

Referências


Artigos relacionados