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.

Napisz odpowiedź

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax