Fundamentos do JUnit 5
Objective
O JUnit 5 é um framework de ciclo de vida de teste: ele cria uma nova instância de teste por método, roda setup e teardown ao redor dela em uma ordem fixa, e avalia assertions (asserções) e assumptions (suposições) sobre o resultado. @ParameterizedTest, @RepeatedTest e @Nested estendem esse modelo básico para cobrir muitas entradas, execuções repetidas e sub-cenários agrupados sem duplicar código de teste.
Use Cases
- Agrupar setup/teardown ao redor de cada método de teste com
@BeforeEach/@AfterEach, e uma vez por classe com@BeforeAll/@AfterAll. - Falhar rápido diante de um valor errado com
assertEquals/assertTrue, ou agrupar várias verificações relacionadas comassertAllpara que todas sejam avaliadas e reportadas mesmo que uma falhe. - Pular um teste em tempo de execução, em vez de reprová-lo, quando uma pré-condição não é atendida, via
assumeTrue/assumeFalse. - Rodar a mesma lógica de teste contra muitas entradas com
@ParameterizedTeste uma fonte como@ValueSourceou@CsvSource, em vez de copiar e colar métodos@Testquase idênticos. - Repetir o mesmo teste um número fixo de vezes com
@RepeatedTest, para revelar instabilidade em código que envolve aleatoriedade ou tempo. - Organizar testes relacionados em uma hierarquia de classes aninhadas com
@Nested, para que o setup possa ser restrito a um sub-cenário. - Dar um nome legível a uma classe ou método de teste com
@DisplayName, exibido por IDEs e relatórios de build em vez do nome bruto do método.
Deep Dive
O ciclo de vida do teste
Toda classe de teste segue a mesma ordem fixa: @BeforeAll uma vez, então, para cada método @Test, o @BeforeEach de uma instância nova, o teste, @AfterEach, e por fim @AfterAll uma vez no final.
javaclass SUTTest {
private static ResourceForAllTests resourceForAllTests;
private SUT systemUnderTest;
@BeforeAll
static void setUpClass() {
resourceForAllTests = new ResourceForAllTests("shared resource");
}
@BeforeEach
void setUp() {
systemUnderTest = new SUT("fresh per test");
}
@Test
void testRegularWork() {
assertTrue(systemUnderTest.canReceiveRegularWork());
}
@AfterEach
void tearDown() {
systemUnderTest.close();
}
@AfterAll
static void tearDownClass() {
resourceForAllTests.close();
}
}Métodos @BeforeAll/@AfterAll precisam ser static, a menos que a classe esteja anotada @TestInstance(Lifecycle.PER_CLASS), porque por padrão o JUnit cria uma nova instância de teste por método de teste; ainda não há instância no momento em que @BeforeAll rodaria sobre ela.
Asserções vs. suposições
Métodos de Assertions reprovam o teste imediatamente quando a verificação não se sustenta; assertAll agrupa várias asserções para que todas rodem e sejam reportadas, mesmo que as anteriores já tenham falhado:
java@Test
void accountInvariants() {
Account account = new Account("1", 100);
assertAll("account",
() -> assertEquals("1", account.getId()),
() -> assertTrue(account.getBalance() > 0),
() -> assertThrows(IllegalArgumentException.class,
() -> account.withdraw(-1))
);
}Assumptions parecem semelhantes, mas significam algo diferente: quando uma suposição falha, o teste é abortado (reportado como pulado), não reprovado; útil para pré-condições que o teste não controla, como "só rode isto quando um valor de configuração estiver presente":
java@Test
void onlyRunsWithApiKeyConfigured() {
assumeTrue(System.getenv("API_KEY") != null);
// ... test that needs the API key
}Testes aninhados e marcados
@Nested agrupa testes ao redor de um sub-cenário, permitindo que uma classe interna tenha seu próprio @BeforeEach, que se compõe com o da classe externa:
javaclass AccountTest {
Account account;
@BeforeEach
void createAccount() {
account = new Account("1", 100);
}
@Nested
class WhenBalanceIsPositive {
@Test
void canWithdraw() {
account.withdraw(50);
assertEquals(50, account.getBalance());
}
}
}@Tag("slow") em uma classe ou método permite que um build selecione ou exclua subconjuntos de testes (por exemplo, mvn test -Dgroups=slow) sem mudar o código de teste, e @Disabled("reason") desliga um teste registrando o motivo, em vez de comentá-lo.
Testes parametrizados e repetidos
@ParameterizedTest roda um único método de teste uma vez por valor de origem, evitando métodos @Test quase duplicados:
java@ParameterizedTest
@ValueSource(strings = {"", " ", "\t"})
void blankStringsAreInvalid(String input) {
assertTrue(input.isBlank());
}
@ParameterizedTest
@CsvSource({"1,1,2", "2,3,5", "-1,1,0"})
void addsTwoNumbers(int a, int b, int expected) {
assertEquals(expected, a + b);
}@RepeatedTest(n) em vez disso reexecuta o mesmo teste n vezes sem entrada variável, injetando um RepetitionInfo se o método precisar saber em qual repetição está:
java@RepeatedTest(5)
void repeatedWithInfo(RepetitionInfo info) {
System.out.println("Run " + info.getCurrentRepetition() + " of " + info.getTotalRepetitions());
}Testes dinâmicos
@TestFactory gera casos de teste em tempo de execução em vez de declarar cada um em tempo de compilação; útil quando o conjunto de casos vem de dados em vez do próprio código-fonte:
java@TestFactory
Stream<DynamicTest> dynamicTestsForSquares() {
return IntStream.rangeClosed(1, 3)
.mapToObj(n -> DynamicTest.dynamicTest(
"square of " + n,
() -> assertEquals(n * n, square(n))));
}Trade-offs
@BeforeAllexigestatic(ou ciclo de vidaPER_CLASS): como uma nova instância de teste é criada por método de teste por padrão, um campo de instância ainda não está visível quando o método de setup (padrão, por classe) precisaria rodar:
javaclass Broken {
Resource r; // instance field
@BeforeAll
void setUp() { r = new Resource(); } // error: @BeforeAll method must be static
}- Suposições pulam, asserções reprovam: confundir os dois muda o que um build vermelho ou um teste pulado realmente significa, e dashboards de CI tratam os dois de forma bem diferente:
javaassumeTrue(false); // test reported as ABORTED/skipped, not failed
assertTrue(false); // test reported as FAILED- Testes parametrizados trocam legibilidade por cobertura: um método cobrindo dez entradas é menos código do que dez métodos
@Testquase idênticos, mas uma mensagem de falha agora precisa incluir qual entrada falhou, e depurar significa encontrar a linha certa na fonte, não o nome certo do método. - Testes dinâmicos não são detectáveis estaticamente: como
@TestFactorygeraDynamicTests em tempo de execução, IDEs não conseguem listá-los antes de uma execução da forma como listam métodos@Test, o que torna fluxos de "rodar só este caso" mais desajeitados.