Bizu de TDD: escrevendo o teste antes do código com Java e JUnit

· CaioSimioni · Artigos

TDD (Test-Driven Development, ou desenvolvimento guiado por testes) é a prática de escrever o teste antes do código que ele testa. Primeiro você descreve, em forma de teste, o que o código deve fazer. Só depois escreve o mínimo de código para o teste passar. Este bizu mostra o ciclo completo com Java e JUnit, construindo do zero um gerador de slug (o alias amigável de uma URL, como o dos artigos do Joomla): "Olá, Mundo!" vira ola-mundo.

O foco aqui é o método. O JUnit entra só com o básico necessário, e os recursos mais avançados ficam para um artigo à parte.

Visão geral

Fase O que você faz Como saber que terminou
Red (vermelho) Escreve um teste para um comportamento que ainda não existe. O teste falha, e pelo motivo certo.
Green (verde) Escreve o código mais simples que faz o teste passar. Todos os testes passam.
Refactor (refatorar) Melhora o código sem mudar o comportamento. Os testes continuam verdes.

A regra de ouro: não escreva código de produção sem antes ter um teste falhando. O teste vermelho é a prova de que ele realmente testa algo. Um teste que nunca falhou não merece confiança.

Configuração única (por máquina)

1. Instalar o Java e o Maven

mise use -g java@temurin-21
mise use -g maven@3

Feche e abra o terminal e confirme:

java -version
mvn -version
  • O JUnit 6 exige no mínimo o Java 17. Usamos o 21 por ser uma versão de suporte longo (LTS).
  • O Maven é quem baixa o JUnit e roda os testes com o comando mvn test.

Criar o projeto

1. Estrutura

slugificador/
├── pom.xml
├── .gitignore
└── src/
    ├── main/java/br/dev/simioni/slug/
    │   └── Slugificador.java
    └── test/java/br/dev/simioni/slug/
        └── SlugificadorTest.java

O código de produção vai em src/main/java e os testes em src/test/java, com o mesmo pacote. Crie as pastas e o pom.xml agora. As classes Java vamos criar depois, começando pelo teste.

mkdir slugificador
cd slugificador
mkdir src\main\java\br\dev\simioni\slug
mkdir src\test\java\br\dev\simioni\slug

2. O pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <groupId>br.dev.simioni</groupId>
  <artifactId>slugificador</artifactId>
  <version>0.1.0-SNAPSHOT</version>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.junit</groupId>
        <artifactId>junit-bom</artifactId>
        <version>6.1.3</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.6</version>
      </plugin>
    </plugins>
  </build>
</project>
  • O junit-bom fixa a versão do JUnit em um lugar só.
  • O maven-surefire-plugin é o que executa os testes. Ele precisa ser uma versão recente para enxergar o JUnit atual.
  • O sourceEncoding em UTF-8 é essencial aqui, porque os testes têm acentos (ç, á). Salve também os arquivos .java como UTF-8.
  • Versões mudam com o tempo. Confira as atuais em mvnrepository.com antes de copiar.

3. O .gitignore

target/
.idea/
*.iml
.vscode/

Os requisitos viram uma lista de testes

Antes de escrever qualquer coisa, anote em papel o que o gerador de slug deve fazer. Cada linha vai virar um teste:

# Comportamento Entrada → saída esperada
1 Deixar em minúsculas Git → git
2 Trocar espaços por hífen Bizu de Git → bizu-de-git
3 Remover acentos Configuração → configuracao
4 Remover caracteres especiais Olá, Mundo! → ola-mundo
5 Juntar separadores repetidos e limpar as pontas   Git   &  Forgejo   → git-forgejo
6 Recusar título nulo ou vazio "   " → erro

Você não precisa prever tudo de uma vez. A lista cresce conforme você aprende. O importante é pegar um item por vez, começando pelo mais simples.

Ciclo 1: o teste antes de existir qualquer código

Red: escrever o teste

Crie src/test/java/br/dev/simioni/slug/SlugificadorTest.java com apenas o primeiro teste. A classe Slugificador ainda não existe, e é isso mesmo que queremos:

package br.dev.simioni.slug;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

class SlugificadorTest {

    @Test
    void deveDeixarPalavraEmMinusculas() {
        assertEquals("git", Slugificador.gerar("Git"));
    }
}
  • @Test marca o método como teste.
  • assertEquals(esperado, obtido) compara os dois valores. A ordem importa: o valor esperado vem primeiro.
  • O nome do teste descreve o comportamento (deve...), não o método testado.

Rode:

mvn test

A primeira falha é de compilação, e ela conta como vermelho:

[ERROR] COMPILATION ERROR :
[ERROR] SlugificadorTest.java:[10,32] cannot find symbol
  symbol:   variable Slugificador

Agora crie o esqueleto em src/main/java/br/dev/simioni/slug/Slugificador.java, só o suficiente para compilar, sem implementar nada:

package br.dev.simioni.slug;

public final class Slugificador {

    private Slugificador() {
    }

    public static String gerar(String titulo) {
        return "";
    }
}

Rode de novo. Agora o vermelho é o que importa, porque o teste compila e falha pelo motivo certo (resultado errado):

[ERROR] Failures:
[ERROR]   SlugificadorTest.deveDeixarPalavraEmMinusculas:10 expected: <git> but was: <>
[INFO] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0

Green: o código mais simples possível

Troque o corpo do método:

public static String gerar(String titulo) {
    return titulo.toLowerCase();
}
mvn test
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Verde. Se quiser ir ainda mais devagar, faça o fake it: escreva return "git"; e só generalize quando um segundo teste obrigar. É útil para praticar, mas nem sempre necessário.

Ainda não há o que refatorar. Hora do próximo ciclo.

Ciclo 2: espaços viram hífen

Red: adicione dentro da classe de teste:

    @Test
    void deveTrocarEspacosPorHifen() {
        assertEquals("bizu-de-git", Slugificador.gerar("Bizu de Git"));
    }

Ao rodar, só o novo teste falha (expected: <bizu-de-git> but was: <bizu de git>). O primeiro continua verde.

Green:

return titulo.toLowerCase().replace(' ', '-');

Ciclo 3: remover acentos

Red:

    @Test
    void deveRemoverAcentos() {
        assertEquals("configuracao", Slugificador.gerar("Configuração"));
    }

Green: decompõe cada letra acentuada em letra mais acento e descarta os acentos:

public static String gerar(String titulo) {
    String semAcentos = Normalizer.normalize(titulo, Normalizer.Form.NFD)
            .replaceAll("\\p{M}+", "");
    return semAcentos.toLowerCase().replace(' ', '-');
}

Acrescente import java.text.Normalizer; no topo do arquivo.

Ciclo 4: remover caracteres especiais

Red:

    @Test
    void deveRemoverCaracteresEspeciais() {
        assertEquals("ola-mundo", Slugificador.gerar("Olá, Mundo!"));
    }

Green: acrescente uma linha ao final da cadeia:

return semAcentos.toLowerCase()
        .replace(' ', '-')
        .replaceAll("[^a-z0-9-]", "");

Ciclo 5: separadores repetidos e pontas

Red:

    @Test
    void deveColapsarSeparadoresEAparaBordas() {
        assertEquals("git-forgejo", Slugificador.gerar("  Git   &  Forgejo  "));
    }

Green: mais duas linhas, uma para juntar hífens repetidos e outra para tirar os das pontas:

return semAcentos.toLowerCase()
        .replace(' ', '-')
        .replaceAll("[^a-z0-9-]", "")
        .replaceAll("-{2,}", "-")
        .replaceAll("^-|-$", "");

Todos os cinco testes passam. O código funciona, mas ficou uma cadeia de remendos. É a deixa para a terceira fase.

Refactor: melhorar com a rede de proteção

Agora que os testes estão verdes, você pode mexer no código sem medo. Duas melhorias:

  • Trocar a cadeia de cinco passos por apenas dois: "qualquer sequência que não seja letra ou número vira um único hífen" e "tira hífens das pontas".
  • Usar Locale.ROOT no toLowerCase, para o resultado não depender do idioma do computador (no turco, por exemplo, o I maiúsculo vira ı).
public static String gerar(String titulo) {
    String semAcentos = Normalizer.normalize(titulo, Normalizer.Form.NFD)
            .replaceAll("\\p{M}+", "");

    return semAcentos.toLowerCase(Locale.ROOT)
            .replaceAll("[^a-z0-9]+", "-")
            .replaceAll("^-+|-+$", "");
}

Acrescente import java.util.Locale; e rode mvn test. Se algum teste ficar vermelho, a refatoração quebrou algo: desfaça e tente de novo, em passos menores.

Ciclo 6: entrada inválida

Red: aqui o teste espera uma exceção:

    @Test
    void deveRejeitarTituloVazio() {
        assertThrows(IllegalArgumentException.class, () -> Slugificador.gerar("   "));
    }

Acrescente import static org.junit.jupiter.api.Assertions.assertThrows;. Hoje o método devolve "" sem reclamar, então o teste falha com Expected IllegalArgumentException to be thrown, but nothing was thrown.

Green: uma "cláusula de guarda" no início do método:

if (titulo == null || titulo.isBlank()) {
    throw new IllegalArgumentException("O título não pode ser vazio");
}

Resultado final

Slugificador.java

package br.dev.simioni.slug;

import java.text.Normalizer;
import java.util.Locale;

public final class Slugificador {

    private Slugificador() {
    }

    public static String gerar(String titulo) {
        if (titulo == null || titulo.isBlank()) {
            throw new IllegalArgumentException("O título não pode ser vazio");
        }

        String semAcentos = Normalizer.normalize(titulo, Normalizer.Form.NFD)
                .replaceAll("\\p{M}+", "");

        return semAcentos.toLowerCase(Locale.ROOT)
                .replaceAll("[^a-z0-9]+", "-")
                .replaceAll("^-+|-+$", "");
    }
}

SlugificadorTest.java

package br.dev.simioni.slug;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;

class SlugificadorTest {

    @Test
    void deveDeixarPalavraEmMinusculas() {
        assertEquals("git", Slugificador.gerar("Git"));
    }

    @Test
    void deveTrocarEspacosPorHifen() {
        assertEquals("bizu-de-git", Slugificador.gerar("Bizu de Git"));
    }

    @Test
    void deveRemoverAcentos() {
        assertEquals("configuracao", Slugificador.gerar("Configuração"));
    }

    @Test
    void deveRemoverCaracteresEspeciais() {
        assertEquals("ola-mundo", Slugificador.gerar("Olá, Mundo!"));
    }

    @Test
    void deveColapsarSeparadoresEAparaBordas() {
        assertEquals("git-forgejo", Slugificador.gerar("  Git   &  Forgejo  "));
    }

    @Test
    void deveRejeitarTituloVazio() {
        assertThrows(IllegalArgumentException.class, () -> Slugificador.gerar("   "));
    }
}

Seis testes e uma classe pequena, cada linha de código nascida de um teste que falhou antes.

Anatomia de um bom teste

Todo teste segue três passos, conhecidos como AAA:

Passo No exemplo
Arrange (preparar) Definir a entrada, como "Olá, Mundo!".
Act (agir) Chamar Slugificador.gerar(...).
Assert (verificar) Comparar com "ola-mundo" usando assertEquals.

Em testes pequenos como estes os três passos cabem em uma linha. Quando o teste crescer, separe-os com linhas em branco.

Regras do dia a dia

  • Passos pequenos: um comportamento por teste, um teste por ciclo.
  • Veja o vermelho: se um teste novo passa de primeira, desconfie. Ele pode estar testando a coisa errada.
  • Falhe pelo motivo certo: erro de compilação ou exceção inesperada não é o vermelho que você queria. Ajuste até a falha ser "esperado X, veio Y".
  • Código mínimo no verde: não antecipe o que ainda não tem teste.
  • Refatore só no verde: e nunca misture refatoração com comportamento novo.
  • Rode os testes o tempo todo: a cada pequena mudança, não só no fim.
  • Nome conta a história: a lista de nomes dos testes deve ler como a especificação do código.

Comandos úteis do Maven:

Comando Para que serve
mvn test Compila e roda todos os testes
mvn -q test Mesmo que acima, mostrando só o essencial
mvn test "-Dtest=SlugificadorTest" Roda só uma classe de teste
mvn test "-Dtest=SlugificadorTest#deveRemoverAcentos" Roda só um teste
mvn clean test Apaga a pasta target e roda tudo do zero

TDD combinado com Git

O TDD casa bem com o fluxo do Bizu de Git. Trabalhe em uma branch de feature e faça um commit a cada verde, com mensagens no padrão Conventional Commits:

git switch develop
git pull
git switch -c feature/slugificador

git add .
git commit -m "feat: converte título para minúsculas"
git commit -am "feat: troca espaços por hífen"
git commit -am "feat: remove acentos e caracteres especiais"
git commit -am "refactor: simplifica a geração do slug"
git commit -am "feat: rejeita título vazio"
  • Faça o commit no verde, não no vermelho. Assim cada commit deixa o projeto funcionando.
  • Evite dar git push com teste falhando, principalmente quando houver um pipeline de CI na branch.
  • Commits separados de refactor deixam claro o que mudou comportamento e o que só mudou a forma.

Problemas comuns

Sintoma Causa e solução
cannot find symbol ao rodar o primeiro teste É o esperado: a classe ainda não existe. Crie o esqueleto para chegar ao vermelho de verdade.
No tests were executed ou Tests run: 0 A classe de teste precisa estar em src/test/java, no mesmo pacote, e o nome deve terminar em Test. Confira também a versão do maven-surefire-plugin.
package org.junit.jupiter.api does not exist A dependência junit-jupiter não está no pom.xml, ou o junit-bom não foi importado.
release version 17 not supported O JDK ativo é mais antigo que o 17. Confira com java -version e ative o JDK 21.
Acentos aparecem quebrados ou o teste de acentos falha O arquivo não está em UTF-8 ou falta o project.build.sourceEncoding no pom.xml.
O teste novo já nasce verde O comportamento já existia, ou o teste está errado. Quebre o código de propósito para ver o teste falhar.
Depois do verde, outro teste ficou vermelho Seu código quebrou um comportamento antigo. É para isso que a lista de testes serve: ajuste sem apagar o teste.
mvn não é reconhecido Feche e abra o terminal depois de instalar, e confirme com mvn -version.

Exercícios para praticar

  1. O que o gerador deve fazer com "!!!", que vira vazio depois da limpeza? Escreva o teste primeiro e decida o comportamento (exceção ou valor padrão).
  2. Limite o slug a 60 caracteres sem cortar no meio de uma palavra.
  3. Faça "C++ e C#" virar c-e-c, e depois decida se isso é aceitável.

Em todos, o ciclo é o mesmo: teste vermelho, código verde, refatoração.

<< voltar para Artigos