Provando que Funciona — Testes com JUnit e AssertJ

Provando que Funciona — Testes com JUnit e AssertJ

Como escrever testes unitários com JUnit 6 e AssertJ: a estrutura preparar, agir e verificar, @BeforeEach, @Nested e @DisplayName, assertAll, testes parametrizados com @CsvSource, a leitura das mensagens de falha no Surefire, as qualidades FIRST de um bom teste e um primeiro ciclo de TDD.
Java

• • 19 min de leitura

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 tarefaQueVenceHojeAindaNaoEstaAtrasada responde 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:

  1. Preparar (arrange): criar os objetos e o cenário.
  2. Agir (act): executar a operação sob teste, geralmente uma única chamada.
  3. 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());
    }
}
  • @BeforeEach marca 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 @BeforeAll e @AfterAll, executados uma vez para a classe inteira.
  • @DisplayName dá aos testes e às classes nomes legíveis, com espaços e acentos, que aparecem nos relatórios.
  • @Nested agrupa 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".
  • assertAll executa 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.

Comentários

Mais em Java

O Capstone dos Jogos — o Plataforma Completo e Exportado
O Capstone dos Jogos — o Plataforma Completo e Exportado

Nesta aula o plataforma vira um jogo completo, A Ilha dos Blocos: menu, duas…

Um Tipo, Várias Formas — Polimorfismo na Prática
Um Tipo, Várias Formas — Polimorfismo na Prática

Polimorfismo em Java: tipo estático e tipo dinâmico, despacho dinâmico, trocar…

Ferramentas de Precisão — Locks, Atomics e Coleções Concorrentes
Ferramentas de Precisão — Locks, Atomics e Coleções Concorrentes

Quando o synchronized pesa demais, o java.util.concurrent oferece peças sob…