JUnit 5 – założenia i twierdzenia
Istotą każdego testu jest sprawdzenie jakiegoś twierdzenia. Testy bez asercji są jak porno bez fabuły. Wszystko się jebie i w sumie nie wiadomo dlaczego. Dziś przyjrzymy się jakie możliwości w tym zakresie oferuje nam JUnit 5.
Założenia
Czyli to, co występuje pod nazwą Assumptions. Mechanizm ten znamy już z poprzedniej wersji frameworku i w nowej wersji nie został on zmieniony. Dodano
kilka metod tak, by w pełni wykorzystać możliwości Javy 8, ale poza tym nie ma znaczących zmian. Ok, ale czym są założenia? W poprzednim poście
omawialiśmy tworzenie własnych rozszerzeń. Pierwszy przykład opierał się o analizę adnotacji na metodach testowych i nieuruchamianie testów, które nie
spełniały pewnych warunków.
Założenia mają podobne zadanie. Jednak realizują je w inny sposób:
Listing 1. Przykład użycia Assumeptions
public class FizzBuzzJUnit5AssumeTest {
private FizzBuzz sut;
@BeforeEach
public void setup() {
sut = new FizzBuzz();
}
@Test
public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
Assumptions.assumeTrue(getEnvAssumption("CI"), "Not on CI or DEV");
assertEquals("FizzBuzz", sut.fizzBuzz(15));
}
@Test
public void shouldReturnBuzzIfDiv5() throws Exception {
Assumptions.assumeTrue(getEnvAssumption("GUI"), "Not on GUI or DEV");
assertEquals("Buzz", sut.fizzBuzz(5));
}
@Test
public void shouldReturnFizzIfDiv3() throws Exception {
Assumptions.assumeTrue(getEnvAssumption("NOGUI"), "Not on NOGUI or DEV");
assertEquals("Fizz", sut.fizzBuzz(3));
}
@Test
public void shouldReturnVal() throws Exception {
Assumptions.assumeTrue(getEnvAssumption(""), "Not on NOGUI or DEV");
assertEquals("2", sut.fizzBuzz(2));
}
private BooleanSupplier getEnvAssumption(String isIt) {
return () -> {
String envName = Optional.ofNullable(System.getenv("ci_name")).orElse("DEV");
return Optional.of(envName).map(s -> s.equals(isIt) || s.equals("DEV")).get();
};
}
}
Jeżeli teraz uruchomimy nasze testy w środowisku, gdzie zmienna ci_name
ma wartość inną niż CI
, GUI
, NOGUI
, DEV albo nie jest zdefiniowana, to otrzymamy komunikat:
Listing 2. Log uruchomienia testu:
org.opentest4j.TestAbortedException: Assumption failed: Not on CI or DEV
at org.junit.jupiter.api.Assumptions.throwTestAbortedException(Assumptions.java:246)
at org.junit.jupiter.api.Assumptions.assumeTrue(Assumptions.java:119)
at org.junit.jupiter.api.Assumptions.assumeTrue(Assumptions.java:80)
at pl.koziolekweb.blog.fizzbuzz.assertions.FizzBuzzJUnit5AssumeTest.shouldReturnFizzBuzzIfDiv3And5(FizzBuzzJUnit5AssumeTest.java:27)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:316)
at org.junit.jupiter.engine.execution.ExecutableInvoker.invoke(ExecutableInvoker.java:114)
at org.junit.jupiter.engine.descriptor.MethodTestDescriptor.lambda$invokeTestMethod$6(MethodTestDescriptor.java:171)
at org.junit.jupiter.engine.execution.ThrowableCollector.execute(ThrowableCollector.java:40)
at org.junit.jupiter.engine.descriptor.MethodTestDescriptor.invokeTestMethod(MethodTestDescriptor.java:168)
at org.junit.jupiter.engine.descriptor.MethodTestDescriptor.execute(MethodTestDescriptor.java:115)
at org.junit.jupiter.engine.descriptor.MethodTestDescriptor.execute(MethodTestDescriptor.java:57)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.lambda$execute$1(HierarchicalTestExecutor.java:81)
at org.junit.platform.engine.support.hierarchical.SingleTestExecutor.executeSafely(SingleTestExecutor.java:66)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:76)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.lambda$execute$1(HierarchicalTestExecutor.java:91)
at org.junit.platform.engine.support.hierarchical.SingleTestExecutor.executeSafely(SingleTestExecutor.java:66)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:76)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.lambda$execute$1(HierarchicalTestExecutor.java:91)
at org.junit.platform.engine.support.hierarchical.SingleTestExecutor.executeSafely(SingleTestExecutor.java:66)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:76)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:51)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:43)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:137)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:87)
at org.junit.platform.launcher.Launcher.execute(Launcher.java:93)
at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:61)
at com.intellij.rt.execution.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:51)
at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:242)
at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:70)
Test został przerwany, ale wyjątek TestAbortedException będzie obsłużony inaczej niż AssertionFailedError (btw, kolejna fajna zmiana nazwy). Test powinien zostać oznaczony jako niewykonany. Podobnie do testu oznaczonego @Disabled. Na czym polega różnica?
@Disabled oraz tagi
Adnotacji tej powinniśmy użyć, jeżeli chcemy wyłączyć test lub wszystkie testy w klasie. Podobnie z tagami. Używamy ich do przeprowadzania masowej akcji bez wnikania w szczegóły.
Rozszerzenie
Rozszerzenia są bardzo elastyczne i powinniśmy ich używać wszędzie tam, gdzie mamy do czynienia ze złożoną logiką. Jednocześnie należy pamiętać, że rozszerzenia są mechanizmem pozwalającym dodać coś do środowiska testowego, ale nie koniecznie. W dodatku ze względu na statyczną naturę adnotacji nie możemy z ich pomocą dynamicznie włączać i wyłączać testów. Rozszerzenia nie wiedzą nic o teście, a test nie wie nic o rozszerzeniu.
Założenia
Mechanizm założeń jest silnie powiązany z testami. Założenia definiujemy na tym samym poziomie co test. Możemy wykorzystać tu wiedzę o teście, by podjąć odpowiednią decyzję. Jest to bardzo elastyczne rozwiązanie, ale ortogonalne do rozszerzeń.
Wiemy już, czym są założenia, przejdźmy do twierdzeń.
Twierdzenia, czyli asercje
Pod tym względem JUnit 5 udostępnił na kilka fajnych rozwiązań.
Testy z wieloma asercjami
Czasami jest tak, że nasz test powinien być wykonany dla różnych danych wejściowych. Możemy do tego wykorzystać parametry. Jednak zdarza się sytuacja odwrotna. Test opiera się na wielu asercjach. W klasycznym podejściu test wysypie się, gdy pierwsza z asercji nie będzie spełniona. Utrudnia to pracę. Piszemy, odpalamy, poprawiamy jeden błąd tylko po to, by kolejne uruchomienie zwróciło nam kolejnego babola. Szczególnie bolesne jest, to gdy asercje nie są ze sobą silnie powiązane, ich kolejność nie gra roli. By rozwiązać ten problem, możemy użyć assertAll:
Listing 3. Użycie assertAll
public class FizzBuzzJUnit5AssertAllTest {
private FizzBuzz sut;
@BeforeEach
public void setup() {
sut = new FizzBuzz();
}
@Test
public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
assertAll(
() -> assertEquals("FizzBuzz", sut.fizzBuzz(16)),
() -> assertEquals("FizzBuzz", sut.fizzBuzz(30)),
() -> assertEquals("FizzBuzz", sut.fizzBuzz(151))
);
}
}
Po uruchomieniu tego testu zobaczymy dwa błędy. Od razu otrzymamy pełen obraz problemu. Jest to przydatna informacja jeżeli w naszym teście poza asercjami, dokonujemy weryfikacji mocków. Wtedy mamy elegancko rozdzielone informacje. Kod zwraca poprawny wynik, ale nie wywołuje jakiejś operacji pod spodem.
Testowanie wyjątków
Testowanie czy kod zwrócił wyjątek jest wybitnie upierdliwe. Co prawda mogliśmy użyć @Test(expected), ale to było złe na wielu poziomach. Potem pojawiła się regułka (@Rule) ExpectedException, Ewentualnie można było sobie wyrzeźbić odpowiednią asercję albo użyć AssertJ, ale dopiero to ostatnie rozwiązanie w połączeniu z Javą 8 było sensowne. JUnit 5 wprowadza podobne rozwiązanie:
Listing 4. Użycie assertThrows
public class FizzBuzzJUnit5AssertExceptionTest {
private FizzBuzz sut;
@Test
public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
assertThrows(NullPointerException.class, () -> assertEquals("FizzBuzz", sut.fizzBuzz(15)));
}
@Test
public void shouldReturnBuzzIfDiv5() throws Exception {
assertThrows(NullPointerException.class, () -> assertEquals("Buzz", sut.fizzBuzz(5)));
}
@Test
public void shouldReturnFizzIfDiv3() throws Exception {
assertThrows(NullPointerException.class, () -> assertEquals("Fizz", sut.fizzBuzz(3)));
}
@Test
public void shouldReturnVal() throws Exception {
assertThrows(NullPointerException.class, () -> assertEquals("2", sut.fizzBuzz(2)));
}
}
Elegancko. Możemy zdecydować, gdzie dokładnie spodziewamy się błędu. Jeżeli błąd pojawi się gdzieś poza „sekcją krytyczną” (nie mylić z sekcją krytyczną związaną z synchronizacją), to test się wywali.
Timeout testu
Trochę bardziej złożonym problemem niż weryfikacja wyjątków jest weryfikacja maksymalnego czasu wykonania testu. Podobnie jak w poprzednim wypadku mogliśmy ustawić @Test(timeout) i mieć to z głowy. Jednak znowuż, mamy do czynienia z problemem oderwania tego, co chcemy przetestować, od tego, co w rzeczywistości możemy przetestować. Tak ustawione ograniczenie dotyczy całego testu. Jeżeli przygotowanie danych, w ramach „sekcji given”, a nie @BeforeEach, będzie długie, to test się wysypie, ale nie na tym, co potrzeba! Kolejnym problemem jest brak informacji, o ile przekroczyliśmy wyznaczoną granicę. Zobaczmy zatem, jak to będzie wyglądać:
Listing 5. Użycie assertTimeout i assertTimeoutPreemptively
public class FizzBuzzJUnit5AssertTimeoutTest {
private FizzBuzz sut;
@BeforeEach
public void setup() {
sut = new FizzBuzz();
}
@Test
public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
assertTimeout(Duration.ofMillis(100), () -> {
assertEquals("FizzBuzz", sut.fizzBuzz(15));
Thread.sleep(200);
});
}
@Test
public void shouldReturnBuzzIfDiv5() throws Exception {
assertTimeout(Duration.ofMillis(100), () -> {
assertEquals("Buzz", sut.fizzBuzz(5));
Thread.sleep(200);
}, "Ups... out of time!");
}
@Test
public void shouldReturnFizzIfDiv3() throws Exception {
assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
assertEquals("Fizz", sut.fizzBuzz(3));
Thread.sleep(200);
});
}
@Test
public void shouldReturnVal() throws Exception {
assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
assertEquals("2", sut.fizzBuzz(2));
Thread.sleep(200);
}, "Ups... out of time!");
}
}
Różnica pomiędzy tymi dwoma metodami będzie widoczna po uruchomieniu. Metoda assertTimeout poczeka na zakończenie testu i w komunikacie poda, o ile przekroczono czas. Metoda assertTimeoutPreemptively, jak sama nazwa wskazuje, prewencyjnie przerwie test i zwróci informację o przekroczeniu czasu oczekiwania.
Podsumowanie
JUnit 5 wprowadza do swojego API asercji kilka rozwiązań znanych z innych bibliotek. Co ważne twórcy wycofali wsparcie dla Matcher z Hamcrest. Nie ma metody assertThat, która akceptowała Matcher. Jednocześnie twórcy rekomendują używanie bibliotek z asercjami, jeżeli te dostarczone w standardowym API będą niewystarczające. Jak dla mnie jest, to duży plus. JUnit nie preferuje użycia konkretnego rozwiązania i pozostawia nam pełną swobodę wyboru.