Bizu de TDD: escrevendo o teste antes do código com Java e JUnit
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-bomfixa 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
sourceEncodingem UTF-8 é essencial aqui, porque os testes têm acentos (ç,á). Salve também os arquivos.javacomo 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"));
}
}
@Testmarca 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.ROOTnotoLowerCase, para o resultado não depender do idioma do computador (no turco, por exemplo, oImaiú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 pushcom teste falhando, principalmente quando houver um pipeline de CI na branch. - Commits separados de
refactordeixam 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
- 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). - Limite o slug a 60 caracteres sem cortar no meio de uma palavra.
- Faça
"C++ e C#"virarc-e-c, e depois decida se isso é aceitável.
Em todos, o ciclo é o mesmo: teste vermelho, código verde, refatoração.