Validation – Operation – Notification

zapisywanie testów metodą given when then

Given When Then – krótkie wprowadzenie

Zapewne słyszałeś już o podejściu do pisania metod wykorzystującym trzy bloki – given-when-then. Jest to wzorzec zapisu testów, który jest również niejako częścią BDD. Polega on na podzieleniu kodu testu na trzy grupy:

  • Given – przygotowanie. Tutaj zadajemy sobie kilka pytań (w zależności od podejścia):
    – Jakich danych potrzebuję przed wykonaniem testu?
    – Czy potrzebuję jakichś zależnych obiektów?
  • When – operacja, którą faktycznie testujemy
  • Then – nasze oczekiwania

Oczywiście nie jest to jedyne podejście do zapisywania testów, ale zyskało ono duże grono zwolenników, głównie ze względu na swoją czytelność.

Weźmy przykład:

@Test
public void should_notify_about_new_issue_created() throws Exception {
String title = "My title";
String description = "My description";

Issue issue = orchestrationExample.createIssue(title, description);

verify(notificationPublisher, times(1)).publish(any());
}

Ten test sprawdza, czy jeśli będziemy tworzyć nowe zgłoszenie (issue) w naszym systemie, to równocześnie zostanie wysłane oczekiwane przez nas powiadomienie. Czytamy go w ten sposób:

  • Mając (Given) tytuł (title) oraz opis (description)
  • Gdy (When) stworzę nowe zgłoszenie (issue)
  • To (Then) jedno powiadomienie zostanie wysłane

Czasem patrząc na test, nie jest to takie oczywiste. Przykładowo:

@Test
public void should_notify_about_new_issue_created() throws Exception {
 Issue issue = orchestrationExample.createIssue(TITLE, DESCRIPTION);
 
 verify(notificationPublisher, times(1)).publish(any());
}

W takim wypadku część given mimo że wygląda, jakby była pusta, to w istocie nie jest. Tyle informacji wystarczy nam, żeby przejść do dalszej części artykułu, ale jeśli zainteresował Cię ten temat, to polecam następujące wpisy:

Podejście Validation – Operation – Notification

Chciałbym w tym artykule zaproponować podobne podejście, ale nie do kodu testowego, lecz do kodu produkcyjnego. Nazwijmy je sobie VON (Validation – Operation – Notification).

  • Walidacja (Validation) – wszystkie wymagania wstępne, jakie muszą zaistnieć oraz zostać spełnione przed wykonaniem właściwej dla danej metody operacji. Mogą to być proste walidacje danych wejściowych, sprawdzenie uprawnień dostępu itd.
  • Operacja (Operation) – serce naszej metody, operacja biznesowa. Ta część jest kluczem i powodem, dla którego tworzymy tę metodę
  • Powiadomienie (Notification) – wszystko, co dzieje się po udanym wykonaniu operacji. Może to być zwykłe zwrócenie wartości z tej metody lub coś dużo większego. Ta część może być wykonywana asynchronicznie bez szkody dla właściwego przepływu biznesu (np. wykorzystując CompletableFuture).

Zobaczmy przykład:


public Issue createIssue(String title, String description) {
 if (title == null) throw new NullPointerException("title can not be null");
 if (description == null) throw new NullPointerException("description can not be null");

Issue issue = issueFactory.createNew(title, description);

Notification notification = create(issue, NEW_ISSUE_CREATED);
 notificationPublisher.publish(notification);
 return issue;
}

Popatrzmy na tę metodę przez pryzmat proponowanego podejścia:

  • Walidacja – weryfikujemy, że tytuł oraz opis nie jest pusty. Bez tego nasza metoda nie ma sensu
  • Tworzymy zgłoszenie (Operacja)
  • Powiadamiamy o stworzonym zgłoszeniu i zwracamy je (Powiadomienie)

Identycznie jak w przyypadku Given-When-Then nie zawsze jest to tak czytelne. Możemy przykładowo przy użyciu biblioteki lombok zapisać naszą metodę w ten sposób:


public Issue createIssue(@NonNull String title, @NonNull String description) {
 Issue issue = issueFactory.createNew(title, description);

Notification notification = create(issue, NEW_ISSUE_CREATED);
 notificationPublisher.publish(notification);
 return issue;
}

Mimo to wciąż widać każdy blok jak na dłoni. I o to chodzi!

Dlaczego warto?

Z mojego doświadczenia wynika, że tę technikę łatwo jest zaaplikować do dowolnego (nawet bardziej skomplikowanego) kodu biznesowego. Niewielkim kosztem można zmienić zastany kod, korzystając właśnie z tego podejścia (przykład poniżej), co znacznie zwiększa jego czytelność, a także zwraca uwagę na potencjalne błędy. Dzięki temu podejściu łatwiej możemy myśleć o błędach, które mogą wystąpić w naszej metodzie. Pisząc metodę, na każdym etapie skupiamy się na innej części, co pozwala na lepszą weryfikację biznesu. Na etapie walidacji zadajemy sobie więcej pytań, na etapie operacji z kolei czujemy się pewnie w stosunku do obiektów, na których operujemy i nie zaprzątamy sobie głowy zbędnymi detalami w postaci publikacji jakichkolwiek innych informacji.

Jeśli uda Ci się w zespole przekonać kolegów do tego podejścia, to można zyskać dodatkowy bonus. Patrząc na metodę, od razu wiadomo, która część jest kluczowa i sam kod produkcyjny łatwiej reprezentuje biznesowe operacje.

Oczywiście nie ma róży bez kolców – czasem takie podejście powoduje wydłużenie metody, nie nadaje się ono też do niskopoziomowych i mocno algorytmicznych operacji.

Obsługa błędów

Na tym etapie zaczynamy wyraźnie dostrzegać zalety zastosowania tego podejścia. Na każdym z etapów możemy bowiem skupić się na innym aspekcie. Prześledźmy kilka scenariuszy.

  1. Wyobraźmy sobie, że coś dzieje się na etapie walidacji. Jedno z pól nie jest spełnione bądź wystąpił jakikolwiek inny problem z przejściem do kolejnego etapu. Mając gwarancję, że to była jedynie walidacja, mamy również gwarancję, że żadne dane nie zostały zmodyfikowane w sposób, którego chcieliśmy uniknąć. Możemy – w zależności od podejścia – rzucić wyjątkiem, zwrócić kod błędu itd. Skoro nie rozpoczęliśmy żadnej operacji, to nie mamy żadnego problemu (w ramach tej metody).
  2. Jeśli coś dzieje się w ramach operacji, to faktycznie mamy problem. Na szczęście jeśli dobrze napisaliśmy część dotyczącą walidacji, to jedyny błąd, jaki może tutaj wystąpić będzie związany nie z niepoprawnymi danymi, ale z technicznymi problemami (sieć internetowa, baza danych, dysk, etc.). Na takie problemy mamy już gotowy szereg rozwiązań. Możemy ponowić operację, odłożyć (poprawne) dane wejściowe do kolejnego wykonania, w najgorszym wypadku musimy zadbać o “sprzątnięcie” danych, które zostały już wprowadzone w ramach tej metody.
  3. Jeśli coś wydarzy się na etapie powiadomienia, to przede wszystkim pamiętajmy o jednym – cel metody został zrealizowany. Operacja powiodła się. Co możemy zrobić z niewysłaną publikacją? Może trzeba ją ponowić, a może tak naprawdę nie trzeba nic robić, bo nie była to informacja krytyczna i wystarczy zalogować tę informację, żeby ktoś mógł poprawić problem i przyjrzeć mu się na spokojnie?

Refaktoryzacja do VON – praktyczny przykład

Spróbujmy teraz zrefaktoryzować przykładowy kawałek kodu do podejścia VON. W tym przykładzie mamy metodę usuwającą kontrakt z firmą bądź pracownikiem. Kontrakt może zostać usunięty, jeśli jeszcze się nie rozpoczął oraz jeśli żadne z jego warunków nie zostały rozpoczęte (przykładowo możemy mieć sytuację, w której według umowy dodatkowym warunkiem jest rezygnacja z kontraktu możliwa najpóźniej na dwa miesiące przed jego rozpoczęciem). Usuwanie zrealizowane jest tutaj przy pomocy tzw. SoftDelete (bardzo nie lubię tego podejścia).

Do dzieła! (Zakładam, że mamy napisane testy jednostkowe, zanim zaczniemy grzebać w tej metodzie, ale nie jest to tematem tego wpisu.)

Przykład metody:


public void deleteContract(Long contractId) {
 Contract contract = contractRepository.getContractById(contractId);
 if (PeriodValidator.isStarted(contract))
 throw new ImmutableObjectException("Can not delete contract because it is already started.");
 if (!contractConditionsService.deleteAllContractConditionsByContract(contract))
 throw new ImmutableObjectException("Can not delete contract because some conditions already started.");

contract.setActive(false);
 contractRepository.save(contract);
 PaymentDisposition paymentDisposition = paymentsService.findPaymentDispositionByContractId(contractId);
 paymentsService.cancelPaymentDispiosition(paymentDisposition);
}

Spróbujmy podzielić naszą metodę na VON i zdecydować, co powinno wydarzyć się w każdej kategorii:

  • Walidacja (validation)
    – nasz kontrakt nie może być usunięty, jeśli już się rozpoczął – to mamy w kodzie
    – nasz kontrakt nie może być usunięty, jeśli któryś z warunków się rozpoczął – to również mamy
    – nie widzę nigdzie walidacji, gdy contractId jest null – pierwszy brak
    – nie widzę nigdzie walidacji tego, co się dzieje, jeśli dany kontrakt nie istnieje w repozytorium – drugi brak
  • Operacja (operation)
    – usuwamy wszystkie dodatkowe warunki kontraktu
    – ustawiamy kontrakt jako nieaktywny (soft-delete)
    – zapisujemy w repozytorium
  • Publikacja (notifications)
    – anuluj płatności dla danego kontraktu – tutaj nie widzę, co się dzieje, jeśli nie ma płatności dla tego kontraktu

Zacznijmy od rozdzielenia tego kodu na etapy. Problematyczny wydaje się fragment z wywołaniem metody  deleteAllContractConditionsByContract – należy ona zarówno do walidacji (jeśli się nie powiedzie, rzucamy wyjątek, który o tym informuje), jak i do operacji. Zacznijmy od prostego rozdzielenia:

</pre>

public void deleteContract(Long contractId) {
Contract contract = contractRepository.getContractById(contractId);
if (PeriodValidator.isStarted(contract))throw new ImmutableObjectException("Can not delete contract because it is already started.");
if (!contractConditionsService.canDeleteAllFor(contract))throw new ImmutableObjectException("Can not delete contract because some conditions already started.");

contractConditionsService.deleteAllContractConditionsByContract(contract);
contract.setActive(false);
contractRepository.save(contract);

PaymentDisposition paymentDisposition = paymentsService.findPaymentDispositionByContractId(contractId);
paymentsService.cancelPaymentDispiosition(paymentDisposition);
}
<pre>

Zakładam, że nie chcemy zmieniać “zewnętrznego świata” i operujemy wyłącznie na tym fragmencie kodu. Nic nie stoi jednak na przeszkodzie, żeby wydzielić prywatne metody. Zacznijmy od tych nieszczęsnych asercji:


public void deleteContract(Long contractId) {
Contract contract = contractRepository.getContractById(contractId);
assertThatContractNotStartYet(contract);
assertThatAnyOfContractConditionsDoesNotStartYet(contract);

contractConditionsService.deleteAllContractConditionsByContract(contract);
contract.setActive(false);
contractRepository.save(contract);

PaymentDisposition paymentDisposition = paymentsService.findPaymentDispositionByContractId(contractId);
paymentsService.cancelPaymentDispiosition(paymentDisposition);
}

Wygląda to już trochę lepiej – mamy wyraźnie zaznaczone bloki. Następnie proponowałbym uzupełnić wspomniane braki w walidacji:


public void deleteContract(@NonNull Long contractId) {
Contract contract = Optional.of(contractRepository.getContractById(contractId)).orElseThrow(ContractNotFoundException::new);
assertThatContractNotStartYet(contract);
assertThatAnyOfContractConditionsDoesNotStartYet(contract);

contractConditionsService.deleteAllContractConditionsByContract(contract);
contract.setActive(false);
contractRepository.save(contract);

PaymentDisposition paymentDisposition = paymentsService.findPaymentDispositionByContractId(contractId);
paymentsService.cancelPaymentDispiosition(paymentDisposition);
}

Ukryjmy jeszcze naszego “Optionala”:


public void deleteContract(@NonNull Long contractId) {
Contract contract = getContractById(contractId);
assertThatContractNotStartYet(contract);
assertThatAnyOfContractConditionsDoesNotStartYet(contract);

contractConditionsService.deleteAllContractConditionsByContract(contract);
contract.setActive(false);
contractRepository.save(contract);

PaymentDisposition paymentDisposition = paymentsService.findPaymentDispositionByContractId(contractId);
paymentsService.cancelPaymentDispiosition(paymentDisposition);
}

Teraz delikatnie zadbajmy o operację, robiąc kosmetyczne poprawki:


public void deleteContract(@NonNull Long contractId) {
Contract contract = getContractById(contractId);
assertThatContractNotStartYet(contract);
assertThatAnyOfContractConditionsDoesNotStartYet(contract);

contractConditionsService.deleteAllFor(contract);
softDelete(contract);

PaymentDisposition paymentDisposition = paymentsService.findPaymentDispositionByContractId(contractId);
paymentsService.cancelPaymentDisposition(paymentDisposition);
}

Zostały nam publikacje. Tutaj mamy tak naprawdę dwie możliwości:

  1. Prosta – dodanie nowej metody do paymentsService, która anuluje dyspozycję płatności. Jest to proste i bezpieczne rozwiązanie, ale wciąż będziemy mieli bezpośrednią relację pomiędzy kontraktami i płatnościami.
  2. Bardziej wyszukana – dodanie kolejki pomiędzy płatnościami i kontraktami. Dzięki temu usuwamy bezpośrednią relację pomiędzy kontraktami i płatnościami.

public void deleteContract(@NonNull Long contractId) {
Contract contract = getContract(contractId);
assertThatContractNotStartYet(contract);
assertThatAnyOfContractConditionsDoesNotStartYet(contract);

contractConditionsService.deleteAllContractConditionsByContract(contract);
softDelete(contract);

paymentsService.cancelPaymentDispositionForContract(contractId);
}

W zasadzie skończyliśmy. Korzystając z zaproponowanego podejścia, znaleźliśmy dwie potencjalne luki w walidacji, które mogłyby sprawić nam sporo problemów. Równocześnie jednak wprowadziliśmy redundancję – dwa razy sprawdzamy, czy dodatkowe warunki pozwalają nam usunąć kontrakt (najprawdopodobniej w contractConditionsService będzie wykonane to samo sprawdzenie).

Mimo to kod wygląda bardziej przejrzyście i to dobry początek do dalszego refaktoringu. 🙂 Finalnie możemy zakończyć akcję z takim kawałkiem kodu:


public void deleteContract(@NonNull Long contractId) {
Contract contract = getValidatedContractForDeletionBy(contractId);
softDeleteContractWithAllConditions(contract);
paymentsService.cancelPaymentDispositionForContract(contractId);
}

Powodzenia!

Oryginalny wpis (w języku angielskim) tego artykułu znajdziecie na moim blogu.

Daniel Pokusa – pragmatyk, zapalony zwolennik zwinnych metodyk prowadzenia projektów, automatyzacji i efektywności. Z jednej strony programista i architekt (nikt nie jest doskonały!) w Onwelo, ściśle związany z rodziną języków JVM oraz kierownik projektów, z drugiej – trener i konsultant w zakresie systemów rozproszonych, jakości, organizacji zespołu i rekrutacji. Współtwórca i jeden z założycieli konferencji SpreadIT oraz nałogowy mówca na takich wydarzeniach jak Confitura, 4Developers, JDD, QualityExcites i inne. Wierzy, że istotą wytwarzania dobrego oprogramowania jest komunikacja, współpraca i umiejętność dzielenia się wiedzą. W wolnym czasie bloguję na Software Empathy.

Dodaj komentarz

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