Existe uma confusão pedagógica recorrente sobre o papel dos testes na engenharia de software. A versão simplista é: testes existem para "verificar que o código funciona". Por essa lógica, testar é uma atividade que vem depois de implementar — primeiro você escreve o código, depois adiciona testes para garantir que ele faz o que promete. O resultado dessa ordem é familiar a quem já trabalhou com código legado: testes que repetem a implementação, testes frágeis que quebram a cada refatoração, e códigos onde testar significa subir banco, configurar quinze fixtures e rezar para o ambiente de CI cooperar.
A virada conceitual de Kent Beck nos anos 90, codificada como Test-Driven Development, não foi sobre quando escrever testes, mas sobre o que eles fazem ao design. Testes escritos primeiro forçam quem programa a pensar em fronteiras antes de pensar em mecânica. Eles tornam visível, no momento da escrita, todo acoplamento desnecessário e toda dependência oculta que de outra forma só apareceriam em produção. Testes não são uma ferramenta de verificação; são uma ferramenta de design. A função principal deles não é provar que o código está certo, é deixar claro o que o código deveria estar fazendo, e expor estruturalmente onde ele não está.
Esse conceito é o pivô deste módulo porque ele conecta tudo o que vimos antes. SOLID, coesão e composição são princípios abstratos até você tentar testar código que os viola — aí ficam concretíssimos. Uma classe com baixa coesão precisa de mocks para tudo. Uma classe acoplada a Postgres não consegue ser testada sem subir um Postgres. Uma hierarquia de herança profunda exige instanciar a pai inteira para testar a filha. Cada problema de design vira uma dor de teste. E o oposto: códigos que respeitam essas heurísticas são quase sempre fáceis de testar — não por sorte, mas porque os princípios são reformulações operacionais da mesma propriedade.
O que significa "testável"
Antes de discutir como projetar para testabilidade, vale ser preciso sobre o que a palavra significa. Um código é testável quando ele permite que você verifique seu comportamento de forma isolada, determinística e rápida. Os três adjetivos importam, e a ausência de qualquer um deles indica problema de design.
Isolamento significa que você consegue testar uma unidade sem
depender de outras unidades concretas. Se para testar
OrderService.confirm() você precisa instanciar EmailSender,
InventoryService, PaymentGateway e Logger
de verdade, esse serviço não está isolado — ele tem dependências concretas
espalhadas onde deveriam estar contratos. Sem isolamento, qualquer teste de
OrderService vira teste de cinco coisas ao mesmo tempo, e a
identificação da causa de uma falha vira investigação policial.
Determinismo significa que o teste produz o mesmo resultado sempre, dado os mesmos inputs. Testes que dependem de hora atual, geração de número aleatório, ordem de execução, threads, ou estado de banco compartilhado são flaky — passam de manhã, falham à tarde, e ninguém sabe por quê. Times maduros tratam testes flaky como bug a ser corrigido, não como inconveniência a ser ignorada. Um teste flaky é pior que nenhum teste, porque ensina o time a ignorar falhas — e quando uma real aparece, ninguém presta atenção.
Velocidade significa que rodar todos os testes da unidade leva poucos segundos, e a suíte completa, poucos minutos. Velocidade não é vaidade: é o que permite o ciclo TDD funcionar. Se cada teste leva trinta segundos, ninguém vai rodar oito vezes por hora — e o método inteiro desmorona. Quando uma suíte de unidade leva mais de dois minutos, ela já não é mais usada como ferramenta de design, é executada só no CI; e o feedback vai do "imediato" para o "talvez amanhã".
O ciclo TDD na prática
O ciclo clássico de Kent Beck é descrito em três passos: red → green → refactor. A formulação parece simples; o que ela ensina não é. Cada passo tem uma disciplina específica que toma anos para ser internalizada, e pular qualquer um deles destrói o método.
Red: escreva um teste que falha. Não escreva código de produção primeiro; o teste vem antes. A disciplina aqui é aceitar a aparente perda de tempo de "testar algo que não existe ainda" — porque o ganho real é mental: você é forçado a definir, em código executável, qual é exatamente o comportamento esperado. Antes de implementar, você sabe como o método deve ser chamado, que parâmetros recebe, o que devolve, e o que conta como sucesso. Esse exercício de design antes da implementação é o ponto inteiro do TDD.
Green: escreva o mínimo de código para o teste passar. Resista à tentação de "já que estou aqui, vou implementar tudo". Faça o mínimo. Pode até ser código feio, hardcoded, óbvio demais — e está tudo bem. A regra é: uma vez verde, pare. O ganho é que cada incremento é pequeno e seguro, e você sempre tem teste cobrindo o que acabou de adicionar.
Refactor: agora, com testes passando, melhore o design. Renomeie variáveis, extraia funções, elimine duplicação que apareceu, suba abstrações que fazem sentido. A rede de testes te dá segurança para mexer. Esta é a fase mais negligenciada do ciclo na prática — e é exatamente onde o ganho de design acontece. Sem refactor, TDD vira "test-first development", o que é apenas metade da disciplina e produz código verde mas mal estruturado.
TDD não é sobre escrever testes mais rápido — frequentemente é mais lento que "implementa e testa depois", especialmente no início. O ganho aparece em sistemas que mudam. Onde o requisito muda, o TDD permite mudar com confiança; onde o sistema cresce, ele evita o pântano de código emaranhado. Para scripts de uso único, TDD é overhead sem retorno.
O que torna código testável — três padrões estruturais
1. Inversão de dependência (DI/DIP)
Já vimos no conceito 01 e 03: quando o código depende de abstrações, não de
implementações concretas, você pode substituir a implementação real por um
duplo de teste. UserService que recebe IUserRepository
no construtor pode ser testado com um repositório fake em memória, sem subir
banco. NotificationService que recebe IEmailSender
pode ser testado com um sender que apenas grava a chamada para inspeção.
Essa é a aplicação mais imediata e direta de testabilidade.
O sintoma do código que viola isso é familiar: a primeira coisa que o método
faz é instanciar suas dependências.
var repo = new PostgresUserRepository(); dentro do método é
sentença de morte para isolamento. O remédio é mover a instanciação para
fora — passada via construtor, parâmetro, ou container DI.
2. Pureza funcional onde possível
Funções puras — que dependem apenas dos inputs e não causam efeitos colaterais — são as mais testáveis que existem. Você passa argumentos, verifica retorno, pronto. Sem mocks, sem fixtures, sem ordem de execução. O conselho prático, vindo da programação funcional mas aplicável em qualquer linguagem, é: separe pureza de efeito. Sua lógica de negócio deveria ser majoritariamente pura; os efeitos (I/O, banco, rede, hora atual) ficam num casco fino ao redor.
Em código orientado a objetos, isso aparece como métodos sem efeito colateral sempre que possível, e a separação clara entre objetos que calculam e objetos que fazem. Em Go, é o estilo idiomático de funções que recebem tudo como parâmetro e devolvem o resultado. Em Python, vale a heurística de Hettinger: "if it doesn't have to be a class, don't make it one".
3. Visibilidade de saída — observabilidade do estado
Testes precisam poder verificar o que o código fez. Isso parece óbvio, mas
muito código viola isso encapsulando estado de forma rígida demais. Se
processOrder() muda o estado interno de Order mas
esse estado não é exposto, o teste só consegue verificar que "não levantou
exceção" — o que é pouco. Métodos query, propriedades read-only, ou eventos
emitidos são formas de tornar o resultado verificável sem violar
encapsulamento.
O equilíbrio é sutil. Por um lado, você não quer expor toda a entranha do
objeto para teste — isso vira teste de implementação, frágil. Por outro,
objetos completamente opacos só podem ser testados pelo lado de fora, e isso
às vezes não basta. A regra é: teste comportamento via interfaces que clientes
reais usariam. Se nenhum cliente real consultaria esse estado, talvez o teste
também não devesse precisar. Quando precisa mesmo, a saída são events:
o objeto emite OrderConfirmed e o teste captura.
Test doubles — vocabulário preciso
Gerald Meszaros, no livro xUnit Test Patterns, formalizou cinco tipos de "test doubles" — substitutos de objetos reais durante testes. Os termos são usados de forma intercambiável no dia a dia, mas as diferenças importam para escrever testes que não viram pesadelo de manutenção.
-
Dummy: objeto passado mas nunca usado. Existe só para
satisfazer assinatura. Exemplo: passar
nullnum parâmetrologgerque nunca é chamado no fluxo testado. -
Stub: devolve respostas pré-definidas a chamadas. "Quando
chamarem
findById(42), devolva esse objeto fixo." Não verifica nada — só fornece dados. - Spy: como stub, mas registra chamadas para inspeção posterior. "Foi chamado quantas vezes? Com quais parâmetros?"
- Mock: stub + verificação automática. Configurado com expectativas; falha o teste se o objeto sob teste não as cumpriu (chamou ou não chamou os métodos esperados, na ordem esperada, etc).
-
Fake: implementação alternativa funcional, mas simplificada.
Exemplo clássico:
InMemoryUserRepositoryque implementa a mesma interface doPostgresUserRepository, guardando dados numdict. Tem comportamento real, só não persiste.
A pergunta prática é: qual usar quando? A heurística de Martin Fowler em Mocks Aren't Stubs é a melhor que existe: prefira fakes quando possível, stubs onde precisa de input controlado, e use mocks com moderação. Mocks têm um problema sutil — testes baseados em mocks verificam como o código foi escrito (que método foi chamado), não o que ele faz. Isso amarra o teste à implementação, e qualquer refatoração da implementação quebra os testes mesmo que o comportamento esteja correto. O sintoma é a famosa frase "mudei uma linha e quebrei cinquenta testes".
Mockar tudo é o caminho mais rápido para escrever testes — e o mais lento para mantê-los. Cada mock é um acoplamento adicional do teste à implementação. Quando 80% do teste é setup de mocks, o teste virou documentação de chamadas internas, não verificação de comportamento. Vale repensar: provavelmente tem uma fronteira mal-posta no design.
A pirâmide de testes — e suas alternativas modernas
Mike Cohn, em Succeeding with Agile, popularizou a pirâmide: muitos testes de unidade na base, alguns de integração no meio, poucos end-to-end no topo. A intuição é simples: testes de unidade são rápidos, determinísticos e específicos; E2E são lentos, frágeis e verificam o sistema todo. Você quer feedback rápido na maior parte dos casos.
A pirâmide envelheceu razoavelmente bem, mas algumas alternativas merecem conhecimento. O troféu de Kent C. Dodds inverte parcialmente: a maior fatia fica no integração, não unidade — argumento de que testes de integração são mais valiosos por verificarem código real interagindo com colaboradores reais (ou fakes próximos do real). A crítica original aos testes unitários é que eles podem todos passar enquanto o sistema inteiro está quebrado — porque cada um isola demais.
O honeycomb, proposto pela Spotify e outros, vai numa direção similar: foca em integration tests com pouca dependência de mocks, e usa testes de unidade apenas onde lógica é genuinamente complexa o suficiente para isolar. A motivação é minimizar o "teste-de-implementação" que aparece quando você isola por isolar.
O conselho prático sintético: tenha cada uma das três categorias, mas ajuste proporção pelo seu sistema. Sistemas de domínio rico (regras complexas, cálculos, transformações) geralmente se beneficiam de mais unidade. Sistemas que são "casca fina sobre banco" (CRUD com validação) frequentemente se beneficiam de mais integração. Sistemas críticos onde o caminho do usuário precisa estar garantido beneficiam de E2E em pontos estratégicos. Pirâmide é orientação, não dogma.
O mesmo princípio em três linguagens
O exemplo abaixo mostra o mesmo serviço — uma confirmação de pedido que precisa enviar e-mail e gravar log — testado nas três linguagens. Note como o design testável (DI + interface) é idêntico, mas os frameworks de teste e os idioms de duplo são distintos.
// xUnit + NSubstitute (mock library popular)
public class OrderServiceTests {
[Fact]
public async Task Confirm_SendsEmail_WhenOrderIsValid() {
// Arrange — fakes/mocks via interface
var notifier = Substitute.For<INotifier>();
var repo = new InMemoryOrderRepository(); // fake real
repo.Save(new Order { Id = "abc", Email = "a@b.com" });
var service = new OrderService(repo, notifier);
// Act
await service.Confirm("abc");
// Assert — verifica COMPORTAMENTO, não implementação
await notifier.Received(1).Notify("a@b.com", Arg.Any<string>());
}
}
xUnit é o padrão atual em .NET. NSubstitute para mocks de interface, fakes em memória onde possível.
# pytest + unittest.mock
def test_confirm_sends_email_when_order_is_valid():
# Arrange
notifier = Mock(spec=Notifier)
repo = InMemoryOrderRepository()
repo.save(Order(id="abc", email="a@b.com"))
service = OrderService(repo=repo, notifier=notifier)
# Act
service.confirm("abc")
# Assert
notifier.notify.assert_called_once_with("a@b.com", ANY)
pytest é o padrão. spec=Notifier garante que o mock só aceita
chamadas válidas do contrato — evita drift silencioso.
// testing (stdlib) — sem framework de mock externo
type fakeNotifier struct {
calls []notifyCall
}
type notifyCall struct{ to, msg string }
func (f *fakeNotifier) Notify(to, msg string) error {
f.calls = append(f.calls, notifyCall{to, msg})
return nil
}
func TestConfirmSendsEmailWhenOrderIsValid(t *testing.T) {
repo := newInMemoryOrderRepo()
repo.Save(Order{ID: "abc", Email: "a@b.com"})
notifier := &fakeNotifier{}
svc := NewOrderService(repo, notifier)
if err := svc.Confirm("abc"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(notifier.calls) != 1 {
t.Errorf("expected 1 notify call, got %d", len(notifier.calls))
}
if notifier.calls[0].to != "a@b.com" {
t.Errorf("wrong recipient: %s", notifier.calls[0].to)
}
}
Go favorece fakes manuais sobre frameworks de mock. Mais verboso, mas explícito — e o código de teste é igual ao código de produção: sem mágica.
Note três coisas idiomáticas. C# e Python têm bibliotecas robustas de mocking
(NSubstitute, Moq, FakeItEasy em .NET; unittest.mock em Python),
mas usá-las demais leva ao problema de testes acoplados à implementação. Go
não tem frameworks de mock canônicos — a comunidade prefere fakes manuais, o
que parece verboso mas tem a vantagem de manter o código de teste no mesmo
registro de complexidade que o de produção. As três linguagens também têm
ecossistemas para testes de propriedade (FsCheck/CsCheck em .NET, Hypothesis
em Python, gopter/rapid em Go) e para mutation testing (Stryker em .NET,
mutmut em Python, go-mutesting em Go) — esses tópicos são tratados em
profundidade no módulo dedicado a testes.
Onde testes geralmente falham
Quatro padrões patológicos aparecem em codebases reais com frequência alarmante. Reconhecê-los antecipadamente economiza meses de retrabalho.
1. Testes que verificam a implementação
Testes que dizem "este método foi chamado nesta ordem" estão verificando como o código está escrito, não o que ele faz. O sintoma é o teste quebrar quando você refatora — mesmo sem mudança de comportamento. A causa raiz é geralmente excesso de mocks. O remédio: prefira fakes que rodam a lógica real (mesmo simplificada), e verifique o resultado, não o caminho.
2. Testes que dependem de ordem
Suítes onde rodar test_b antes de test_a faz tudo
quebrar revelam estado compartilhado entre testes — banco não-resetado,
variáveis globais, singletons. Isso não é só inconveniência: é bomba-relógio.
Frameworks modernos rodam testes em paralelo por padrão, e estado compartilhado
faz tudo ruir não-deterministicamente. Cada teste deveria ser uma cápsula
independente. Setup e teardown explícitos, ou fixtures isoladas.
3. Testes lentos demais para serem rodados
Quando rodar uma única suíte demora dois minutos, ninguém roda enquanto está programando. Quando rodar tudo demora vinte, só o CI roda. O feedback que era o ponto inteiro do método se perde. Suítes lentas são quase sempre sintoma de testes que sobem demais (banco, container, app inteiro) onde poderiam isolar — a "pyramid inversion" virou anti-pattern. O remédio é ferro: identificar os lentos, reescrevê-los como unidades quando possível, ou separá-los em uma suíte secundária ("integration", "slow") que roda em momentos diferentes.
4. Testes que ninguém olha quando falham
Testes flaky ou de baixo valor educam o time a ignorar a faixa vermelha do CI. Quando uma falha real aparece, ela passa despercebida no meio das "falhas que sempre falham". Esse é provavelmente o pior estado de uma suíte — pior do que não ter testes. O remédio é cultural: testes flaky são bug, e bug se conserta ou se remove. Não há intermediário aceitável.
Testabilidade no projeto deste módulo
O URL Shortener exercita testabilidade em escala viável: a interface
IUrlRepository permite trocar Postgres por uma implementação em
memória nos testes, o gerador de código curto pode ser injetado para teste
determinístico, e os endpoints HTTP podem ser testados via cliente de teste
sem subir o servidor. Quando você implementar nas três linguagens, observe
especificamente:
-
Quanto setup é necessário para testar
shorten()isoladamente. Se for muito, há acoplamento mal-posto. - Quantos mocks você precisa criar. Se a maioria dos testes tem três ou mais mocks, considere se as fronteiras estão certas.
- Quão deterministicamente seu código gera o "código curto". Se ele depende de hora atual ou random, você não consegue testar sem injetar uma fonte controlada — e isso é design, não detalhe.
Esses três pontos não são exclusivos do projeto deste módulo — eles são aplicáveis a qualquer código que você escreva daqui pra frente. Internalizá-los é o que faz a diferença entre escrever testes porque "tem que ter" e usar testes como ferramenta de pensamento.
Referências para aprofundar
- livro Test-Driven Development: By Example — Kent Beck (2002).
- livro Growing Object-Oriented Software, Guided by Tests — Steve Freeman & Nat Pryce (2009).
- livro xUnit Test Patterns: Refactoring Test Code — Gerard Meszaros (2007).
- livro The Art of Unit Testing (3rd ed.) — Roy Osherove (2024).
- artigo Mocks Aren't Stubs — Martin Fowler.
- artigo Write Tests. Not Too Many. Mostly Integration. — Kent C. Dodds (2018).
- artigo Testing Strategies in a Microservice Architecture — Toby Clemson (Martin Fowler bliki).
- artigo The Way of Testivus — Alberto Savoia.
- docs xUnit.net Documentation.
- docs pytest Documentation — Good Integration Practices.
- docs Go Testing Package.
- vídeo Is TDD Dead? — DHH, Martin Fowler, Kent Beck (2014).
- vídeo Magic Tricks of Testing — Sandi Metz (RailsConf 2013).