JUnit 5 – Zagnieżdżanie testów

Samo nazywanie i tagowanie testów nie zawsze jest wystarczające. Szczególnie że oba te mechanizmy są stosunkowo proste. Pozwalają one na organizację testów w pewien logiczny sposób. Nazwy pozwalają na odszukiwanie testów w raportach, z wykorzystaniem przeszukiwania pełnotekstowego. Tagi pozwalają na oznaczanie testów o podobnym znaczeniu w ramach całej aplikacji. Pozwala to na filtrowanie i wybiórcze uruchamianie grup testów. Nadal jednak brakuje nam mechanizmu, który pozwoliłby na na grupowanie testów na na poziomie struktury kodu. JUnit 5 wprowadza taki mechanizm.

Testy zagnieżdżone

Java pozwala na tworzenie klas wewnętrznych. Klasy te mogą być zarówno statyczne, jak i niestatyczne. Obiektu klasy statycznej jest powiązana z klasą, w której została zdefiniowana – klasą zewnętrzną. W przypadku klasy niestatycznej potrzebujemy obiekt klasy zewnętrznej, do którego dopięty będzie obiekt klasy wewnętrznej. I właśnie ten mechanizm jest wykorzystywany przez JUnit 5. Oczywiście nic za darmo. Musimy dodać adnotację @Nested do naszej klasy wewnętrznej. Klasa ta musi być niestatyczna. W przeciwnym wypadku, testy zostaną zignorowane.

Listing 1. Testy zagnieżdżone

@DisplayName("FizzBuzz should")
public class FizzBuzzJUnit5NestedTest {

	private FizzBuzz sut;

	@BeforeEach
	public void setup() {
		sut = new FizzBuzz();
	}

	@Nested
	@DisplayName("return FizzBuzz when dividable by 3 and 5")
	class DividedBy15 {

		@Test
		public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
			assertEquals("FizzBuzz", sut.fizzBuzz(15));
		}
	}


	@Nested
	@DisplayName("return Buzz when dividable by 5")
	class DividedBy5 {
		@Test
		public void shouldReturnBuzzIfDiv5() throws Exception {
			assertEquals("Buzz", sut.fizzBuzz(5));
		}
	}


	@Nested
	@DisplayName("return Fizz when dividable by 3")
	class DividedBy3 {

		@Test
		public void shouldReturnFizzIfDiv3() throws Exception {
			assertEquals("Fizz", sut.fizzBuzz(3));
		}
	}

	@Nested
	@DisplayName("return number in other cases")
	class NotDividedBy3Or5 {

		@Test
		public void shouldReturnVal() throws Exception {
			assertEquals("2", sut.fizzBuzz(2));
		}
	}
}

Cały „power” tego rozwiązania jest widoczny w połączeniu z nazwaniem testów. Przy czym problem na chwilę obecną polega na tym, że po aktualizacji JUnit 5 do milestone 4, Idea nie potrafi uruchomić testów 🙁 Skazuje nas, to na walkę z konsolą. W wersji M3 jeszcze wszystko działa. Cóż, takie uroki używania softu, który jest w fazie produkcji.

JUnit 5 – nazywanie testów, tagi i filtrowanie

Jedną z najbardziej rozpoznawalnych cech BDDW jest opisowość testów. Oczywiście nie jest to najważniejsza cecha. Raczej jest to produkt uboczny metodyki. Jednak gdy piszemy testy, to taka opisowość jest bardzo przydatna. Zresztą widać, że narzędzia pozwalające na opisywanie testów w bardziej naturalny sposób zyskują popularność w ostatnich latach.

Jedną z rzeczy, których brakuje w JUnit 4, jest możliwość zdefiniowania opisu dla testu. Niestety zaszycie opisu w nazwie metody jest słabe. Jesteśmy ograniczeni przez język, a dodatkowo ciężko czyta się camel case. O notacji z podkreśleniami nie wspominam, bo oznacza to dodatkową zabawę z checkstyle. TestNG pozwalało nazywać testy, za pośrednictwem pola testName w adnotacji @Test:

Listing 1. Nazywanie testów w TestNG

@Test(suiteName = "FizzBuzz should")
public class FizzBuzzTestNGNamedTest {

	private FizzBuzz sut;

	@BeforeTest
	public void setup() {
		sut = new FizzBuzz();
	}

	@Test(testName = "return FizzBuzz when dividable by 3 and 5")
	public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
		assertEquals("FizzBuzz", sut.fizzBuzz(15));
	}

	@Test(testName = "return Buzz when dividable by 5")
	public void shouldReturnBuzzIfDiv5() throws Exception {
		assertEquals("Buzz", sut.fizzBuzz(5));
	}

	@Test(testName = "return Fizz when dividable by 3")
	public void shouldReturnFizzIfDiv3() throws Exception {
		assertEquals("Fizz", sut.fizzBuzz(3));
	}

	@Test(testName = "return number in other cases")
	public void shouldReturnVal() throws Exception {
		assertEquals("2", sut.fizzBuzz(2));
	}
}

Mechanizm ten nie jest jednak kompletny. Na przykład IntelliJ wyświetla nazwy metod, a nie testów. JUnit 5 wprowadza dwa inne mechanizmy.

Testy nazwane

Najprostszym mechanizmem jest nazwanie testów. Służy do tego specjalna adnotacja @DisplayName. Znowuż, nazwa adnotacji świetnie oddaje intencje. Wyświetlamy test pod pewną nazwą. Jego rzeczywista nawa, to nazwa metody 🙂

Listing 2. Użycie @DisplayName

@DisplayName("FizzBuzz should")
public class FizzBuzzJUnit5DescriptiveTest {

	private FizzBuzz sut;

	@BeforeEach
	public void setup() {
		sut = new FizzBuzz();
	}

	@Test
	@DisplayName("return FizzBuzz when dividable by 3 and 5")
	public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
		assertEquals("FizzBuzz", sut.fizzBuzz(15));
		assertEquals("FizzBuzz", sut.fizzBuzz(30));
		assertEquals("FizzBuzz", sut.fizzBuzz(150));
	}

	@Test
	@DisplayName("return Buzz when dividable by 5")
	public void shouldReturnBuzzIfDiv5() throws Exception {
		assertEquals("Buzz", sut.fizzBuzz(5));
		assertEquals("Buzz", sut.fizzBuzz(10));
		assertEquals("Buzz", sut.fizzBuzz(50));
	}

	@Test
	@DisplayName("return Fizz when dividable by 3")
	public void shouldReturnFizzIfDiv3() throws Exception {
		assertEquals("Fizz", sut.fizzBuzz(3));
		assertEquals("Fizz", sut.fizzBuzz(6));
		assertEquals("Fizz", sut.fizzBuzz(99));
	}

	@Test
	@DisplayName("return number in other cases")
	public void shouldReturnVal() throws Exception {
		assertEquals("2", sut.fizzBuzz(2));
		assertEquals("8", sut.fizzBuzz(8));
		assertEquals("11", sut.fizzBuzz(11));
	}
}

Oczywiście same nazwy to nie wszystko. Pozwalają one zaszaleć na poziomie generowania raportów, ale dopiero w połączeniu z tagami możemy odfiltrować testy.

Tagi i filtrowanie

Filtrowanie testów zarówno w JUnit 4 jak i w TestNG odbywało się na podstawie nazwy klasy. Dodatkowo TestNG udostępniał mechanizm grup testów, które pozwalały na zbieranie testów razem:

Listing 3. Mechanizm grup z TestNG

public class FizzBuzzTestNGGroupedTest {

	private FizzBuzz sut;

	@BeforeTest
	public void setup() {
		sut = new FizzBuzz();
	}

	@Test(groups = {"3", "5"})
	public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
		assertEquals("FizzBuzz", sut.fizzBuzz(15));
	}

	@Test(groups = {"5"})
	public void shouldReturnBuzzIfDiv5() throws Exception {
		assertEquals("Buzz", sut.fizzBuzz(5));
	}

	@Test(groups = {"3"})
	public void shouldReturnFizzIfDiv3() throws Exception {
		assertEquals("Fizz", sut.fizzBuzz(3));
	}

	@Test
	public void shouldReturnVal() throws Exception {
		assertEquals("2", sut.fizzBuzz(2));
	}
}

Test z grupy 3 i grupy 5 można uruchomić oddzielnie. Możemy też uzależnić uruchomienie pewnych testów od tego czy testy z jakiejś grupy przeszły. To jest bardzo potężny mechanizm. JUnit 5 podszedł do problemu trochę inaczej. Wprowadzając adnotację @Tag i dając możliwość, filtrowania testów po tej adnotacji.

Listing 4. Mechanizm tagów z JUnit 5

public class FizzBuzzJUnit5TagsTest {

	private FizzBuzz sut;

	@BeforeEach
	public void setup() {
		sut = new FizzBuzz();
	}

	@Test
	@Tag("3")
	@Tag("5")
	public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
		assertEquals("FizzBuzz", sut.fizzBuzz(15));
	}

	@Test
	@Tag("5")
	public void shouldReturnBuzzIfDiv5() throws Exception {
		assertEquals("Buzz", sut.fizzBuzz(5));
	}

	@Test
	@Tag("3")
	public void shouldReturnFizzIfDiv3() throws Exception {
		assertEquals("Fizz", sut.fizzBuzz(3));
	}

	@Test
	public void shouldReturnVal() throws Exception {
		assertEquals("2", sut.fizzBuzz(2));
	}
}

Proces filtrowania odbywa się w trakcie wykrywania testów obecnych w classpath. Zatem o ich uruchomieniu lub nie, decydujemy na poziomie silnika testów. Oczywiście, jeżeli chcemy użyć mavena, by uruchomić tylko wybrane testy, to możemy to zrobić:

Listing 5. Konfiguracja mavena


    maven-surefire-plugin
    2.19
    
        
            3
        
    

Przy czym, mechanizm ten nie jest aż tak elastyczny jak grupy w TestNG. Jeżeli jednak dodamy odpowiedni silnik…

JUnit 5 – Pierwsze kroki

Dziś pobawimy się najprostszymi testami JUnit 5. Samo tworzenie i uruchamianie testów nie różni się w znaczący od wersji 4. Przynajmniej z punktu widzenia użytkownika. Tworzymy test, odpalamy mavena albo też naciskamy Alt+Shift+F10 Enter i gotowe. Jest jednak kilka drobnych, acz znaczących różnic, jeżeli chcemy skorzystać z czegoś więcej. Na przykład przygotować konfiguracje do testu.

Test referencyjny w JUnit 4

Poniżej test JUnit 4, który stanowi punkt odniesienia dla kodu pisanego z użyciem JUnit 5. Nie jest to najlepszy możliwy test, ale nie chciałem zaśmiecać go niepotrzebnymi elementami np. dodatkowymi asercjami.

Listing 1. Testy napisane z użyciem JUnit4

public class FizzBuzzJUnit4WithoutRunnersTest {

	private FizzBuzz sut;

	@BeforeClass
	public static void classSetup() {
		Logger.getLogger("JUnit 4").info("Started at " + LocalDateTime.now());
	}

	@AfterClass
	public static void classTeardown() {
		Logger.getLogger("JUnit 4").info("Finished at " + LocalDateTime.now());
	}

	@Before
	public void setup() {
		sut = new FizzBuzz();
	}

	@Test
	public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
		assertEquals("FizzBuzz", sut.fizzBuzz(15));
	}

	@Test
	public void shouldReturnBuzzIfDiv5() throws Exception {
		assertEquals("Buzz", sut.fizzBuzz(5));
	}

	@Test
	public void shouldReturnFizzIfDiv3() throws Exception {
		assertEquals("Fizz", sut.fizzBuzz(3));
	}

	@Test
	public void shouldReturnVal() throws Exception {
		assertEquals("2", sut.fizzBuzz(2));
	}
}

Mamy tu cztery metody testowe oraz metodę setup, która będzie wykonywać się przed każdym testem. Dodatkowo mamy dwie metody classSetup i classTeardown, które są uruchamiane przed utworzeniem instancji klasy testowej. Co dokładnie tu się dzieje, to nie jest istotne.

Zmiany w JUnit 5

JUnit 5 wprowadził kilka drobnych zmian w nazewnictwie adnotacji. Nowe adnotacje znacznie lepiej opisują, co się dzieje. Nasza klasa testowa po traktowaniu JUnit 5 będzie wyglądać w następujący sposób:

Listing 2. Testy napisane z użyciem JUnit 5

public class FizzBuzzJUnit5Test {

	private FizzBuzz sut;

	@BeforeAll
	static void classSetup() {
		Logger.getLogger("JUnit 4").info("Started at " + LocalDateTime.now());
	}

	@AfterAll
	static void classTeardown() {
		Logger.getLogger("JUnit 4").info("Finished at " + LocalDateTime.now());
	}

	@BeforeEach
	public void setup() {
		sut = new FizzBuzz();
	}

	@Test
	public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
		assertEquals("FizzBuzz", sut.fizzBuzz(15));
	}

	@Test
	public void shouldReturnBuzzIfDiv5() throws Exception {
		assertEquals("Buzz", sut.fizzBuzz(5));
	}

	@Test
	public void shouldReturnFizzIfDiv3() throws Exception {
		assertEquals("Fizz", sut.fizzBuzz(3));
	}

	@Test
	public void shouldReturnVal() throws Exception {
		assertEquals("2", sut.fizzBuzz(2));
	}
}

I tak oto adnotacja @Before została zastąpiona przez @BeforeEach, a @BeforeClass przez @BeforeAll. Podobnie ma się sprawa z adnotacjami @After*

Oczywiście nie jest to jedyna zmiana.

Wyłączanie testów

W JUnit 4 wyłączenie testu polegało na dodaniu adnotacji @Ignore:

Listing 3. Przykład wyłączonego testu JUnit 4

@Test
@Ignore
public void shouldReturnVal() throws Exception {
	assertEquals("2", sut.fizzBuzz(2));
	assertEquals("8", sut.fizzBuzz(8));
	assertEquals("11", sut.fizzBuzz(11));
}

O ile w przypadku adnotacji @Before*/@After* można było domyślić się jaka jest intencja użytkownika, to już w przypadku adnotacji @Ignore, jest znacznie gorzej.

Test ignorowany, czyli co?

Kilka razy miałem w różnych zespołach rozmowę na ten temat. Czy jeżeli test jest ignorowany, to można go wywalić? Czy oznacza to, że można zignorować jego rezultaty? A może coś jeszcze innego? Zazwyczaj kończyło się wdrożeniem kolejnego, świetnego, i oczywistego schematu nazywania testów, który umożliwia ich filtrowanie. Żeby było zabawniej, schematy nazewnicze średnio działają z metodami, co prowadzi do tworzenia „kontenerów na testy ignorowane”. Klas, które zawierają testy, ale wszystkie są wyłączone… no właśnie, wyłączone.

Oczywiście intencja programisty, który użył tej adnotacji jest zupełnie inna. Chce on wyłączyć test. Z jakiegoś powodu test nie powinien być uruchamiany. Nie oznacza to, że będzie on pomijany. Po prostu w danym momencie chcemy nie uruchamiać danego testu. W tym celu możemy użyć adnotacji @Disable:

Listing 4. Przykład wyłączonego testu JUnit 5

@Test
@Disable
public void shouldReturnVal() throws Exception {
	assertEquals("2", sut.fizzBuzz(2));
	assertEquals("8", sut.fizzBuzz(8));
	assertEquals("11", sut.fizzBuzz(11));
}

Intencja jest teraz oczywista. Test został wyłączony.

Podsumowanie

JUnit 5 nie zmienia sposobu pisania testów w jakiś drastyczny sposób. Nie na tym podstawowym, prostym poziomie. Zmiany, które wprowadza, są subtelniejsze. Nowe nazewnictwo znacznie lepiej opisuje intencje stojące za poszczególnymi fragmentami kodu. Tym samym ułatwia nam czytanie testów i ich zrozumienie. W połączeniu z innymi mechanizmami, które omówię w kolejnych wpisach, daje nam nowe możliwości w tworzeniu kodu testowego.

JUnit 5 – Wstęp

JUnit 4 jest już stary. Został opublikowany gdzieś w okolicach 2006 roku. Ostatnia aktualizacja (4.12) to rok 2014. Sama biblioteka nie jest zła, jeśli chodzi o testy jednostkowe. Spełnia swoje zadanie i nie ma powodu by się do czegoś przyczepić. Przez ostatnie 11 lat zaszło trochę zmian w języku jak i w sposobach wytwarzania oprogramowania. Przyszedł już czas na aktualizację.

Rzeczy które w JUnit 4 mi nie pasują

JUnit jest świetny, jeśli chodzi o testy jednostkowe. I tylko jeśli chodzi o testy jednostkowe. Jakakolwiek próba użycia go w testach integracyjnych jest z góry skazana na niepowodzenie. JUnit został zaprojektowany tak, by testy w nim pisane były niezależne, szybkie, powtarzalne i automatyzowalne. Innymi słowy, by spełniały zasadę F.I.R.S.T. Piąty element tej zasady jest związany z pokryciem. Jednak zaczęliśmy używać JUnita do tworzenia testów integracyjnych i integracyjno-jednostkowych. W efekcie mechanizm związany z @RunWith stał się zaczepem dla pluginów. Coś, co miało pomagać w migracji z wersji 3.x, służy nam do zupełnie czegoś innego.

W dodatku mamy do czynienia z klasycznym dependency hell, gdy potrzebujemy wykorzystać kilka runnerów. Który będzie pierwszy? Czy kolejność jest prawidłowa? Co będzie, jak zamienię kolejnością runnery? Słabo.

Kolejna rzecz to zarządzanie zależnościami pomiędzy testami. JUnit tego nie ma. Czasami jednak jest to przydatna funkcjonalność. Nie tylko na poziomie testów integracyjnych, ale też po to by testy jednostkowe można było wykonywać według zasady „fail fast”. Co prawda można na poziomie np. mavena ustawić odpowiednią regułę, ale dotyczy ona wszystkich testów. Przydałaby się granulacja na poziomie klasy albo przynajmniej pojedynczego zestawu.

Paskudnie rozwiązane zarządzanie zależnościami. W sumie nie ma się co dziwić, bo prawdziwy test jednostkowy nie ma zależności. Tyle tylko, że czasami warto by jakąś fabryczkę do mocków zrobić, albo dorzucić jakiś niewielki helper… a tu dupa i trzeba to ręcznie wiązać, albo za pomocą runnerów.

Ostatnia rzecz to asercje. Pozostało po starszych wersjach i nie idzie tego zmienić. Z drugiej strony wystarczy podpiąć AssertJ i olać asercje JUnita.

Dlatego lubię TestNG

TestNG jest znacznie młodsze. Wersja 1.0 jest z 2004 roku, ale najstarsza na githubie wersja 5.13 z 2010. Najnowsza 6.11 z końca lutego 2017 🙂 Mamy tutaj wszystko to, co w JUnit jest upierdliwe, uciążliwe, albo czego nie ma, a by się przydało. Po pierwsze zależności pomiędzy testami możemy skonfigurować na poziomie samych testów. Zatem można przygotować zestaw testowy tak, by wywalenie się pojedynczego testu skutkowało nieuruchomieniem tylko części testów. To z kolei oznacza, że można tworzyć testy integracyjne we w miarę łatwy sposób. Co więcej, można budować całe drzewa testów. Wystarczy wykorzystać grupy, które można zagnieżdżać. Do tego dochodzi całkiem dobra granulacja Before/After i już mamy w pełni elastyczne narzędzie.

Po drugie TestNG posiada na pokładzie mechanizm modułów. Chcesz zrobić sobie takie małe DI, by postawić część kontekstu? Proszę bardzo, jeżeli tylko trzymasz się JSR-330, czyli Dependency Injection for Java. Jako implementacja wykorzystywany jest Google Guice, co daje całkiem wygodne API. Swoją drogą im częściej porównuję Springa i Guice tym bardziej lubię Guice.

Po trzecie may też do dyspozycji mechanizm DataProvider, który pozwala nam na szybkie tworzenie np. testów parametryzowanych. Coś, co w JUnit trzeba ogarnąć runnerem, tu działa out of box.

Rzeczy dobre w JUnit

Ciekawie rozwiązano problem weryfikacji wyrzucanych wyjątków. Możemy użyć do tego odpowiedniego parametru w adnotacji, ale to nie jest najlepsze podejście. Co prawda wychwycimy wyjątek, ale nie zawsze możemy powiedzieć, że to co złapaliśmy, jest tym, czego się spodziewamy. Dlaczego? Ponieważ czasami taki sam wyjątek może być wyrzucony w różnych punktach testu. Zamiast tak prymitywnego podejścia, dostępnego też w TestNG, możemy użyć adnotacji @Rule. Z jej pomocą zdefiniujemy odpowiednią regułę, która pozwoli nam na ciche przechwycenie wyjątku oraz jego weryfikację. TestNG posiada podobnie działający mechanizm.

Podejście JUnit5

Jak widać, jest trochę rzeczy złych w JUnit 4. Twórcy postanowili naprawić większość z nich. W tym celu całkowicie zmienili architekturę. Podzielili ją na trzy główne części.

Zanim przejdę do omówienia elementów, drobna uwaga. JUnit 5 jest obecnie w epoce milestonea łupanego. Opis dotyczy tego jak to wygląda na chwilę obecną. Xapewne nie będziemy mieli już wielu zmian, to jednak może okazać się, że gdy czytasz ten tekst jest on w jakimś stopniu nieaktualny.

JUnit Platform

Jest to główna „paczka” nowej biblioteki. Zawiera w sobie najważniejsze elementy. Po pierwsze Launcher, który jest odpowiedzialny za udostępnienie API platformy różnego rodzaju klientom. Dodatkowo wykonuje pracę związaną z wyszukaniem testów w classpath. Klientami są implementacje TestEngine. Sam TestEngine udostępniony przez platformę służy do filtrowania i uruchamiania testów w zależności od potrzeb. Chcesz mieć FitNess na pokładzie i uruchamiać testy pisane za pomocą tej biblioteki? Wystarczy dodać odpowiedni silniczek.

Same silniki testowe są wyszukiwane za pomocą mechanizmu SPI, czyli nie musimy nic dodatkowo konfigurować, by całość śmigała. Wystarczy, by nasza paczka spełniała założenia SPI. Kolejnym elementem jest commons, który zawiera bebechy i narzędzia. Używać na własną odpowiedzialność.

Do tego mamy jeszcze mały programik konsolowy do uruchamiania testów, pluginy do gradle i surefire oraz runner, pozwalający na uruchomienie testów JUnit 5 w środowisku JUnit 4. Ten ostatni element ma ułatwić migrację.

JUnit Jupiter

Jupiter to pierwszy z silników testowych dostarczonych przez bibliotekę. Służy do uruchamiania testów oraz ma API do tworzenia rozszerzeń. Rozszerzenia pozwalają na modyfikowanie środowiska testowego, dodawania pewnych elementów do testów itp. Pełnią podobną rolę co runnery JUnit4, ale robią to w zupełnie inny sposób.

JUnit Vintage

Drugi z silników testowych, służy do uruchamiania testów napisanych w JUnit 4 i Junit 3.

Konfiguracja

Konfiguracja JUnit 5 w mavenie jest śmiesznie prosta:

Listing 1. Konfiguracja w pom.xml


    
        
            maven-surefire-plugin
            2.19
            
                
                    org.junit.platform
                    junit-platform-surefire-provider
                    1.0.0-M3
                
                
                    org.junit.jupiter
                    junit-jupiter-engine
                    5.0.0-M3
                
                
                    org.junit.vintage
                    junit-vintage-engine
                    4.12.0-M3
                
            
        
    



    
        org.junit.jupiter
        junit-jupiter-api
        5.0.0-M3
        test
    
    
        junit
        junit
        4.12
        test
    

    
        pl.pragmatists
        JUnitParams
        1.0.6
        test
    

Przy czym jedna uwaga. Jak już chcemy korzystać z JUnit 5, to darujmy sobie próby konfiguracji JUnit 4 jako osobnego zadania dla surefire. Lepiej jest dodać silnik Vintage, który pod spodem po prostu odpala JUnit 4 niż kombinować z konfiguracją. Szkoda czasu, a i wynik będzie popieprzony. Dlaczego? Ponieważ JUnit 5 wykryje testy JUnit 4, ale bez silnika vintage ich nie uruchomi. Zatem w logu dostaniemy:

Listing 2. Uruchomienie testów JUnit 4 bez silnika Vintage

mar 28, 2017 11:38:09 AM org.junit.platform.launcher.core.ServiceLoaderTestEngineRegistry loadTestEngines
INFO: Discovered TestEngines with IDs: [junit-jupiter]
Running pl.koziolekweb.blog.fizzbuzz.FizzBuzzJUnit4IgnoreTest
Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.001 sec - in pl.koziolekweb.blog.fizzbuzz.FizzBuzzJUnit4IgnoreTest
Running pl.koziolekweb.blog.fizzbuzz.FizzBuzzJUnit4WithoutRunnersTest
Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0 sec - in pl.koziolekweb.blog.fizzbuzz.FizzBuzzJUnit4WithoutRunnersTest
Running pl.koziolekweb.blog.fizzbuzz.FizzBuzzJUnit4WithRunnersTest
Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0 sec - in pl.koziolekweb.blog.fizzbuzz.FizzBuzzJUnit4WithRunnersTest

Klasa jest, testu nie ma, a teraz pomyślcie, jak wygląda raport. Będzie zawierać wiele zer… to nie dobrze?

Podsumowanie

Czas na krótkie podsumowanie. JUnit 5 wygląda na bibliotekę, która pozwoli nam na lepsze zarządzanie testami. Same testy będą pisane trochę inaczej i istnieje szansa, że będą lepsze. Moim zdaniem warto już dziś przyjrzeć się temu projektowi i spróbować go wdrożyć we własnych projektach. Całość jest mniej więcej stabilna, a zatem nie będzie jakiś dziwnych wybuchów. W kolejnych artykułach przybliżę różnice pomiędzy JUnit 5 i 4, w zakresie tworzenia testów.

Zstąpienie Aniołów – Krucjata inna niż wszystkie

Okładka Zstąpienie Aniołów

Tytuł: Zstąpienie Aniołów
Autor: Mitchel Scanlon
Rok: 2016
ISBN: 978-8-36165-644-9

Najtrudniejsza książka z serii Herezji Horusa za mną 😉 Jest inna. Po prostu. Gdy kilka lat temu czytałem recenzje Zstąpienia Aniołów, to przewijał się motyw nudy. Wersję angielską przeczytałem gdzieś 10 lat temu i jakoś nie pokochałem. Ostatnio wróciłem do tematu i przeczytałem polskie tłumaczenie. Książka jest dobra. Tyle tylko, że po pierwszych pięciu tomach HH można mieć trochę inne oczekiwania. W efekcie wielu czytelników dochodzi do wniosku, że całość wieje nudą. Jednak po kolei.

Akcja książki rozpoczyna się na kilka lat przed przybyciem Imperatora na Caliban. Lion el’Jonson nie jest jeszcze mistrzem Zakonu, ale już od początku wiemy, że nominacja to kwestia czasu. Historia opowiedziana jest z perspektywy Zahariela, jednego z zakonnych suplikantów, a później młodego rycerza. Mamy więc tutaj książkę raczej przygodową z elementami fantasy, ale powiedzmy sobie szczerze, to fantasy jest na poziomie bajki o św. Jerzym i smoku. Walczymy z dużym potworem i jest OK. Bliżej temu do „Zabójcy Trolli” niż do HH. Całość podzielona jest na cztery części i dwie pierwsze dotyczą właśnie okresu rycerskiego. Dwie kolejne to już opowieść z perspektywy Zahariela – astares.

Autor pomija tu proces tworzenia kosmicznego marine. Jedynie opisuje wstępną fazę selekcji związana z wyborem najtwardszych i najwytrzymalszych rycerzy i suplikantów. Pomija też kwestię przemiany starszych rycerzy, później jedynie wspomniane jest, że zostali poddani kuracji chemicznej, ale nie otrzymali geno-ziarna. W końcu akcja książki przenosi się na Sarosh, jedną z planet odkrytych w czasie krucjaty. Dalej standard – lokalsi okazali się wyznawcami jakiejś tam formy chaosu, zdradzili siły imperium i próbowali zabić Jonsona. Ten się wkurwił i…

No właśnie i zarządził uderzenie punktowe z wykorzystaniem niewielkich sił. To, co odróżnia Zstąpienie od innych książek z serii HH, to brak wojny totalnej. Nie znajdziemy tu opisów epickich bitw, poza jedną – zdobyciem twierdzy rycerzy Lupusa w czasach przed imperialnych, ale i tu raczej są to Krzyżacy. Nie ma tytanicznych zmagań prowadzonych przez tysiące astares z użyciem setek czołgów, samolotów i wsparciem Collegia Titanica. Nie ma tego wszystkiego, co jest znakiem charakterystycznym HH. W zamian jest opowieść o wewnętrznej rywalizacji w zakonie. Każdy, kto zna choć trochę świat w40k, odnajdzie tu wiele informacji i wyjaśnień dotyczących, takiego, a nie innego losu Calibanu.

Książka jest dobra. Jest dobrym czytadłem. Jednak zanim zacznie się lekturę, należy odrzucić schematy znane z HH. Wtedy mamy do czynienia z całkiem dobrą książką. Na koniec doczepię się do jednej rzeczy. Dialogi w książce są takie jakieś koślawe. Nie są drętwe, ale nie ma w nich tego czegoś. Większość, to pierdolenie o Chopinie, autor starał się chyba stylizować je na „teksty prawdziwych rycerzy”, ale mu nie wyszło. I jest to jedyny poważny minus.

Iterator filtrujący, czyli kontrakt iteratora w dwóch odsłonach

Dziś o tym, że kontrakt zawarty w dokumentacji można interpretować na wiele sposobów oraz dlaczego SRP jest istotne.

Na 4p padło pytanie, jak ogarnąć problem iteratora, który będzie przy okazji filtrował kolekcję. Sama implementacja jest stosunkowo prosta:

Listing 1. Iterator filrtujący

public class FilteringIterator<T> implements Iterator<Optional<T>> {

	private final Iterator<T> iter;

	private final Predicate<T> predicate;

	public FilteringIterator(Collection<T> collection, Predicate<T> predicate) {
		this.iter = collection.iterator();
		this.predicate = predicate;
	}

	@Override
	public boolean hasNext() {
		return iter.hasNext();
	}

	@Override
	public Optional<T> next() {
		T next = iter.next();
		return Optional.ofNullable(next).filter(predicate);
	}
}

Założenie jest następujące. Idziemy przez kolekcję i delegujemy zachowania iteratora do iteratora powiązanego z tą kolekcją. Przy czym metoda next pełni role adaptera, który zwraca Optional[T] zamiast T. jarekr000000, zwrócił mi jednak uwagę, że należało by logikę predykatu przenieść do metody hasNext. Tyle tylko, że w takim wypadku otrzymamy iterator, który będzie iteratorem until (limitującym). To znaczy, będzie zwracać jakąś wartość, dopóki warunek jest spełniony.

Listing 2. Iterator limitujący

public class UntilIterator<T> implements Iterator<T> {

	private final Iterator<T> iter;

	private final Predicate<T> predicate;
	private Optional<T> current;


	public UntilIterator(Collection<T> collection, Predicate<T> predicate) {
		this.iter = collection.iterator();
		this.predicate = predicate;
	}

	@Override
	public boolean hasNext() {
		boolean b = iter.hasNext();
		if (b) {
			current = Optional.ofNullable(iter.next())
					.filter(predicate);
			return current
					.map($ -> Boolean.TRUE) // element istnieje i spełnia warunek
					.orElse(false); // element nieistnieje albo niespełnia waruneku
		}
		return false;
	}

	@Override
	public T next() {
		return current.orElseThrow(NoSuchElementException::new);
	}
}

Implementacja jest oczywiście po łebkach i nie zapewnia prawidłowego działania w środowisku współbieżnym.

Na czym polega różnica?

Na początek rzućmy okiem na kontrakt metody hasNext:

Returns true if the iteration has more elements. (In other words, returns true if next() would return an element rather than throwing an exception.)

źródło

Pierwszy z iteratorów będzie zwracał coś, Optional albo Empty, dla każdego elementu w oryginalnej kolekcji. Oznacza to też, że metoda hasNext będzie zwracać true dopóki w oryginalnej kolekcji są jeszcze elementy. Oznacza to też, że jeżeli element nie spełnia warunków testu, to i tak metoda next nie powinna rzucać wyjątkiem.

Drugi z iteratrów zachowa się zupełnie inaczej. W tym przypadku, hasNext zwróci false dla pierwszego elementu niespełniającego warunku. Stanie się tak nawet wtedy, gdy oryginalna kolekcja będzie miała jeszcze elementy. Tym samym metoda next może rzucić wyjątek, nawet wtedy, gdy kolekcja nadal posiada elementy spełniające warunek.

Kolejna różnica, to wartość zwracana z metody next. Pierwszy z iteratorów opakuje ją w Optional. Drugi zwróci wartość taką jakiej oczekujemy iterując przez kolekcję obiektów o danym typie. Czy ta różnica ma znaczenie? Czasami ma, ponieważ dla pierwszego iteratora musimy wykonać operację „rozpakowania” wartości. W przypadku drugiego już nie. Dobrze obrazują to testy:

Listing 3. Test dla iteratora filtrujacego

public class FilteringIteratorTest {

	@Rule
	public ErrorCollector collector;

	private List<Integer> ints;

	@Before
	public void setUp() throws Exception {
		collector = new ErrorCollector();
		ints = asList(4, 3, 2, 1);
	}

	@Test
	public void filterOutSomeInts() throws Exception {
		FilteringIterator<Integer> it = new FilteringIterator<>(ints, i -> i % 2 == 0);

		// 4
		assertThat(it.hasNext()).isTrue();
		assertThat(it.next()).isPresent().contains(4);

		// 3
		assertThat(it.hasNext()).isTrue();
		assertThat(it.next()).isEmpty();

		// 2
		assertThat(it.hasNext()).isTrue();
		assertThat(it.next()).isPresent().contains(2);

		// 1
		assertThat(it.hasNext()).isTrue();
		assertThat(it.next()).isEmpty();

		// no more elements
		assertThat(it.hasNext()).isFalse();

		catchException(it).next();
		collector.checkThat(caughtException(), instanceOf(NoSuchElementException.class));

	}
}

Listing 4. Test dla iteratora limitującego

public class UntilIteratorTest {

	@Rule
	public ErrorCollector collector;

	private List<Integer> ints;

	@Before
	public void setUp() throws Exception {
		collector = new ErrorCollector();
		ints = asList(4, 3, 2, 1);
	}

	@Test
	public void filterUntilSomeInts() throws Exception {
		UntilIterator<Integer> it = new UntilIterator<>(ints, i -> i % 2 == 0);
		// 4
		Assert.assertTrue(it.hasNext());
		Assert.assertEquals(it.next().intValue(), 4);

		// 3 → false
		Assert.assertFalse(it.hasNext());
		catchException(it).next();
		collector.checkThat(caughtException(), instanceOf(NoSuchElementException.class));
	}

}

Wszystko ładnie pięknie, ale jest jeden problem.

SRP

Zasada pojedynczej odpowiedzialności jest całkiem fajna. Tyle tylko, że tu jest złamana. Jak? Nasze iteratory robią dwie rzeczy naraz. Po pierwsze poruszają się po kolekcji. Po drugie manipulują elementami kolekcji. W efekcie już na samym początku mamy problem. Autor pytania łamiąc SRP, wpędził się w kłopoty. Jarek i ja dyskutowaliśmy nad problemem, który w praktyce nie powinien istnieć.

Pozostaje jeszcze kwestia kontraktu metody hasNext. Jest on na tyle ogólny, że oba iteratory mogą w na swój sposób określić, jak go realizują. Ze szczególnym uwzględnieniem co oznacza „brak kolejnych elementów”.

Zachłannie, leniwie, współbieżnie – czego nauczyłem się od Jose Valima

Warto jeździć na konferencje i słuchać mądrzejszych do siebie. Warto, ponieważ często nie dowiemy się nic nowego, ale sposób przekazania wiedzy pozwoli na uporządkowanie kilku rzeczy.

Jose na tegorocznych LambdaDays opowiadał o GenStage i Flow. Jednak nie to jest najważniejsze. Jakby przy okazji omówił trzy różne podejścia do pracy z danymi.

Podejście zachłanne

Jest najprostsze. Ładujemy wszystko do pamięci i jazda. W takim przypadku program nie zawiera żadnej dodatkowej magii:

Listing 1. Zachłanne przetwarzanie danych.

def eager() do
  File.read!("plwiki-20170301-all-titles")
    |> String.split("\n")
       |> Enum.flat_map(&String.split/1)
       |> Enum.reduce(%{}, fn word, map ->
                                Map.update(map, word, 1, & &1 + 1)
                           end
                     )
end

W powyższym programie wczytujemy plik tekstowy i chcemy policzyć wystąpienia poszczególnych słów. Nie ma tu nic skomplikowanego. Funkcja read! wczytuje cały plik. Następnie dzielimy go na linie, a z linii budujemy listy (enumerable) słów za pomocą flat_map. Na koniec wszystko redukujemy. Takie podejście ma jednak pewną wadę. Musimy zapewnić odpowiednio dużo pamięci dla naszego programu. Oczywistym rozwiązaniem jest…

Podejście leniwe

W tym przypadku ustalamy, że będziemy czytać plik linia po linii i przetwarzać na raz całą linię.

Listing 2. Leniwe przetwarzanie danych.

def lazy() do
  File.stream!("plwiki-20170301-all-titles", [], :line)
    |> Stream.flat_map(&String.split/1)
    |> Enum.reduce(%{}, fn word, map ->
                            Map.update(map, word, 1, & &1 + 1)
                        end
                  )
end

Funkcja stream! jako trzeci parametr przyjmuje :line. Dzięki czemu odczyta na raz tylko jedną linię z pliku. Udało nam się wyeliminować dużą konsumpcję pamięci, ale pytanie, co jak chcemy przyspieszyć?

Podejście współbieżne

Jeżeli dane są wzajemnie niezależne, to możemy spróbować usprawnić proces ich przetwarzania. Wystarczy, by przetwarzanie odbywało się na wielu procesorach.

Listing 3. Równoległe przetwarzanie danych.

def parallel() do
  File.stream!("plwiki-20170301-all-titles")
    |> Flow.from_enumerable()
    |> Flow.flat_map(&String.split/1)
    |> Flow.partition()
    |> Flow.reduce(fn -> %{} end, fn word, map ->
                                     Map.update(map, word, 1, & &1 + 1)
                                  end
                    )
    |> Enum.into(%{})
end

Narzędziem, które nam to ułatwia, jest Flow. Dzięki niemu możemy zaimplementować zarówno mechanizm równoległego przetwarzania danych, z wykorzystaniem GenServer, jak i wykorzystać tzw. Back-pressure, by dostroić obciążenie komponentu odczytującego dane do możliwości systemu.

Jednak w tym przypadku spotkamy jeszcze jeden problem. Jeżeli redukcja ma być nieblokująca, to musimy jakoś pogodzić pisanie do mapy przez wiele wątków. Chcemy uniknąć sytuacji, gdy dwa wątki modyfikują wartość pod tym samym kluczem. W tym celu Flow udostępnia funkcję partition, która gwarantuje, że dany klucz będzie „przywiązany” do wątku. Na koniec wystarczy zsumować mapy. Jest to bardzo proste, ponieważ wiemy, że zawierają unikalne klucze.

Podsumowanie

Ok, ale co w tym dziwnego? Zauważcie, że do naszego problemu podeszliśmy tak, by na początek zaaplikować rozwiązanie najprostsze. Następnie komplikowaliśmy rozwiązanie, jednocześnie uzyskując nowe możliwości i lepsze rezultaty. Warto tak działać, ponieważ w ten sposób minimalizujemy szansę na popełnienie błędu. Niestety zauważyłem, że coraz więcej osób chce od razu mieć rozwiązanie, które jest skomplikowane. Jednak produkt końcowy zawiera dużo dziwnych błędów.

Na koniec małe wizualne porównanie działania poszczególnych rozwiązań. Na dole zrzut z htopa. Erlang śmiga na rdzenie od 1 do 4. Na rdzeniach 7 i 8 śmiga nagrywanie pulpitu. Obserwujcie, jak dużo pamięci jest zajęte. Całość odpalać w wysokich detalach na pełnym ekranie.

Pliki w tym tekście to dumpy wikipedii, zanim jednak pobierzesz jakiś plik weź pod uwagę możliwości swojego komputera 😉

Kod znajdziecie tu.

Zmiany, zmiany, zmiany….

Zmiana jest w sumie jedna, ale za to duża. Od dziś cały blog będzie śmigał po https. Co to oznacza? Od strony użytkownika nic się nie zmienia, ponieważ jest też odpowiednia konfiguracja i wejście przez http, przekieruje was w odpowiednie miejsce. Powinno działać. Jeżeli jednak macie jakieś problemy albo przeglądarka krzyczy o niebezpiecznych zasobach, to dajcie znać.

Trochę o mierzeniu kodu z jmh

jmh jest leciwym narzędziem. Jakoś w marcu stuknie mu już 4 lata. Ostatnio zwróciłem na nie uwagę przy okazji rozwiązywania problemu „czyj kod powinniśmy użyć, nasz czy hindusów”. Wiadomo, że do rozwiązywania tego typu problemów najlepiej sprawdzają się jakieś w miarę niezależne od nas narzędzia. Jako że pisanie mikro benchmarków jest sztuką, narzędzie powinno ładnie zamykać skomplikowane elementy w przyzwoicie wyglądające API. Powinno też umieć wyliczyć jakieś statystyki w rozsądny sposób.

No właśnie statystyki. W przypadku większość benchmarków samo wyznaczenie wartości mierzonej jest niewystarczające. Co prawda na poziomie newsa prasowego możemy przeboleć brak informacji o dokładności pomiaru, ale już na poziomie naszego produktu powinniśmy mieć pełnię wiedzy o pomiarach. Dlatego właśnie jmh zdaje się być dobrym narzędziem.

Krótka powtórka z Pracowni Fizycznej I

Na studiach miałem kilka przedmiotów laboratoryjnych. Zaczynało się to jakoś na trzecim semestrze od przedmiotu o nazwie Pracownia Wstępna, gdzie następował odsiew na podstawie umiejętności zrozumienia tekstu polecenia. Następnie były wa semestry zajęć Pracownia Fizyczna, a na koniec była jeszcze Pracownia Elektroniczna. Te ostatnie zajęcia nie były obowiązkowe. Elementem łączącym te zajęcia, poza osobami prowadzących, był wymóg zrozumienia jak należy przeprowadzać badania.

Co mierzymy?

Jest to pierwsze pytanie, na które musimy sobie odpowiedzieć. Jeżeli nie jesteśmy, w stanie określić, co chcemy zmierzyć, to przystępowanie do pomiarów nie ma sensu. Co więcej, należy określić bardziej abstrakcyjny cel pomiaru. Przykładowo, jeżeli mierzę wydajność mojego kodu, to muszę powiedzieć, wobec czego ta wydajność jest mierzona. Wobec innej wersji kodu? W kontekście specyficznych danych? Czy też jest to pomiar wstępny mający za zadanie określić z wielkościami, jakiego rzędu mamy do czynienia?

Jak mierzymy?

Drugie pytanie dotyczy wybranej metody pomiaru. Zazwyczaj daną wielkość można zmierzyć na wiele sposobów. Zapewne spotkaliście się z historią jak, to Ernest Rutherford egzaminował młodego Nilsa Bohra. Podobnie ma się sprawa z każdym pomiarem. Jeżeli chcę zmierzyć wydajność jakiegoś kodu, to muszę określić, w jaki sposób będę ją mierzyć. Czy mierzę czas pojedynczej operacji? Czy też zliczam ilość operacji w przedziale czasu? Czy pomiar prowadzę w środowisku kontrolowanym, czy swobodnym? Jak duża jest próba? W końcu, co ważne w środowisku JVM (i każdym innym z JIT), jak wygląda rozgrzewka maszyny?

Jakie są wady i zalety wybranej metody pomiarowej?

Jak już wybiorę odpowiednią metodę, to należy uzasadnić ten wybór. Przykładowo, mam fragment kodu, który będzie często wykorzystywany na produkcji. Wybrałem metodę pomiaru liczby operacji w ciągu sekundy z rozgrzewką. Pomiar prowadzę w środowisku izolowanym (fizyczna maszyna przeznaczona do tego typu testów). Zaletą tej metody duża precyzja wynikająca z izolacji. Jako że wykonam najpierw rozgrzewkę, to JIT dokona odpowiednich kompilacji. W efekcie otrzymam pomiar precyzyjny oraz powtarzalny (obarczony małym błędem). Jednak metoda ta nie pozwala nam na określenie, czy w środowisku produkcyjnym wydajność będzie podobna. Wynika to z izolacji testów i tym samym braku możliwości odtworzenia warunków produkcyjnych. Czy jest to poważna wada? Zapewne tak, jeżeli przyjmiemy, że od wyników testów zależy wdrożenie naszego kodu. Czy kod spełnia założone parametry?

Jak określić niedokładność metody i błąd pomiaru?

Z wadami wiąże się jeszcze jeden temat. O ile sama identyfikacja wad może być prosta, to już określenie, w jakim stopniu wpływają one na wyniki, jest trudniejsze. Po pierwsze musimy określić, jak dalece niedokładną metodę stosujemy. Zazwyczaj oznacza, to określenie granulacji mierzonej wartości. Liczba operacji na sekundę jest świetnym przykładem. Wiemy jakiej metryki używamy i jaka jest minimalna mierzalna wartość. Potrafimy też wskazać jakie czynniki wpływają na sam pomiar np. dokładność System.nanoTime. Uzbrojeni w taką wiedzę możemy wyliczyć błąd pojedynczego pomiaru albo odchylenie standardoweW.
Na koniec czeka nas jeszcze jedno drobne zadanie. Musimy zweryfikować, czy sposób pomiaru, nie wpłynął na wartość mierzoną. Następnie musimy uwzględnić, to w naszym rachunku błędów.

Jak już przebrniemy przez powyższe zagadnienia, to możemy przystąpić do pomiarów.

Rola jmh

Gdzie można umieścić jmh? Narzędzie to ułatwia nam tworzenie benchmarków, ponieważ pozwala w łatwy sposób zdefiniować sam pomiar. Co więcej, na zakończenie wyliczy za nas odpowiednie wartości związane z błędami pomiarów. Tym samym nie musimy samodzielnie rzeźbić kodu do matematyki. Dostarcza nam też odpowiednich narzędzi pomiarowych. Zatem nasze zadanie ogranicza się jedynie do określenia co i w jaki sposób chcemy zmierzyć. Oczywiście po naszej stronie jest też odpowiedź na pytanie, czy wybrana metoda ma sens.

Praktyka

Po tym przydługim wstępie teoretycznym przejdźmy do praktyki. W jej ramach porównamy sobie cztery różne implementacje operacji sumowania elementów kolekcji/streamu.

  • Sumowanie z wykorzystaniem pętli for
  • Sumowanie z wykorzystaniem stream
  • Sumowanie z wykorzystaniem parallel stream
  • Sumowanie z wykorzystaniem ForkJoin Framework

Żeby nie było prosto, w naszym kodzie wykorzystamy BigInteger, a następnie porównamy wyniki z kodem zaimplementowanym z użyciem long.

Sam kod wygląda następująco.

Listing 1. Implementacja w oparciu o BigInteger

public class BigIntBenchmark {

    @Benchmark
    public void loopWithSum(Blackhole blackhole) {
        BigInteger sum = new BigInteger("0");

        for (int i = 0; i < MAX; i++) {
            sum = sum.add(new BigInteger(i + ""));
        }
        blackhole.consume(sum);
    }

    @Benchmark
    public void streamWithSum(Blackhole blackhole) {
        blackhole.consume(IntStream.range(0, MAX)
                .mapToObj(i -> new BigInteger(i + ""))
                .reduce(new BigInteger("0"), BigInteger::add));
    }

    @Benchmark
    public void pStreamWithSum(Blackhole blackhole) {
        blackhole.consume(IntStream.range(0, MAX).parallel()
                .mapToObj(i -> new BigInteger(i + ""))
                .reduce(new BigInteger("0"), BigInteger::add));
    }

    @Benchmark
    public void fjWithSum(Blackhole blackhole) {
        ForkJoinPool pool = new ForkJoinPool();
        blackhole.consume(pool.invoke(new BigIntFJT(0, MAX)));
    }

}


class BigIntFJT extends RecursiveTask<BigInteger> {

    private final int start;
    private final int end;
    private final int MAX = 1_000;


    public BigIntFJT(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected BigInteger compute() {
        if (end - start <= MAX) {
            BigInteger sum = new BigInteger("0");

            for (int i = start; i < end; i++) {
                sum = sum.add(new BigInteger(i + ""));
            }

            return sum;
        }
        BigIntFJT fjt1 = new BigIntFJT(start, start + ((end - start) / 2));
        BigIntFJT fjt2 = new BigIntFJT(start + ((end - start) / 2), end);

        fjt1.fork();
        fjt2.fork();
        return fjt1.join().add(fjt2.join());
    }
}

To samo w przypadku użycia long wygląda następująco:

Listing 2. Implementacja w oparciu o long

public class LongBenchmark {

    @Benchmark
    public void loopWithSum(Blackhole blackhole) {
        long sum = 0L;

        for (int i = 0; i < MAX; i++) {
            sum += i;
        }
        blackhole.consume(sum);
    }

    @Benchmark
    public void streamWithSum(Blackhole blackhole) {
        blackhole.consume(LongStream.range(0, MAX).sum());
    }

    @Benchmark
    public void pStreamWithSum(Blackhole blackhole) {
        blackhole.consume(LongStream.range(0, MAX).parallel().sum());
    }

    @Benchmark
    public void fjWithSum(Blackhole blackhole) {
        ForkJoinPool pool = new ForkJoinPool();
        blackhole.consume(pool.invoke(new BigIntFJT(0, MAX)));
    }
}


class LongFJT extends RecursiveTask<Long> {

    private final int start;
    private final int end;
    private final int MAX = 1_000;


    public LongFJT(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= MAX) {
            long sum = 0L;

            for (int i = 0; i < MAX; i++) {
                sum += i;
            }

            return sum;
        }
        LongFJT fjt1 = new LongFJT(start, start + ((end - start) / 2));
        LongFJT fjt2 = new LongFJT(start + ((end - start) / 2), end);

        fjt1.fork();
        fjt2.fork();
        return fjt1.join() + fjt2.join();
    }
}

Jednym odstępstwem od normy jest wykorzystanie Long w przypadku ForkJoin, bo to typ generyczny.

Nasz benchmark jest już gotowy. Możemy go skompilować za pomocą mavena i uruchomić. Będzie działać w domyślnych ustawieniach. Warto jednak rzucić okiem w bebechy naszego kodu.

@Benchmark i Blackhole

Adnotacja @Benchmark wskazuje na metody, które są mierzone. Dla nich zostanie wygenerowane odpowiednie środowisko i wykonane pomiary. Warto zwrócić uwagę, na fakt, że w trakcie kompilacji jmh generuje dodatkowe klasy, które są właściwym pomiarem. W nich właśnie dzieje się cała pomiarowa magia.

Czym jest Blackhole? Żeby zrozumieć zadanie tej klasy, należy najpierw sięgnąć w głąb JVM. Jak uruchamiamy maszynę wirtualną, to jednym z elementów, które wstają praktycznie na samym początku, jest JIT. Narzędzie to śledzi kod pod kątem "gorących metod". Po angielsku hot spots stąd też nazwa maszyny Suna. Jeżeli zidentyfikuje on takie miejsce, to następuje proces kompilacji bytecodu do kodu maszynowego. Dzięki temu kod ten będzie wykonywany bezpośrednio, z pominięciem interpretera. Jednakże JIT potrafi też kilka innych rzeczy. Potrafi optymalizować kod. Przyjrzyjmy się naszej metodzie sumującej kolejne liczby w pętli, ale z lekką modyfikacją:

Listing 3. Metoda loopWithSum

public void loopWithSum(Blackhole blackhole) {
    BigInteger sum = new BigInteger("0");
    
    for (int i = 0; i < MAX; i++) {
        sum = sum.add(new BigInteger(i + ""));
    }
    // blackhole.consume(sum);
}

Co dzieje się ze zmienną sum na koniec działania kodu? Nic się nie dzieje. Znika ona ze stosu i nie jest nigdzie zapisywana. Zatem można pominąć wszystkie jej modyfikacje w kodzie, bo są bezproduktywne. Oczywiście w tym przypadku nie jest tak do końca, bo jednak JIT nie wie, co dzieje się w BigInteger i nie może na pałę dokonać optymalizacji. Jednak w przypadku kodu z long nic nie stoi na przeszkodzie, by po prostu usunąć ciało metody.
Jak temu można zapobiec? Na dwa sposoby. Pierwszy to zwracanie wartości z naszej metody. Osobiście jestem na nie, ponieważ chciałbym tworzyć kod, który jest spójny z testami. Drugi sposób to przekazać rezultat gdzieś w świat. W tym przypadku wrzucić go do czarnej dziury. Takie coś wystarczy, by JIT nie dokonał eksterminacji naszego kodu.

Konfigurowanie pomiarów

Jak już wspomniałem, uruchomienie naszego kodu odbędzie się z wartościami domyślnymi. Każda metoda zostanie uruchomiona dwadzieścia razy w ramach rozgrzewki, następnie dwadzieścia razy w ramach właściwych pomiarów, a cały cykl zostanie powtórzony dziesięć razy. Sam pomiar będzie domyślnie zajmować jedną sekundę. Jak łatwo obliczyć cały test zajmie nam około 53 minut (2 klasy * 10 cykli * 4 metody per klasa w każdym cyklu * 40 pomiarów i rozgrzewek). Trochę dużo...

Konfiguracji możemy dokonać na trzy sposoby. Po pierwsze z linii poleceń możemy ustawić odpowiednie parametry. Uruchomienie naszego pomiaru z przełącznikiem -h wyświetli listę i RTFMW. Jest to niezła metoda, jeżeli chcemy coś na szybko odpalić z jakimiś nietypowymi ustawieniami. Parametry te nadpisują konfigurację w kodzie. Drugą metodą jest wspomniana przed chwilą konfiguracja w kodzie:

Listing 4. Przykładowa konfiguracja w kodzie

@Benchmark
@Warmup(iterations = LOOP, time = TIME, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = LOOP, time = TIME, timeUnit = TimeUnit.MILLISECONDS)
@Fork(LOOP)
public void loopWithSum(Blackhole blackhole) {
    BigInteger sum = new BigInteger("0");

    for (int i = 0; i < MAX; i++) {
        sum = sum.add(new BigInteger(i + ""));
    }
    blackhole.consume(sum);
}

Ten rodzaj konfiguracji sprawdzi się najlepiej, jeżeli chcemy śledzić zmiany. W takim przypadku dodanie konfiguracji do kodu pozwoli nam na zarządzanie zmianami za pomocą na przykład gita. Trzecią metodą jest użycie klasy OptionBuilder. W tym przypadku z poziomu naszego kodu możemy uruchomić pomiar, konstruując odpowiedni zestaw parametrów. Jest to najlepsza opcja, jeżeli chcemy zintegrować jmh z serwerem CI.

Rodzaje pomiarów

jmh domyślnie wykonuje pomiar wydajności wyrażonej w liczbie operacji na sekundę. Tego typu pomiary są najpopularniejsze i sprawdzą się w większości przypadków. Jeżeli jednak chcemy zmierzyć inne wartości, to musimy wykorzystać adnotację @BenchmarkMode z odpowiednim parametrem:

  • Throughput – wydajność ops/s domyślny pomiar.
  • AverageTime – średni czas wykonania operacji. Jest to odwrotność poprzedniego.
  • SampleTime – sampling. Uruchamia test i losowo co pewien czas mierzy czas wykonania metody.
  • SingleShotTime – pomiar pojedynczego wywołania. Bez rozgrzewki.
  • All – wszystkie pomiary, ale w ramach osobnych cykli.

Wybierając odpowiedni tryb, należy zwrócić uwagę na to, co mierzymy. Średni czas wykonania metody raczej jest słabą metryką w przypadku prostych metod jak nasze. Z drugiej strony może być świetnym rozwiązaniem w przypadku metod, które wykonują złożone operacje na dużych zbiorach danych.

Podsumowanie

Czas na podsumowanie. jmh daje nam całkiem duże możliwości, jeśli chodzi o pomiar wydajności. Jest to narzędzie, które odciąża nas w zakresie przygotowania maszyny wirtualnej (rozgrzewka), poprawności pomiaru (mierzy różne rzeczy) oraz statystyki (dostajemy odchylenie jako element wyniku). Warto wdrożyć to narzędzie do naszego arsenału.

Na koniec jeszcze jedna uwaga. Przemyślcie dokładnie co i jak chcecie mierzyć, ponieważ źle zaprojektowany pomiar może prowadzić do bardzo dziwnych wniosków i tym samym problemów na produkcji. Co gorsza, źle wykonany pomiar podważy zaufanie do narzędzia, bo przecież mierniczy się nie myli 😉

Kod dostępny tu