Como escrever testes automatizados que verificam o código a cada mudança, organizar esses testes no projeto e ler o que eles dizem quando falham.
"Fronteiras Explícitas" terminou com uma constatação: até aqui, todo código do curso foi verificado à mão, rodando o programa e conferindo a saída. Essa verificação acontece uma vez e depois é esquecida. Na próxima mudança, ninguém repete, e um defeito introduzido num canto do código só aparece quando alguém, geralmente um usuário, o encontra. Testes automatizados transformam cada verificação num pequeno programa, que roda em milissegundos, quantas vezes for preciso. Nesta aula você vai usar o JUnit, a ferramenta de testes padrão do Java, e o AssertJ, uma biblioteca que torna as verificações mais legíveis, para testar o projeto de tarefas.
Por que testar automaticamente
Um teste automatizado é um código que executa um trecho do programa numa situação conhecida e verifica se o resultado é o esperado. A vantagem não está no primeiro teste, e sim no centésimo, e na centésima execução deles. Com uma boa coleção de testes:
- Mudar o código deixa de ser arriscado. Depois de uma alteração, os testes dizem em segundos se algo que funcionava parou de funcionar. É isso que permite melhorar o código continuamente, em vez de deixá-lo apodrecer por medo de mexer.
- Os defeitos aparecem perto da causa. Um teste que falha logo depois de uma mudança aponta exatamente o que quebrou, enquanto um defeito descoberto semanas depois exige investigação.
- Os testes documentam o comportamento. Um teste chamado
tarefaQueVenceHojeAindaNaoEstaAtrasadaresponde uma pergunta sobre as regras do sistema com mais precisão do que qualquer comentário, e nunca fica desatualizado, porque se ficar, falha.
Os testes desta aula são testes unitários: verificam uma unidade pequena, como uma classe ou um método, isolada do resto, sem banco de dados, rede ou arquivos. São os mais rápidos e mais numerosos. Testes de integração, que verificam várias partes funcionando juntas com recursos reais, aparecem na trilha de web, em "Bancos de Verdade nos Testes".
Configurando o projeto
Com o Maven de "Construção Automatizada", os testes ficam em src/test/java, nos mesmos pacotes das classes testadas:
src/
├── main/java/br/com/exemplo/tarefas/modelo/Tarefa.java
└── test/java/br/com/exemplo/tarefas/modelo/TarefaTest.java
Por convenção, a classe de testes tem o nome da classe testada mais Test. As dependências vão no pom.xml, com escopo test:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.0.0</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>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
</plugins>
</build>
O JUnit publica um BOM, o recurso que "Construção Automatizada" apresentou: o junit-bom alinha as versões de todos os seus módulos. Por isso a dependência junit-jupiter não precisa de versão. O JUnit 6, lançado em setembro de 2025, é a versão atual. Ele continua a geração anterior, o JUnit 5, com os mesmos pacotes e anotações, e exige Java 17 ou mais novo. Tudo nesta aula vale para os dois. O plugin Surefire é o que o Maven usa para rodar os testes na fase test. No Gradle, o equivalente é declarar as dependências com testImplementation e acrescentar tasks.test { useJUnitPlatform() }. As versões eram as vigentes quando este artigo foi escrito, e devem ser conferidas antes de usar.
O primeiro teste
Os testes de Tarefa, o record de "Do Arquivo Solto ao Projeto":
package br.com.exemplo.tarefas.modelo;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
class TarefaTest {
private static final LocalDate HOJE = LocalDate.of(2026, 9, 25);
@Test
void concluirDevolveUmaTarefaConcluidaSemAlterarAOriginal() {
Tarefa original = new Tarefa(1, "Estudar", HOJE, false);
Tarefa concluida = original.concluir();
assertTrue(concluida.concluida());
assertFalse(original.concluida());
assertEquals(original.titulo(), concluida.titulo());
}
@Test
void tarefaComPrazoVencidoENaoConcluidaEstaAtrasada() {
Tarefa tarefa = new Tarefa(1, "Pagar conta", HOJE.minusDays(1), false);
assertTrue(tarefa.atrasada(HOJE));
}
@Test
void tarefaQueVenceHojeAindaNaoEstaAtrasada() {
Tarefa tarefa = new Tarefa(1, "Pagar conta", HOJE, false);
assertFalse(tarefa.atrasada(HOJE));
}
@Test
void tarefaSemPrazoNuncaFicaAtrasada() {
Tarefa tarefa = new Tarefa(1, "Ler um livro", null, false);
assertFalse(tarefa.atrasada(HOJE.plusYears(10)));
}
@Test
void tituloEmBrancoEhRecusado() {
IllegalArgumentException erro = assertThrows(IllegalArgumentException.class,
() -> new Tarefa(1, " ", HOJE, false));
assertEquals("O título não pode ser vazio", erro.getMessage());
}
}
Cada método anotado com @Test é um teste. O JUnit cria uma instância nova da classe para cada teste e o executa, exatamente como o executor de "O Programa que se Examina", só que com muito mais recursos. Nem a classe nem os métodos precisam ser públicos: o acesso de pacote basta, e é a convenção.
As asserções são as verificações. assertEquals(esperado, obtido) compara dois valores com equals, e a ordem dos argumentos importa para a mensagem de erro. assertTrue e assertFalse verificam condições. assertThrows verifica que um trecho lança uma exceção do tipo indicado, e devolve a exceção para que ela também possa ser examinada. O trecho é passado como uma lambda, para que o JUnit possa executá-lo dentro de um try. O import static de "Do Arquivo Solto ao Projeto" permite chamar as asserções sem o nome da classe.
A estrutura de um teste
Repare que cada teste tem três blocos, separados por linhas em branco:
- Preparar (arrange): criar os objetos e o cenário.
- Agir (act): executar a operação sob teste, geralmente uma única chamada.
- Verificar (assert): conferir o resultado.
Esse padrão, também chamado de dado, quando, então (given, when, then), deixa claro o que cada teste verifica. Um teste que mistura várias ações e verificações intercaladas testa coisas demais, e quando falha não diz exatamente o quê.
Os nomes dos testes são frases que descrevem o comportamento, e não o método: tarefaQueVenceHojeAindaNaoEstaAtrasada, e não testAtrasada2. Um nome assim documenta uma regra de negócio, a de que o prazo vence no fim do dia, que poderia ser discutida e decidida de outra forma. Os testes também cobrem os casos de fronteira: o prazo de hoje, o prazo nulo, o título em branco. É neles que os defeitos costumam se esconder.
Rodando e lendo o resultado
mvn test compila o código e os testes e roda todos. No fim, o Surefire mostra um resumo:
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
As IDEs rodam os testes com um clique, e mostram cada um com uma marca verde ou vermelha. Para ver o que acontece quando um teste falha, basta introduzir um defeito: se o método atrasada usasse !prazo.isAfter(hoje) em vez de prazo.isBefore(hoje), a tarefa que vence hoje passaria a ser considerada atrasada, e o teste correspondente falharia:
[ERROR] TarefaTest.tarefaQueVenceHojeAindaNaoEstaAtrasada:35 expected: <false> but was: <true>
[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
A mensagem aponta o teste, a linha e a diferença entre o esperado e o obtido. O build falha, e o JAR não é gerado. É a garantia de "Construção Automatizada": código que não passa nos testes não é entregue. O Surefire também distingue falhas (uma asserção não se confirmou) de erros (o teste lançou uma exceção inesperada), a mesma distinção do exercício 5 de "O Programa que se Examina".
Preparação comum, nomes legíveis e grupos
Os testes de ListaDeTarefas mostram mais recursos do JUnit:
package br.com.exemplo.tarefas.servico;
import br.com.exemplo.tarefas.modelo.Tarefa;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Lista de tarefas")
class ListaDeTarefasTest {
private static final LocalDate HOJE = LocalDate.of(2026, 9, 25);
private ListaDeTarefas lista;
@BeforeEach
void criarLista() {
lista = new ListaDeTarefas();
}
@Test
@DisplayName("uma lista nova não tem pendências")
void listaNovaVazia() {
assertThat(lista.pendentes()).isEmpty();
assertEquals(0, lista.total());
}
@Test
@DisplayName("tarefas recebem ids sequenciais a partir de 1")
void idsSequenciais() {
Tarefa a = lista.adicionar("A", null);
Tarefa b = lista.adicionar("B", null);
assertAll(
() -> assertEquals(1, a.id()),
() -> assertEquals(2, b.id()),
() -> assertEquals(2, lista.total()));
}
@Nested
@DisplayName("ao concluir")
class AoConcluir {
@Test
@DisplayName("a tarefa sai das pendentes")
void saiDasPendentes() {
lista.adicionar("Estudar", null);
lista.adicionar("Revisar", null);
lista.concluir(1);
assertThat(lista.pendentes())
.extracting(Tarefa::titulo)
.containsExactly("Revisar");
}
@Test
@DisplayName("um id inexistente devolve vazio e não altera nada")
void idInexistente() {
lista.adicionar("Estudar", null);
assertTrue(lista.concluir(99).isEmpty());
assertThat(lista.pendentes()).hasSize(1);
}
}
@Test
@DisplayName("atrasadas inclui só as pendentes com prazo vencido")
void atrasadas() {
lista.adicionar("Vencida", HOJE.minusDays(3));
lista.adicionar("No prazo", HOJE.plusDays(1));
lista.adicionar("Sem prazo", null);
lista.adicionar("Vencida mas feita", HOJE.minusDays(5));
lista.concluir(4);
assertThat(lista.atrasadas(HOJE))
.extracting(Tarefa::titulo)
.containsExactly("Vencida");
}
@Test
@DisplayName("a lista devolvida por pendentes não pode ser alterada por fora")
void pendentesImutavel() {
lista.adicionar("Estudar", null);
assertThrows(UnsupportedOperationException.class,
() -> lista.pendentes().clear());
}
}
@BeforeEachmarca um método executado antes de cada teste. Como cada teste roda numa instância nova e recebe uma lista nova, nenhum teste enxerga o que outro fez. Existem também@AfterEach, e@BeforeAlle@AfterAll, executados uma vez para a classe inteira.@DisplayNamedá aos testes e às classes nomes legíveis, com espaços e acentos, que aparecem nos relatórios.@Nestedagrupa testes relacionados numa classe interna, a construção de "Ordem e Critério", que tem acesso aos campos da classe de fora. O relatório mostra a hierarquia: "Lista de tarefas > ao concluir > a tarefa sai das pendentes".assertAllexecuta todas as asserções e relata todas as que falharam, em vez de parar na primeira. É útil quando várias propriedades do mesmo resultado precisam ser conferidas.
Repare no teste listaNovaVazia: ele usa lista.total(), um método com acesso de pacote. Como o teste está no mesmo pacote que ListaDeTarefas, embora numa pasta diferente, ele enxerga esse método. É o uso do acesso de pacote antecipado em "Do Arquivo Solto ao Projeto".
AssertJ: asserções que se leem
As asserções do JUnit funcionam, mas ficam limitadas quando o resultado é uma coleção ou um objeto complexo. O AssertJ oferece asserções fluentes, encadeadas a partir de assertThat(valor), que se leem quase como frases:
assertThat(lista.atrasadas(HOJE))
.extracting(Tarefa::titulo)
.containsExactly("Vencida");
Lê-se: "verifique que as atrasadas, extraindo os títulos, contêm exatamente 'Vencida'". Com as asserções do JUnit, o mesmo exigiria montar a lista de títulos à mão e compará-la com assertEquals. E a mensagem de falha do AssertJ é muito mais útil, porque mostra o conteúdo real e o esperado:
Expecting actual:
["Vencida", "Vencida mas feita"]
to contain exactly (and in same order):
["Vencida"]
but some elements were not expected:
["Vencida mas feita"]
O AssertJ tem asserções específicas para cada tipo. As mais usadas:
assertThat(texto).startsWith("Olá").contains("mundo").hasSize(10);
assertThat(numero).isPositive().isBetween(1, 10);
assertThat(lista).hasSize(3).contains("a").doesNotContain("z");
assertThat(lista).containsExactlyInAnyOrder("c", "a", "b");
assertThat(opcional).isPresent().contains(valor);
assertThatThrownBy(() -> new Tarefa(1, " ", null, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("título");
Depois de digitar assertThat(x)., a IDE mostra as asserções disponíveis para o tipo de x, e isso torna a biblioteca fácil de descobrir. Muitos projetos usam o JUnit para estruturar e rodar os testes e o AssertJ para as asserções.
Testes parametrizados
Quando o mesmo teste precisa rodar com vários valores, repetir o método é desperdício. Uma classe utilitária que calcula a prioridade de uma tarefa pelo número de pontos de exclamação no título, com um máximo de três:
package br.com.exemplo.tarefas.texto;
public final class Prioridade {
private Prioridade() { }
public static int deTitulo(String titulo) {
int exclamacoes = 0;
for (char c : titulo.toCharArray()) {
if (c == '!') {
exclamacoes++;
}
}
return Math.min(exclamacoes, 3);
}
}
Um teste parametrizado roda uma vez para cada conjunto de valores:
class PrioridadeTest {
@ParameterizedTest
@CsvSource({
"'Estudar', 0",
"'Pagar conta!', 1",
"'Entregar o trabalho!!', 2",
"'Consertar o vazamento!!!', 3",
"'URGENTE!!!!!!', 3"
})
void prioridadeEhONumeroDeExclamacoesAteTres(String titulo, int esperada) {
assertEquals(esperada, Prioridade.deTitulo(titulo));
}
@ParameterizedTest
@ValueSource(strings = {"", "sem pontuação", "pergunta?"})
void tituloSemExclamacaoTemPrioridadeZero(String titulo) {
assertEquals(0, Prioridade.deTitulo(titulo));
}
}
@ParameterizedTest substitui o @Test. @CsvSource fornece linhas de valores separados por vírgula, que o JUnit converte para os tipos dos parâmetros do método, e as aspas simples delimitam textos. @ValueSource fornece um só valor por execução. Cada linha aparece como um teste separado no relatório, e uma falha mostra exatamente qual combinação falhou. Testes parametrizados deixam barato cobrir os casos de fronteira, como o texto vazio e o excesso de exclamações. (Os testes parametrizados ficam num módulo à parte, junit-jupiter-params, que a dependência junit-jupiter já inclui.)
O que torna um teste bom
Uma sigla resume as qualidades de um bom teste unitário: FIRST.
- Fast (rápido): milissegundos, para que rodar todos os testes a cada mudança seja natural.
- Independent (independente): nenhum teste depende de outro, nem da ordem de execução. O JUnit não garante a ordem, e uma instância nova por teste ajuda nisso.
- Repeatable (repetível): o mesmo resultado sempre, em qualquer máquina e a qualquer hora. Um teste que depende de
LocalDate.now()passa hoje e falha amanhã, e por isso os testes desta aula usam uma data fixa, e os métodos testados recebem a data como parâmetro. - Self-validating (autoverificável): o teste diz sozinho se passou ou falhou. Um teste que só imprime valores para alguém conferir não é um teste.
- Timely (oportuno): escrito junto com o código, e não meses depois.
Há também uma prática que leva o último ponto ao extremo: o desenvolvimento guiado por testes (TDD, test-driven development), em que o teste é escrito antes do código. O ciclo é curto: escrever um teste que falha, porque a funcionalidade ainda não existe; escrever o mínimo de código para ele passar; melhorar o código com os testes garantindo que nada quebrou. O exercício 5 propõe um ciclo desses. Mesmo quem não pratica TDD o tempo todo se beneficia de uma lição dele: código difícil de testar costuma ser código mal projetado, com responsabilidades demais ou dependências escondidas.
Os testes desta aula tiveram uma vida fácil: Tarefa e ListaDeTarefas não dependem de nada externo. Mas um serviço real envia e-mails, consulta um banco de dados, lê o relógio do sistema. Testar esse tipo de código exige substituir as dependências por versões controladas, como o notificador registrador de "Contratos sem Implementação" já sugeria.
Fontes e leituras recomendadas
- JUnit User Guide: https://docs.junit.org/current/user-guide/. O guia oficial, com todas as anotações, os testes parametrizados, as extensões e a configuração com Maven e Gradle.
- AssertJ, "Core assertions guide": https://assertj.github.io/doc/. A documentação do AssertJ, com as asserções para cada tipo e exemplos de mensagens de erro.
- Kent Beck, TDD: Desenvolvimento Guiado por Testes (Bookman): o livro que definiu o TDD, escrito pelo co-criador do JUnit original. Curto, prático, e ainda a melhor introdução ao ciclo.
- Ham Vocke, "The Practical Test Pyramid" (martinfowler.com, 2018): https://martinfowler.com/articles/practical-test-pyramid.html. Um artigo longo e muito prático sobre os tipos de teste e a proporção entre eles.
- Vladimir Khorikov, Unit Testing Principles, Practices, and Patterns (Manning, 2020): um livro moderno sobre o que torna um teste valioso, com critérios claros para decidir o que testar e como.
Exercícios
Exercício 1
Escreva um teste parametrizado para o método Textos.slug da biblioteca de "Do Arquivo Solto ao Projeto", cobrindo acentos, espaços extras, pontuação e maiúsculas.
Ver resposta
✓ Resposta:
class TextosTest {
@ParameterizedTest
@CsvSource({
"'Olá Mundo', 'ola-mundo'",
"' espaços nas pontas ', 'espacos-nas-pontas'",
"'Relatório de Ações — Março/2026', 'relatorio-de-acoes-marco-2026'",
"'JAVA', 'java'",
"'!!!', ''"
})
void slugGeraTextoSeguroParaNomesDeArquivo(String entrada, String esperado) {
assertEquals(esperado, Textos.slug(entrada));
}
}
Cada linha cobre um aspecto: acentos com espaço, espaços extras nas pontas e no meio, pontuação e travessão, maiúsculas, e um caso extremo, um texto só com pontuação, que resulta num slug vazio. Esse último caso levanta uma questão de projeto que o teste torna visível: um nome de arquivo vazio, como ".pdf", é aceitável? Talvez slug devesse recusar entradas assim ou devolver um valor padrão. Os testes parametrizados deixam essa conversa barata, porque acrescentar um caso é acrescentar uma linha.
Exercício 2
Aponte os problemas do teste abaixo e reescreva-o:
@Test
void teste1() {
ListaDeTarefas lista = new ListaDeTarefas();
lista.adicionar("A", LocalDate.now().minusDays(1));
lista.adicionar("B", null);
IO.println(lista.pendentes());
lista.concluir(1);
assertEquals(1, lista.pendentes().size());
assertEquals(0, lista.atrasadas(LocalDate.now()).size());
}
Ver resposta
✓ Resposta: Os problemas: (1) o nome teste1 não diz o que é verificado; (2) o IO.println não verifica nada, e um teste deve ser autoverificável; (3) testa duas coisas diferentes, a conclusão e o cálculo de atrasadas, então uma falha não diz qual comportamento quebrou; (4) usa LocalDate.now(), o que o torna dependente do relógio: é pouco provável, mas um teste que roda exatamente na virada da meia-noite pode ver datas diferentes nas duas chamadas. Reescrito em dois testes:
private static final LocalDate HOJE = LocalDate.of(2026, 9, 25);
@Test
void concluirRemoveATarefaDasPendentes() {
ListaDeTarefas lista = new ListaDeTarefas();
lista.adicionar("A", null);
lista.adicionar("B", null);
lista.concluir(1);
assertThat(lista.pendentes()).extracting(Tarefa::titulo).containsExactly("B");
}
@Test
void tarefaConcluidaNaoContaComoAtrasada() {
ListaDeTarefas lista = new ListaDeTarefas();
lista.adicionar("A", HOJE.minusDays(1));
lista.concluir(1);
assertThat(lista.atrasadas(HOJE)).isEmpty();
}
Cada teste tem um nome que descreve uma regra, uma ação e uma verificação precisa, com data fixa.
Exercício 3
Qual é a diferença entre escrever três assertEquals seguidos e agrupá-los num assertAll? Quando cada forma é preferível?
Ver resposta
✓ Resposta: Com asserções seguidas, a primeira que falhar lança a exceção e encerra o teste, e as seguintes nem são executadas. Você vê um problema de cada vez: corrige, roda de novo e talvez descubra o segundo. Com assertAll, todas as asserções são executadas, e o relatório lista todas as que falharam de uma vez. assertAll é preferível quando as asserções verificam propriedades independentes do mesmo resultado, como os campos de um objeto, e saber que três delas estão erradas ao mesmo tempo ajuda a diagnosticar. Asserções seguidas são preferíveis quando uma depende da outra: se a primeira verifica que uma lista não está vazia e a segunda acessa o primeiro elemento, não faz sentido executar a segunda depois de a primeira falhar, porque ela lançaria uma exceção sem sentido.
Exercício 4
Por que os testes ficam numa pasta separada (src/test/java), mas no mesmo pacote da classe testada? O que se perderia com cada alternativa: testes em src/main/java, ou num pacote diferente?
Ver resposta
✓ Resposta: A pasta separada garante que os testes não vão para o JAR que é entregue: o Maven compila src/test/java só para rodar os testes, e o empacotamento inclui apenas src/main/java. Com os testes em src/main/java, eles iriam para produção, e com eles as bibliotecas de teste, aumentando o tamanho e a superfície de ataque do programa. O mesmo pacote dá aos testes acesso aos membros com acesso de pacote, como total(), permitindo verificar detalhes internos sem torná-los públicos. Num pacote diferente, os testes só enxergariam a interface pública, o que alguns consideram até mais saudável, porque obriga a testar o comportamento visível. Mas às vezes é preciso verificar um estado interno, e aí a alternativa seria abrir o método para todo mundo só por causa dos testes. A convenção de mesmo pacote e pasta separada combina as duas vantagens.
Exercício 5
Pratique um ciclo de TDD: a lista de tarefas precisa de um método renomear(int id, String novoTitulo), que devolve a tarefa renomeada, ou vazio se o id não existir, e que recusa títulos em branco. Escreva primeiro os testes e depois a implementação.
Ver resposta
✓ Resposta: Primeiro, os testes, que não compilam, porque o método não existe:
@Test
void renomearAlteraOTituloMantendoOResto() {
lista.adicionar("Estudar", HOJE);
Optional<Tarefa> renomeada = lista.renomear(1, "Estudar módulos");
assertThat(renomeada).isPresent();
assertEquals("Estudar módulos", renomeada.get().titulo());
assertEquals(HOJE, renomeada.get().prazo());
assertThat(lista.pendentes()).extracting(Tarefa::titulo).containsExactly("Estudar módulos");
}
@Test
void renomearIdInexistenteDevolveVazio() {
assertThat(lista.renomear(42, "Qualquer")).isEmpty();
}
@Test
void renomearParaTituloEmBrancoEhRecusado() {
lista.adicionar("Estudar", null);
assertThrows(IllegalArgumentException.class, () -> lista.renomear(1, " "));
}
Depois, a implementação mínima que faz os três passarem:
public Optional<Tarefa> renomear(int id, String novoTitulo) {
for (int i = 0; i < tarefas.size(); i++) {
Tarefa atual = tarefas.get(i);
if (atual.id() == id) {
Tarefa renomeada = new Tarefa(atual.id(), novoTitulo, atual.prazo(), atual.concluida());
tarefas.set(i, renomeada);
return Optional.of(renomeada);
}
}
return Optional.empty();
}
A recusa do título em branco não exigiu código novo: o construtor do record já a faz, e o teste confirma que o comportamento se mantém. Com os testes passando, vem a etapa de melhoria: concluir e renomear têm o mesmo laço de busca, que pode virar um método privado que recebe a transformação como uma UnaryOperator<Tarefa>, a técnica de "Comportamento como Valor". Os testes garantem que a refatoração não quebra nada.