Równoległe uruchomienie testów jednostkowych ma istotną przewagę nad klasycznym podejściem w postaci szybszego otrzymania informacji zwrotnej o działaniu naszej aplikacji. Jednak nigdy nie ma nic za darmo. Aby uzyskać taki efekt trzeba się trochę napocić. O czym należy więc pamiętać i jakich pułapek unikać? Zaraz przekonamy się o tym na przykładzie wykorzystania biblioteki JUnit.

Przypadek do rozważań

Zanim zaczniemy, musimy sobie nakreślić jakąś sytuację biznesową. Załóżmy, że aplikacja, którą projektujemy, komunikuje się z zewnętrznym systemem. Nauczeni doświadczeniem schowaliśmy sobie od razu tą usługę za interfejsem, aby przyjemniej nam się pisało testy jednostkowe. Ten komponent jest naprawdę trywialny, ponieważ jedyną rzeczą jaką robi jest dostarczenie informacji o tym czy dany system działa czy też nie.

1
2
3
4
5
public interface ExternalService {

  boolean isWorking();

}

Nasza biznesowa klasa BusinessClass korzysta z powyższego interfejsu. Służy on jej do tego, aby dostarczyć użytkownikowi aplikacji wiadomość o tym czy zewnętrzna usługa jest dostępna, czy też nie. Jeśli jest to zwracamy odpowiedni literał. Jeśli nie to wyskakuje wyjątek ExternalServiceNotWorkingException.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class BusinessClass {

  private final ExternalService externalService;

  public BusinessClass(ExternalService externalService) {
    this.externalService = externalService;
  }

  public String doSomething() {
    if (externalService.isWorking()) {
      return "WORKING!";
    }
    throw new ExternalServiceNotWorkingException();
  }

}

Bardzo prosty kod, który nam w zupełności wystarczy na potrzeby artykułu. Sprawdźmy jeszcze jak wygląda kod testowy. W nim znajdziemy fake interfejsu ExternalService w postaci klasy FakeBusinessService, która pozwala nam na sterowanie dostępnością serwisu. Robi to poprzez wykorzystanie settera - setWorking.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class FakeExternalService implements ExternalService {

  private boolean working = true;

  public void setWorking(boolean working) {
    this.working = working;
  }

  @Override
  public boolean isWorking() {
    return working;
  }

}

Same testy pokrywają dwa przypadki. Pierwszy z nich to sytuacja, w której zewnętrzna usługa działa. W takim wypadku użytkownik otrzyma prosty komunikat o treści "WORKING!". Jeśli natomiast dokonamy symulacji braku dostępności tego systemu to rzucony zostanie wcześniej przedstawiony wyjątek.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class UnitTest extends AbstractTest {

  BusinessClass sut = new BusinessClass(EXTERNAL_SERVICE);

  @Test
  void exceptionIfThrownWhenExternalServiceIsNotWorking() {
    // given
    EXTERNAL_SERVICE.setWorking(false);

    // when/then
    assertThrows(ExternalServiceNotWorkingException.class, () -> sut.doSomething());
  }

  @Test
  void stringIsReturnedWhenExternalServiceIsWorking() {
    // given
    EXTERNAL_SERVICE.setWorking(true);

    // when
    String result = sut.doSomething();

    // then
    assertEquals("WORKING!", result);
  }

}

Inicjalizacja zmiennej o nazwie EXTERNAL_SERVICE znajduje się w klasie AbstractTest. To do niej przypisujemy fake w postaci new FakeExternalService(). Zapamiętaj to proszę, ponieważ ta informacja jest cenna z punktu widzenia tego o czym będę mówił za chwilę.

Przygotowanie pod zrównoleglenie

Wszystko pięknie i ładnie, ale czy dwa przypadki testowe wystarczą, aby pokazać zysk korzystania z równoległych testów? Pewnie, że nie. Z tego powodu sztucznie zwiększymy ich ilość. Zrobimy to wykonując trzy kroki:

  • umieścimy każdą metodę testową w oddzielnej klasie testowej
  • zamienimy adnotację @Test na @RepeatedTest(10_000), aby wykonać ten sam test 10 000 razy
  • zduplikujemy każdą z klas pięciokrotnie

W ten sposób chociaż w małym stopniu odwzorujemy prawdopodobną sytuację produkcyjną. Do utworzenia projektu posłużymy się Gradlem. Zrezygnowałem z Mavena z jednego, ważnego powodu. Opowiem o nim dopiero na koniec tego artykułu.

Klasyczne wywołanie testów w sposób “jeden po drugim”

Dobra, sprawdźmy jak wygląda wykonanie domyślnie skonfigurowanego jarzma testowego.

Szeregowe wywołanie testów Szeregowe wywołanie testów

Powyżej widzimy raport z testów, który przygotował nam Gradle. Mała dygresja - jeśli chcemy wykonać testy w Gradle to musimy skorzystać z komendy gradle test. Jednak, gdy nie dokonamy żadnej zmiany w kodzie to Gradle już nam ich nie wykona. Wszystko dzięki jego optymalizacjom. Aby to zmienić wystarczy napisać w terminalu gradle cleanTest test. Wtedy testy zawsze nam się wykonają.

Wracając do głównego wątku. Całość testów (jest ich 100_000) wykonała się w 2.713s. Zapamiętajmy ten wynik. Jeśli spojrzymy sobie na poszczególne czasy wykonania klas testowych to zauważymy, że pierwsza z nich wykonywała się najdłużej. Ona “wygrzała” nam środowisko, a reszta z niego skorzystała. Stąd ich krótsze wykonanie.

Szeregowe wywołanie testów - szczegóły Szeregowe wywołanie testów - szczegóły

Warto nadmienić, że zmienna EXTERNAL_SERVICE nie była w tym przypadku zadeklarowana jako statyczna. Dlatego w każdym z testów powstawała nowa instancja klasy FakeExternalService, o czym informują nas logi:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@22066637
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@6c22b54e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@27cb3401
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@49a88ca6
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@588e6c7a
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@21ff2ae0
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@5f6a69e2
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@52aa1315
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@796c4886
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@219cc561
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@2db59dc9
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@2f8511d1
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@34b644df
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@73c638e5
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@721ca881
...

Co się stanie w przypadku, gdy utworzymy tylko jedną instancją klasy FakeExternalService? Czy testy nam przyspieszą?

Jedna zmienna dla wszystkich testów

Wejdźmy do abstrakcyjnej klasy AbstractTest i podmieńmy jedyną dostępną tam linijkę.

1
public static final FakeExternalService EXTERNAL_SERVICE = new FakeExternalService();

Uruchommy testy dla nowej sytuacji. Gdy wejdziemy w logi zobaczymy, że mamy już tylko jedną instancję klasy FakeExternalService. Tak jak chcieliśmy.

1
2
3
4
5
6
7
8
9
10
11
12
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
Thread[#1,Test worker,5,main] - working test - pl.cezarysanecki.FakeExternalService@78fa769e
...

Szeregowe wywołanie testów - współdzielona instancja Szeregowe wywołanie testów - współdzielona instancja

W czasie wykonania widać nieznaczną poprawę. Jednak ta dysproporcja 0.35 sekundy nie jest aż taka duża. W dodatku jeśli kilka razy uruchomilibyśmy te dwa przypadki, to za każdym razem otrzymalibyśmy inne wyniki. Co ciekawe, raz jedna sytuacja będzie szybsza, a raz druga. Wychodzi na to, że nie ma to aż takiego znaczenia dla czasu wykonania testów, z którego rozwiązania skorzystamy. Oczywiście dla pamięci pierwsze rozwiązanie będzie gorsze plus nie wiem jak mogłaby wpłynąć również na to większa ilość takich zmiennych. To zostawiam Tobie do weryfikacji jeśli jesteś tego ciekawy/ciekawa. Oczywiście daj znać w komentarzu, gdy odkryjesz coś ciekawego.

Przyszła pora na zrównoleglenie testów

W projekcie, na którym jestem korzystamy ze Spocka. Tam konfiguracja zrównoleglenia odbywa się w sposób przedstawiony tutaj w dokumentacji. Natomiast na potrzeby tego artykułu skorzystałem z JUnit. To właśnie na jego przykładzie przedstawię co należy zrobić, aby osiągnąć oczekiwany przez nas efekt. Nie jest to skomplikowane. Po pierwsze w katalogu /test/resources tworzymy plik o nazwie junit-platform.properties. W nim dodajemy następującą linijkę.

1
junit.jupiter.execution.parallel.enabled=true

W ten sposób zezwoliliśmy w naszym projekcie na równoległe wykonanie testów. Jednak domyślnie całe jarzmo testowe nadal będzie wykonywało się szeregowo. Aby to zmienić musimy dodać jeszcze dodatkową konfigurację.

1
2
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.mode.default=same_thread

Taki zestaw sprawia, że każda klasa dostaje swój wątek, a testy w niej wykonują się szeregowo. Najlepiej to obrazuje rysunek przedstawiony w dokumentacji. Można oczywiście zdecydować się na pełną równoległość, ale nie wydaje się, aby to było konieczne.

I to tyle. Można oczywiście jeszcze konfigurować równoległe wykonanie pojedzynczego testu albo klasy testowej przy pomocy adnotacji @Execution. Jednak u nas nie jest to potrzebne. Uruchommy testy, ale przed tym usuńmy jeszcze słowo kluczowe static przy zmiennej EXTERNAL_SERVICE. Do tej zmiany wrócimy za chwilę.

Równoległe wywołanie testów Równoległe wywołanie testów

Co?! Prawie 12 sekund?! Przecież miało być szybciej! Zgadza się i jest szybciej. Ta wartość to suma wykonania wszystkich klas testowych. Tak naprawdę rzeczywistym czasem trwania testów są najdłużej trwające testy w ramach jednej klasy testowej. Oczywiście o ile mamy wystarczającą liczbę wątków do zaspokojenia wszystkich klas testowych. Sprawdźmy jak prezentują się szczegóły wykonania równoległych testów.

Równoległe wywołanie testów - szczegóły Równoległe wywołanie testów - szczegóły

Wychodzi na to, że rzeczywisty czas wykonania to 1.318 sekundy. To dwa razy szybciej niż dla szeregowego przypadku. Teraz widać ten zysk.

No dobra, spróbujmy jeszcze przywrócić static dla zmiennej EXTERNAL_SERVICE. Domyślasz się co się stanie? Sprawdźmy to.

Równoległe wywołanie testów - problem ze współdzieleniem Równoległe wywołanie testów - problem ze współdzieleniem

Tylko 87% testów zakończyło się powodzeniem. Wynika to z prostego faktu, że mamy tylko jedną instancję klasy FakeExternalService dla wszystkich testów, które odpalane są na różnych wątkach. Może się więc zdarzyć sytuacja (jak widać, nawet nierzadko), że jeden test ustawi flagę na true i zanim wywoła kod produkcyjny, inny test przestawi mu ją na false. Oczywiście w drugą stronę też to jest jak najbardziej możliwe. Wniosek jest więc prosty. Nie możemy mieć punktów styku pomiędzy testami działającymi równolegle. Jeśli kod nie będzie prosty jak w tym przypadku ciężko będzie nam namierzyć winowajców.

Ciekawostka

Przy okazji pisania tego artykułu natrafiłem na ciekawy błąd podczas pracowania z Mavenem. Początkowo chciałem Ci przedstawić problem zrównoleglenia testów korzystając z tego narzędzia. Jednak z powodu natrafionego problemu, nie było to do końca możliwe w sposób jaki sobie zaplanowałem. Ale o co chodzi? Wróćmy do sytuacji, gdzie skonfigurowaliśmy sobie równoległość w testach oraz zmienna EXTERNAL_SERVICE nie była statyczna. Załóżmy też, że naszym narzędziem do budowania jest właśnie Maven. Odpalmy więc przy jego pomocy testy za pomocą komendy mvn test.

1
2
3
4
5
6
7
8
9
10
[INFO] Tests run: 97352, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.435 s -- in pl.cezarysanecki.p1.WorkingUnit4Test
[INFO] Tests run: 217, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.442 s -- in pl.cezarysanecki.p1.WorkingUnit1Test
[INFO] Tests run: 644, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.468 s -- in pl.cezarysanecki.p1.WorkingUnit3Test
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.469 s -- in pl.cezarysanecki.p1.WorkingUnit5Test
[INFO] Tests run: 260, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.481 s -- in pl.cezarysanecki.p1.WorkingUnit2Test
[INFO] Tests run: 1290, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.543 s -- in pl.cezarysanecki.p1.ExceptionUnit2Test
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.543 s -- in pl.cezarysanecki.p1.ExceptionUnit1Test
[INFO] Tests run: 169, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.552 s -- in pl.cezarysanecki.p1.ExceptionUnit5Test
[INFO] Tests run: 38, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.554 s -- in pl.cezarysanecki.p1.ExceptionUnit3Test
[INFO] Tests run: 28, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.556 s -- in pl.cezarysanecki.p1.ExceptionUnit4Test

Wynik w logach może wprawić nas w osłupienie. Pomimo deklaracji, przy pomocy adnotacji @RepeatedTest(10_000), że chcemy aby każdy test wykonał się dokładnie 10 000 razy, Maven nie potrafił jej spełnić. Ba, nawet niektóre testy, jak w przypadku WorkingUnit5Test, się nie wykonały. Jest to problem pluginu Surefire, który został lepiej opisany w tym artykule w sekcji Limitations.

Podsumowanie

Zrównoleglenie testów na pewno pomoże nam zaszczędzić sporo czasu podczas ich uruchomienia. Jednak wprowadzenie zrównoleglenia może nie być takie proste jeśli projekt, na którym pracujemy, jest “dojrzały”. Chyba, że nikt w nim nie skorzystał ze statycznych zmiennych wykorzystywanych przez wiele klas testowych. Może Ty znasz jeszcze jakieś sytuacje, w których nie będzie możliwe zrówoleglenie testów albo będzie to bardzo utrudnione? Daj znać w komentarzu!