Poprzedni wpis prezentował sposób implementacji CQRS przy wykorzystaniu refleksji. Natomiast należałoby jeszcze sprawdzić czy to co napisaliśmy działa zgodnie z założeniami. Z pomocą przyjdą nam testy jednostkowe. Dlatego nie zwlekając przejdźmy do meritum tego artykułu.

Przypadki testowe

Odkryłem, że warto byłoby napisać sześć przypadków testowych dla komend oraz pięć dla eventów. Co prawda testy dla eventów są identyczne jak te dla komend, ale koniecznie trzeba je również napisać. Z tego powodu do rozważań weźmiemy przypadki dla komend, które prezentują się następująco:

  • poprawne skonstruowanie szyny komend
  • prawidłowe obsłużenie komendy, wysłanej za pomocą szyny, przez odpowiedni handler
  • rzucenie wyjątku jeżeli brak handlera dla wysyłanej komendy
  • wyrzucenie błędu, gdy podjęto próbę rejestracji handlera bez generycznego interfejsu
  • nie pozwolenie na rejestrację handlera nie obsługującego konkretnej implementacji komendy
  • brak możliwości rejestracji dwóch handlerów dla tej samej komendy (różnica z przypadkami dla eventów)

Poprawne skonstruowanie szyny komend

Przypadek ma za zadanie sprawdzić czy szyna komend odpowiednio zarejestrowała handlery obsługujące komendy. Na wejściu tworzymy nasz obiekt testowy podając mu tylko jeden handler do konstruktora w formie kolekcji. Następnie wywołujemy gettera o dostępie pakietowym, stworzonego na potrzeby testów, aby dobrać się do naszych handlerów. Na koniec weryfikujemy czy znajduje się wśród nich nasz handler oraz czy nie ma tam jakiegoś innego przypadkowego.

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void should_properly_set_commands_bus() {
  ProperCommandHandler properCommandHandler = new ProperCommandHandler();
  AutoCommandsBus commandsBus = new AutoCommandsBus(
      Set.of(properCommandHandler)
  );

  var handlers = commandsBus.getHandlers();

  assertThat(handlers.get(HandledCommand.class)).isEqualTo(properCommandHandler);
  assertThat(handlers.get(NotHandledCommand.class)).isNull();
}

W trakcie pisania tego wpisu zastanawiam się nad słusznością tego testu. Nie wiem czy nas użytkowników interesuje w jaki sposób szyna zarejestruje nasze handlery. My chcemy mieć tylko pewność, że wysyłając komendę zostanie ona odpowiednio obsłużona, co weryfikują kolejne testy. W ten sposób można by pozbyć się tego przypadku, a co za tym idzie, zbędnego gettera. Warto przemyśleć jeszcze raz tą sytuację.

Prawidłowe obsłużenie komendy, wysłanej za pomocą szyny, przez odpowiedni handler

Kolejnym sprawdzeniem jest zarejestrowanie handlera, wysłanie komendy oraz zweryfikowanie czy została ona poprawnie obsłużona. Żeby nie zagłębiać się w szczegóły implementacyjne postanowiłem użyć tutaj zapisu do tymczasowego pliku. Według tego planu komenda ma w sobie jakiś komunikat, który handler zapisze w pliku, a my go sobie odczytamy i zestawimy w asercji.

1
2
3
4
5
6
7
8
9
10
11
@Test
void should_command_handler_handle_command() {
  Path path = TestFiles.createTestFileInDir("auto_commands_bus_test_%s" + System.currentTimeMillis(), tempDir);
  AutoCommandsBus commandsBus = new AutoCommandsBus(
      Set.of(new ProperCommandHandler(path))
  );

  commandsBus.send(new HandledCommand("Message"));

  assertThat(TestFiles.readFileLines(path)).containsExactlyInAnyOrder("Message");
}

Ten test wydaje się skuteczny i na pewno służy dobrej sprawie. Szczerze chwilę się głowiłem, żeby wpaść na ten pomysł, ale jestem zadowolony z finalnego rozwiązania.

Rzucenie wyjątku jeżeli brak handlera dla wysyłanej komendy

Musimy również uwzględnić sytuację, w której nie będziemy mogli obsłużyć komendy, ponieważ nie będzie miała ona swojego handlera. Zaplanowałem, że zostanie wtedy rzucony własnoręcznie stworzony wyjątek NoHandlerForCommandException. Należy to teraz sprawdzić czy faktycznie tak zadziała.

1
2
3
4
5
6
7
8
9
@Test
void should_fail_when_no_command_handler_for_command() {
  AutoCommandsBus commandsBus = new AutoCommandsBus(
      Set.of(new ProperCommandHandler())
  );

  assertThatThrownBy(() -> commandsBus.send(new NotHandledCommand()))
      .isInstanceOf(NoHandlerForCommandException.class);
}

Używając assertThatThrownBy sprawdzamy czy faktycznie zostaje rzucony odpowiedni wyjątek. Test wydaje się łatwy, prosty i przyjemny, czyli taki jaki powinien być.

Wyrzucenie błędu, gdy podjęto próbę rejestracji handlera bez generycznego interfejsu

Jest to weryfikacja przypadku, kiedy to ktoś spróbowałby utworzyć takiego handlera ‘class CommandHandlerWithoutGeneric implements CommandHandler’. Ta klasa nie ma zdefiniowane jaką dokładnie komendę obsługuje. Trzeba, więc zrobić zabezpieczenie na taką ewentualność i rzucić odpowiedni wyjątek.

1
2
3
4
5
@Test
void should_fail_when_set_command_handler_without_generic() {
  assertThatThrownBy(() -> new AutoCommandsBus(Set.of(new CommandHandlerWithoutGeneric())))
      .isInstanceOf(NotImplementedCommandHandlerInterfaceException.class);
}

Test mieści się w dwóch linijkach, a na pewno zaoszczędzi nam wielu chwil debugowania.

Nie pozwolenie na rejestrację handlera nie obsługującego konkretnej implementacji komendy

Ten przypadek jest podobny do powyższego. Jeżeli klasa handlera zostałaby zdefiniowana następująco ‘class CommandHandlerForCommandInterface implements CommandHandler’ to również nie wiadomo jaką komendę on obsługuje. Jako swoją wartość generyczną ma podany interfejs Command zamiast konkretnej implementacji.

1
2
3
4
5
@Test
void should_fail_when_set_command_handler_without_generic_command_implementation() {
  assertThatThrownBy(() -> new AutoCommandsBus(Set.of(new CommandHandlerForCommandInterface())))
      .isInstanceOf(NotImplementedCommandInterfaceException.class);
}

Z tego właśnie powodu również i tutaj trzeba to sprawdzić oraz zabezpieczyć się poprzez rzucenie wyjątku.

Brak możliwości rejestracji dwóch handlerów dla tej samej komendy

Na koniec został do sprawdzenia bardzo ważny przypadek. Czy, aby na pewno nie pozwalamy dodać dwóch handlerów dla jednej komendy. Jest to nasze założenie architektoniczne i warto, aby było spełnione. Właśnie dla tego powstał ten przypadek testowy.

1
2
3
4
5
@Test
void should_fail_when_two_command_handlers_for_command() {
  assertThatThrownBy(() -> new AutoCommandsBus(Set.of(new ProperCommandHandler(), new RedundantCommandHandler())))
      .isInstanceOf(IllegalStateException.class);
}

Może nie jest to najszczęśliwszy wyjątek jaki łapiemy, ale program na pewno nam nie pozwoli zarejestrować takiego przypadku. IllegalStateException akurat wynika z faktu, że nie możemy wstawić do mapy dwóch tych samych kluczy podczas wywołania metody Stream.collect(Collectors.toMap). Warto zostawić ten test, aby poinformować o tym, że nie pozwalamy na rejestrowanie dwóch handlerów dla tej samej komendy.

Podsumowanie

Pisanie testów jednostkowych jest naprawdę ważne (6 zalet ich pisania). Nie dość, że sprawdzamy działanie naszego rozwiązania to jeszcze informujemy innych co tak właściwe robi nasz kod. Teraz mam pewność, że moja implementacja dla CQRS działa prawidłowo i będę mógł je bezpiecznie zastosować w moim projekcie.