A Mudança de Mindset: Test-Driven Development (TDD) na Prática
O ciclo Red-Green-Refactor com um Use Case real. TDD como ferramenta de design de API (não de cobertura). Triangulação para generalizar implementações, test doubles sem mock frameworks e quando NÃO usar TDD.
A maioria dos desenvolvedores escreve testes como burocracia: implementa a feature, depois escreve os testes correndo para passar pela code review. O resultado são testes que apenas verificam o caminho feliz, escondendo os edge cases que explodem em produção.
O TDD (Test-Driven Development), criado por Kent Beck, inverte esse processo: você escreve o teste antes de escrever qualquer linha da implementação. Mas o benefício real do TDD não é cobertura de código — é que ele força um bom design de API. Se você não consegue escrever o teste em 3 linhas, sua classe está mal projetada.
O Ciclo Red-Green-Refactor
- RED — escreva o menor teste possível que descreva o comportamento desejado. Execute: o teste falha (vermelho) porque a implementação não existe.
- GREEN — escreva o mínimo de código para fazer o teste passar. Pode ser feio, pode ser hardcoded — o objetivo é apenas verde.
- REFACTOR — com o teste como rede de segurança, melhore o código. Renomeie, extraia funções, aplique padrões. O teste avisa se você quebrou algo.
Cada iteração dura de 2 a 10 minutos. Você repete o ciclo dezenas de vezes por funcionalidade, adicionando um caso de uso por vez.
TDD na Prática: Use Case de Transferência Bancária
O exemplo de transferência bancária é ideal para demonstrar o TDD porque tem regras de negócio claras e múltiplos edge cases: saldo insuficiente, valor negativo, mesma conta de origem e destino. Sem TDD, é comum implementar o caminho feliz e esquecer esses casos. Com TDD, cada novo teste força a implementação a crescer organicamente para cobrir cada cenário.
import 'reflect-metadata';
import { TransferFundsUseCase } from './TransferFundsUseCase';
import { InMemoryAccountsRepository } from '../tests/repositories/InMemoryAccountsRepository';
describe('TransferFundsUseCase', () => {
let accountsRepo: InMemoryAccountsRepository;
let useCase: TransferFundsUseCase;
beforeEach(() => {
accountsRepo = new InMemoryAccountsRepository();
useCase = new TransferFundsUseCase(accountsRepo);
});
// ITERAÇÃO 1: Caso mais simples possível
it('deve transferir fundos entre duas contas', async () => {
// Arrange: prepara o estado inicial
const sourceAccount = await accountsRepo.create({ balance: 1000, ownerId: 'user-1' });
const targetAccount = await accountsRepo.create({ balance: 500, ownerId: 'user-2' });
// Act: executa a ação sendo testada
await useCase.execute({
sourceAccountId: sourceAccount.id,
targetAccountId: targetAccount.id,
amount: 300,
});
// Assert: verifica o resultado esperado
const updatedSource = await accountsRepo.findById(sourceAccount.id);
const updatedTarget = await accountsRepo.findById(targetAccount.id);
expect(updatedSource?.balance).toBe(700); // 1000 - 300
expect(updatedTarget?.balance).toBe(800); // 500 + 300
});
});import type { IAccountsRepository } from '../domain/repositories/IAccountsRepository';
interface TransferDTO {
sourceAccountId: string;
targetAccountId: string;
amount: number;
}
export class TransferFundsUseCase {
constructor(private accountsRepo: IAccountsRepository) {}
async execute({ sourceAccountId, targetAccountId, amount }: TransferDTO): Promise<void> {
const source = await this.accountsRepo.findById(sourceAccountId);
const target = await this.accountsRepo.findById(targetAccountId);
if (!source || !target) throw new Error('Account not found');
// Implementação mínima — sem validações ainda
source.balance -= amount;
target.balance += amount;
await this.accountsRepo.save(source);
await this.accountsRepo.save(target);
}
}
// Teste passa. Agora adicionamos o próximo caso de uso via novo teste (RED). // ITERAÇÃO 2: Saldo insuficiente
it('deve rejeitar transferência quando saldo for insuficiente', async () => {
const source = await accountsRepo.create({ balance: 100, ownerId: 'user-1' });
const target = await accountsRepo.create({ balance: 0, ownerId: 'user-2' });
await expect(
useCase.execute({ sourceAccountId: source.id, targetAccountId: target.id, amount: 200 })
).rejects.toThrow('Saldo insuficiente.');
// Garante que o banco não foi modificado quando a transação falha
const unchanged = await accountsRepo.findById(source.id);
expect(unchanged?.balance).toBe(100);
});
// ITERAÇÃO 3: Valor negativo ou zero
it('deve rejeitar transferência com valor inválido', async () => {
const source = await accountsRepo.create({ balance: 1000, ownerId: 'user-1' });
const target = await accountsRepo.create({ balance: 0, ownerId: 'user-2' });
await expect(
useCase.execute({ sourceAccountId: source.id, targetAccountId: target.id, amount: 0 })
).rejects.toThrow('Valor da transferência deve ser maior que zero.');
await expect(
useCase.execute({ sourceAccountId: source.id, targetAccountId: target.id, amount: -50 })
).rejects.toThrow('Valor da transferência deve ser maior que zero.');
});
// ITERAÇÃO 4: Conta de origem e destino iguais
it('deve rejeitar transferência para a mesma conta', async () => {
const account = await accountsRepo.create({ balance: 1000, ownerId: 'user-1' });
await expect(
useCase.execute({ sourceAccountId: account.id, targetAccountId: account.id, amount: 100 })
).rejects.toThrow('Conta de origem e destino não podem ser iguais.');
});import type { IAccountsRepository } from '../domain/repositories/IAccountsRepository';
import { AppError } from '../errors/AppError';
interface TransferDTO {
sourceAccountId: string;
targetAccountId: string;
amount: number;
}
export class TransferFundsUseCase {
constructor(private accountsRepo: IAccountsRepository) {}
async execute({ sourceAccountId, targetAccountId, amount }: TransferDTO): Promise<void> {
// Validações adicionadas conforme os testes foram escritos (iterações 3 e 4)
if (amount <= 0) {
throw new AppError('Valor da transferência deve ser maior que zero.');
}
if (sourceAccountId === targetAccountId) {
throw new AppError('Conta de origem e destino não podem ser iguais.');
}
const [source, target] = await Promise.all([
this.accountsRepo.findById(sourceAccountId),
this.accountsRepo.findById(targetAccountId),
]);
if (!source) throw new AppError('Conta de origem não encontrada.', 404);
if (!target) throw new AppError('Conta de destino não encontrada.', 404);
// Adicionado na iteração 2
if (source.balance < amount) {
throw new AppError('Saldo insuficiente.');
}
source.balance -= amount;
target.balance += amount;
// Idealmente aqui seria uma transação de banco de dados
await Promise.all([
this.accountsRepo.save(source),
this.accountsRepo.save(target),
]);
}
}InMemoryRepository: Test Double sem Mock Framework
Um dos motivos pelo qual o TDD funciona tão bem com Clean Architecture é que o Use Case depende apenas de interfaces. O InMemoryAccountsRepository é uma implementação real da interface — não um mock de jest.fn(). Isso significa que os testes também validam a lógica do repositório em memória. Se um mock retornasse dados pre-programados, perdemos a confiança de que o estado entre operações está sendo mantido corretamente.
import type { IAccountsRepository, CreateAccountDTO } from '../../domain/repositories/IAccountsRepository';
import type { Account } from '../../domain/entities/Account';
// Implementação in-memory da interface — zero dependência externa
// Mais legível e flexível do que jest.fn() para repositórios
export class InMemoryAccountsRepository implements IAccountsRepository {
// Exposto publicamente para asserções nos testes
public items: Array<Account> = [];
async create(data: CreateAccountDTO): Promise<Account> {
const account: Account = {
id: crypto.randomUUID(),
balance: data.balance,
ownerId: data.ownerId,
createdAt: new Date(),
};
this.items.push(account);
return account;
}
async findById(id: string): Promise<Account | null> {
return this.items.find((a) => a.id === id) ?? null;
}
async save(account: Account): Promise<Account> {
const index = this.items.findIndex((a) => a.id === account.id);
if (index === -1) throw new Error(`Account ${account.id} not found`);
this.items[index] = account;
return account;
}
}Quando NÃO usar TDD: para exploração de novas tecnologias (spike/prova de conceito), código descartável, UI e estética visual, scripts de migração de dados one-off. TDD brilha em lógica de negócio: cálculos financeiros, regras de elegibilidade, fluxos de autenticação, processamento de dados. Esses são os casos onde um bug em produção tem alto custo.
Conclusão
TDD muda o problema que você está resolvendo quando escreve código. Sem TDD, você pensa 'como implemento isso?'. Com TDD, você pensa 'como quero usar isso?' — e a segunda pergunta produz interfaces muito melhores. Os edge cases (saldo insuficiente, valor negativo, mesma conta) que você descobre ao escrever os testes antes são os mesmos bugs que você teria encontrado 6 meses depois em produção. A única diferença é o custo.