Testes e qualidade de software

TDD em Java: um kata completo e uma avaliação honesta

Intermediário9 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

TDD é uma disciplina de design que, de quebra, deixa testes pelo caminho. Este guia percorre um problema completo, da primeira asserção falhando até um design refatorado, e depois examina o que a prática realmente faz com a estrutura do código, como lidar com colaboradores, onde ela funciona mal e o que as evidências de fato mostram.

O que você vai aprender

  • Executar o ciclo red-green-refactor com a disciplina que o torna útil
  • Fazer um design pequeno emergir dos testes, escolhendo o próximo teste de forma deliberada
  • Escolher entre outside-in e inside-out para uma determinada funcionalidade
  • Reconhecer as situações em que o test-first não se encaixa bem e explicar por quê
  • Responder às objeções clássicas ao TDD com algo melhor do que entusiasmo

O desenvolvimento orientado a testes é fácil de descrever e difícil de fazer bem. Você escreve um teste que falha, escreve o mínimo de código que o faz passar e depois melhora o design enquanto o teste continua passando. Tudo o que é interessante na prática está no que essa restrição faz com você: não dá para escrever código impossível de chamar, não dá para inventar uma abstração antes de ter um uso para ela, e você sempre sabe se o que acabou de escrever funciona.

A prática também costuma ser vendida além da conta. TDD não é uma estratégia de testes — uma suíte construída com TDD no nível unitário cobre muito pouco do risco de integração — e não substitui pensar no design. O que ele entrega de forma confiável é um ciclo de feedback curto e código fácil de instanciar, e essas duas coisas acabam importando muito. Este guia desenvolve um exemplo completo e depois analisa com honestidade onde a prática se paga e onde não.

O ciclo, com precisão

Red. Escreva um teste para um comportamento que não existe. Rode o teste. Ele precisa falhar, e pelo motivo que você espera: um teste que falha por causa de um erro de digitação na preparação não ensina nada. Um teste que passa de imediato significa que você não identificou nenhum comportamento novo.

Green. Escreva o código mais simples que faça o teste passar. "Mais simples" é para ser levado ao pé da letra, inclusive retornar uma constante. Isso parece absurdo na primeira vez, e é justamente o mecanismo que obriga o próximo teste a tratar de algo real.

Refactor. Com todos os testes verdes, melhore a estrutura: remova duplicação, renomeie, extraia, simplifique. É a etapa que as pessoas pulam, e pulá-la transforma o TDD em uma forma de produzir código mal projetado com boa cobertura de testes.

Dois hábitos fazem o ciclo funcionar. Mantenha cada volta em poucos minutos: se você está no vermelho há quinze minutos, o passo foi grande demais; reverta e dê um menor. E trate o código de teste como código de produção: ele vai ser lido muito mais vezes do que escrito.

Um kata, do zero até um design

O problema: um conversor RomanNumerals, de int para String, para valores de 1 a 3999. É pequeno o bastante para ser mostrado por inteiro e rico o bastante para ter uma decisão de design real no meio do caminho.

O primeiro teste é a menor declaração de intenção possível.

class RomanNumeralsTest {

    @Test
    void one() {
        assertThat(RomanNumerals.of(1)).isEqualTo("I");
    }
}

Ele não compila, o que conta como vermelho. A coisa mais simples que o deixa verde:

public final class RomanNumerals {
    public static String of(int value) {
        return "I";
    }
}

Agora um segundo teste obriga o código a distinguir dois casos.

@Test
void two() {
    assertThat(RomanNumerals.of(2)).isEqualTo("II");
}

A correção óbvia — um if — é pior do que a geral, e é neste momento que o design aparece:

public static String of(int value) {
    return "I".repeat(value);
}

Isso passa para 1, 2 e 3, e falha em 4. Em vez de escrever quatro testes separados, expresse os casos como uma tabela, que é exatamente o lugar de um teste parametrizado:

@ParameterizedTest(name = "{0} -> {1}")
@CsvSource({"1,I", "3,III", "4,IV", "9,IX", "14,XIV", "40,XL", "90,XC",
            "400,CD", "900,CM", "1994,MCMXCIV", "3999,MMMCMXCIX"})
void converts(int value, String expected) {
    assertThat(RomanNumerals.of(value)).isEqualTo(expected);
}

Adicionar apenas 4,IV já quebra o repeat. A tentação é encadear condicionais; a etapa de refatoração é o que impede isso. O insight — de que as formas subtrativas IV, IX, XL são apenas símbolos extras em uma tabela ordenada — surge porque a duplicação nos condicionais o torna visível:

public final class RomanNumerals {

    private record Symbol(int value, String glyph) {}

    private static final List<Symbol> SYMBOLS = List.of(
            new Symbol(1000, "M"), new Symbol(900, "CM"), new Symbol(500, "D"), new Symbol(400, "CD"),
            new Symbol(100, "C"),  new Symbol(90, "XC"),  new Symbol(50, "L"),  new Symbol(40, "XL"),
            new Symbol(10, "X"),   new Symbol(9, "IX"),   new Symbol(5, "V"),   new Symbol(4, "IV"),
            new Symbol(1, "I"));

    private RomanNumerals() {}

    public static String of(int value) {
        if (value < 1 || value > 3999) {
            throw new IllegalArgumentException("Roman numerals cover 1..3999, got " + value);
        }
        StringBuilder out = new StringBuilder();
        int remaining = value;
        for (Symbol symbol : SYMBOLS) {
            while (remaining >= symbol.value()) {
                out.append(symbol.glyph());
                remaining -= symbol.value();
            }
        }
        return out.toString();
    }
}

A cláusula de guarda veio do seu próprio teste vermelho, escrito depois que a tabela já estava verde:

@ParameterizedTest
@ValueSource(ints = {0, -1, 4000, Integer.MAX_VALUE})
void rejectsValuesOutsideTheRange(int value) {
    assertThatThrownBy(() -> RomanNumerals.of(value))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("1..3999");
}

Repare no que a sequência produziu. A tabela não é uma implementação que por acaso funcionou; é a estrutura para a qual os testes empurraram, porque cada condicional que eles forçavam era duplicação com o mesmo formato. Repare também no que não foi construído: nenhuma RomanNumeralFactory, nenhuma interface de estratégia, nenhuma configuração. Nada exigia isso.

O que os testes fazem com o design

O TDD aplica quatro tipos de pressão, e elas são o verdadeiro produto da prática.

Tudo precisa poder ser construído. Uma classe cujo construtor lê um singleton estático, abre uma conexão ou inicia um pool de threads não pode ser criada de forma barata em um teste. Com TDD você sente isso no primeiro minuto e passa a dependência de fora, que é a mesma conclusão a que a injeção de dependência chega pelo outro lado.

Tudo precisa ser observável. Se o único efeito de um método é uma linha de log ou a alteração de um campo privado, o teste não tem o que verificar. Essa pressão empurra para retornar valores em vez de alterar estado escondido, e para concentrar os efeitos colaterais em poucos pontos explícitos.

As unidades continuam pequenas. Um método com oito ramificações precisa de oito testes ou mais, com uma preparação trabalhosa. A dor chega antes de o método ir para produção, e não em uma revisão três semanas depois.

As abstrações chegam tarde. A etapa de refatoração introduz uma interface quando existe uma segunda implementação, não quando alguém prevê uma. A generalidade especulativa é o tipo de desperdício mais caro em uma base de código, e o test-first torna incômodo criá-la.

Nada disso é automático. Um time pode produzir código procedural amplamente testado, com um serviço que faz tudo e uma centena de mocks. A disciplina cria a pressão; quem projeta ainda precisa responder a ela.

Outside-in e inside-out

Há duas formas de entrar em uma funcionalidade, e elas servem a situações diferentes.

Inside-out (às vezes chamada de clássica) começa pelo objeto de domínio no centro — o cálculo, a máquina de estados, a regra de parsing — e avança para fora até o controller. Os colaboradores são objetos reais sempre que forem baratos. Os testes são robustos porque verificam valores em vez de interações, e refatorações raramente os quebram. O risco é construir um núcleo lindamente testado que não se encaixa na API de que você acaba precisando.

Outside-in (mockista, escola de Londres) começa na fronteira: um teste de aceitação para o comportamento visível ao usuário, depois um teste do controller com um serviço mockado, depois o serviço com um repositório mockado, descobrindo cada interface no momento em que ela é necessária. O design sai coeso porque cada colaborador foi inventado para atender a quem o chama. O custo são testes acoplados às interações: mude a forma como um serviço conversa com o repositório e o teste falha, mesmo com o comportamento idêntico.

Na prática, a maioria dos times experientes trabalha outside-in no nível da funcionalidade para descobrir seu formato e depois inside-out dentro de cada unidade que contém lógica de verdade. O guia de BDD descreve o ciclo mais externo desse esquema: um cenário falhando que continua vermelho enquanto vários ciclos no nível unitário rodam dentro dele.

Colaboradores, sem se afogar em mocks

O trabalho com test-first empaca no momento em que o objeto testado precisa de algo lento. Três respostas, em ordem de preferência:

  1. Empurre a dependência para fora. Se um método precisa da data atual, receba um Clock. Se precisa de um total vindo de um repositório, receba o total como parâmetro e deixe quem chama buscá-lo. Funções que dependem só dos próprios argumentos são triviais de testar.
  2. Use um fake. Uma implementação em memória de uma interface de repositório pequena faz uma dúzia de testes parecerem código comum e pega bugs de ordenação que stubs não conseguem pegar.
  3. Use mocks com moderação. Reserve os mocks para fronteiras de verdade — um gateway de pagamento, um serviço de envio de e-mail —, onde a interação é o comportamento que vale a pena verificar.

Quando um teste precisa de mais de umas três chamadas com stub para rodar, o design geralmente está pedindo um objeto menor. Esse sinal é um dos feedbacks mais confiáveis que a prática oferece.

Onde o test-first é difícil

Código legado. Não dá para conduzir código que você não consegue instanciar. Envolva primeiro o comportamento existente em um teste de caracterização de granularidade grossa — registre o que ele faz hoje, certo ou errado —, depois introduza uma costura e trabalhe com test-first dentro dela.

Algoritmos com implementação difícil de adivinhar. Em uma rotina numérica ou um algoritmo de grafos, testes pequenos não vão levar você à abordagem certa; primeiro é preciso entender o algoritmo. O test-first continua ajudando, mas como forma de fixar a especificação e os casos de borda, não de descobrir o método.

Interfaces de usuário. Layout e interação são avaliados a olho. Conduza a lógica de apresentação com test-first e mantenha a camada de renderização fina o bastante para que sua correção seja óbvia.

Exploração. Quando você está aprendendo uma API desconhecida, escreva um spike sem testes, aprenda o que precisava, apague e comece de novo com test-first. Tentar fazer TDD em território desconhecido produz testes para palpites.

Código altamente concorrente. Um teste que passa noventa e nove vezes em cem é pior do que nenhum teste. Concorrência exige designs determinísticos, rodadas de estresse e ferramentas especializadas, e não o ciclo comum.

As objeções, respondidas com honestidade

"Isso dobra a quantidade de código." Mais ou menos, sim — o código de teste costuma ter tamanho comparável ao de produção. A questão é se esse código se paga com menos depuração, refatorações mais seguras e documentação viva. Em uma base de código que será mantida por anos, normalmente sim; em um script que roda uma vez, não.

"As evidências não são conclusivas." Correto. Estudos empíricos comparando test-first com test-after relatam efeitos que vão de uma redução relevante de defeitos até nenhuma diferença significativa, com resultados igualmente variados quanto ao esforço. Boa parte do benefício medido pode vir de escrever unidades pequenas e muitos testes, e não da ordem em que são escritos. Isso é motivo para ser modesto nas afirmações, não para deixar de testar.

"Meus testes quebram toda vez que eu refatoro." Então os testes estão verificando estrutura em vez de comportamento — geralmente chamadas demais a verify, ou testes escritos contra métodos privados que ganharam visibilidade de pacote. Verifique o que quem chama consegue observar.

"Não temos tempo." O tempo é gasto de qualquer jeito; a escolha é entre escrever testes e reverificar o comportamento manualmente toda vez que algo muda. A versão honesta dessa objeção geralmente é sobre o prazo de um protótipo, e para um protótipo realmente descartável é uma troca razoável — desde que o protótipo seja de fato jogado fora.

"A cobertura já está alta, então estamos bem." Cobertura mede execução, não asserção. Uma suíte pode executar todas as linhas sem verificar quase nada. O que o TDD produz não é cobertura, e sim a garantia de que cada comportamento teve um teste que falhava na sua ausência, uma propriedade muito mais forte — e a que o guia de estratégia de automação defende que seja preferida a uma meta de cobertura.

Principais conclusões

  • O ciclo é red, green, refactor — e é a etapa de refatoração que transforma uma suíte de testes em uma prática de design, e não apenas em uma rede de segurança.
  • Escreva o código mais simples que passe, inclusive constantes; é o próximo teste que força a generalização, e a duplicação que ele cria é o que revela a estrutura certa.
  • A pressão do test-first torna o código construível, observável e pequeno, e adia as abstrações até que um segundo caso de uso as justifique.
  • Escolha outside-in para descobrir interfaces na fronteira de uma funcionalidade e inside-out para unidades com lógica de verdade; a maioria dos times usa os dois na mesma funcionalidade.
  • Empurre as dependências para fora, prefira fakes a mocks e trate "este teste precisa de quatro stubs" como um problema de design, e não de preparação.
  • Seja honesto sobre os limites: código legado precisa primeiro de costuras, algoritmos precisam primeiro ser entendidos, e as evidências empíricas sustentam afirmações modestas, não grandiosas.

Fontes e leituras complementares

Perguntas frequentes

TDD é mais lento do que escrever o código primeiro?
Por funcionalidade, escrever a primeira versão costuma ser um pouco mais lento, porque você paga pelos testes logo de início em vez de nunca pagar. Ao longo da vida de uma base de código, a comparação muda: o tempo de depuração cai bastante, porque as falhas aparecem segundos depois de serem introduzidas, e refatorar deixa de ser arriscado. Estudos controlados mostram efeitos variados sobre densidade de defeitos e esforço, então desconfie de promessas de ganhos de velocidade espetaculares e avalie na sua própria base de código.
Preciso escrever um teste antes de cada linha?
Não. A disciplina é que nenhum comportamento de produção é adicionado sem um teste falhando que o exija, o que não é o mesmo que um teste por linha. Trabalho exploratório, spikes e protótipos funcionam melhor sem testes e depois são descartados ou reescritos com test-first. Configuração, código gerado e delegação trivial raramente se beneficiam. É sendo dogmático com isso que times acabam escrevendo testes para getters.
Qual é a diferença entre a escola de Londres e a de Chicago?
A escola de Londres (mockista, outside-in) começa na fronteira do sistema, substitui cada colaborador por um mock à medida que o descobre e conduz o design de cima para baixo por meio de interfaces. A escola de Chicago (clássica, inside-out) começa pelos objetos de domínio, usa colaboradores reais sempre que são baratos e cresce para fora. Londres dá feedback mais rápido sobre a estrutura e testes mais frágeis; Chicago dá testes mais robustos e descobre as interfaces mais tarde. A maioria dos times mistura as duas.
Dá para aplicar TDD em código legado sem testes?
Não diretamente, porque código legado geralmente não pode ser instanciado em um teste sem arrastar metade da aplicação junto. O caminho padrão é primeiro adicionar um teste de caracterização em torno do comportamento existente, na fronteira mais grossa que estiver ao alcance, usá-lo como rede de segurança para introduzir uma costura (seam) — extrair uma interface, adicionar um parâmetro ao construtor, quebrar uma dependência estática — e então trabalhar com test-first dentro dessa costura. A rede cresce uma brecha de cada vez.