Skip to content

Obserwowalne zachowania i ich bezpieczeństwo

Definicja refaktoringu jasno mówi, że w jego procesie nie powinny się zmieniać obserwowalne zachowania systemu. W przeciwnym razie niektóre funkcje biznesowe zaczną zachowywać się inaczej, a co gorsza możemy zepsuć te, które są zupełnie niezwiązane ze zmienianym obecnie miejscem. Taki błąd to błąd o wysokiej konsekwencji. Nic zatem dziwnego, że w dużych systemach, których nie znamy, lub ich części nie rozumiemy, pojawia się strach przed zmianami, bo łatwo jest coś zepsuć. Zaczynamy wtedy "hackować" system, zamiast go rozwijać i poprawiać jego strukturę. Dlatego właśnie, aby dobrze przeprowadzić refactoring, kluczowe jest poznanie obserwowalnych zachowań.

Odkrywanie obserwowalnych zachowań

Jeśli dokonujemy lokalnej refaktoryzacji poprawiającej czytelność, to sprawa może być dość prosta, a wskazówek może nam przykładowo dać sygnatura metody, czy funkcji. Przykładowo funkcja licząca pierwiastek:

public static double sqrt(double c) {
   if (c < 0) return Double.NaN;
   double EPSILON = 1E-15;
   double t = c;
   while (Math.abs(t - c/t) > EPSILON*t)
       t = (c/t + t) / 2.0;
   return t;
}

Raczej znamy jej obserwowalne zachowania, więc możemy je zaprezentować jako przypadki testowe:

@Test
void canCalculateSqrtRoot() {
   //expect
   assertThat(Math.sqrt(4)).isEqualTo(2);
   assertThat(Math.sqrt(9)).isEqualTo(3);
   assertThat(Math.sqrt(12.25)).isEqualTo(3.5);
}

W tym konkretnym przypadku odpowiedzią na pytanie "CO?" (patrz: "CO?", a "JAK?") jest policzenie pierwiastka, a odpowiedzią na pytanie "JAK?", jest konkretna implementacja, przykładowo metoda Newtona.

Zakładając, że inne metody liczą pierwiastek z podobną dokładnością, znając obserwowalne zachowania i posiadając dla nich testy, możemy wymienić implementację na przykład na metodę bisekcji, a obserwowalne zachowania zostaną te same.

Ten konkretny przypadek jest łatwy, ponieważ:

  1. znane było obserwowalne zachowanie
  2. ogólnie znane są lub wiemy gdzie szukać różnych sposobów realizacji tego zadania — wiedza czysto matematyczna

Co ciekawe, te obserwowalne zachowania mogą być szczegółem implementacyjnym zachowania wyższego poziomu, przykładowo liczenia limitu kredytowego: Liczenie limitu kredytowego

W większości przypadków mamy jednak do czynienia z czymś bardziej skomplikowanym i nie wiemy, czemu służy analizowany kod:

public static int calc(int base) {
   numbers.sort(Comparators.comparable());
   int divideBy = 1;
   for (int a : numbers) {
       if (a == 10) {
           divideBy = 2;
       }
   }
   double deductiona = base * (RATES_OF_I_TAX / divideBy);
   double deductionb = base * (RATES_OF_H_INS / 100);

   double deductionc = base * (RATES_OF_S_INS / 100);
   int tmp = base - (int) deductiona - (int) deductionb - (int) deductionc;
   return tmp;
}

W tym kawałku kody widzimy sortowanie listy i filtrowanie po parametrach — innymi słowy JAK działa algorytm, a nie CO jest jego celem. W takim przypadku łatwo wpaść w pułapkę, że utożsamiamy techniczne aspekty z obserwowalnymi zachowaniami. W efekcie prowadzi to do tego, że zamykamy się na inne rozwiązanie mogące dać ten sam efekt. Innymi słowy, doprowadzamy do sytuacji, gdy zmianę struktury, nie jest możliwa, bez zmian obserwowalnych zachowań.

Dlatego właśnie tak ważne jest poznawanie obserwowalnych zachowań, a pomóc nam w tym może:

  1. spojrzenie na klientów funkcji — kontekst użycia może nam dać wskazówki
  2. spojrzenie na dokumentację lub testy — jednak w systemach legacy, często ta pierwsza nie jest aktualna, a testy nie istnieją
  3. rozmowa z zespołem — bardziej doświadczonymi w danym systemie członkami zespołu
  4. zwrócenie uwagi na UI (jeśli istnieje) — śledząc hierarchię wywołań, można dojść do widoku korzystającego z funkcji, a wtedy ekspert domenowy, czy użytkownik systemu może nam powiedzieć, co robi konkretny przycisk

Zbytnie przywiązanie się do struktury rozwiązania może spowodować, że zaczniemy na niej opierać przypadki testowe:

@Test
void calcShouldFilterListOfNumbers() {
   //...
}

@Test
void calcShouldFind10InList() {
   //...
}

@Test
void calcShouldCalculateTmpVariable() {
   //...
}

Niby nie ma nic złego w tym, że chcemy wiedzieć, że lista została posortowana, ale może nam to utrudnić nazwanie prawdziwych obserwowalnych zachowań i znalezienie łatwiejszej struktury, do której chcemy refaktoryzować nasz kod. W takim przypadku testy nam nie pomagają, a wręcz przeszkadzają.

Dlatego też warto trenować myślenie osobno o tym, co robi funkcja, a osobno jak to jest wykonywane. W szczególności w kontekście refaktoryzacji. Przy mniejszych funkcjach może być to oczywiste, ale przy refaktoryzacji o większym zakresie może stać się problematyczne.

Wyobraź sobie taką sytuację: serwis, którego zadaniem jest wyciągnięcie listy kierowców-taksówkarzy wzbogaconej o informacje o typie samochodu, którego ostatnio używali. Wszystko to na potrzeby widoku administracyjnego.

DriverLastSessionReport

Aby taki report wykonać, serwis potrzebuje: - pobrać całą listę kierowców z DriverService - dla każdego kierowcy pobiera informację o ostatniej sesji z DriverSessionService - pobiera informacje o ostatnio użytym samochodzie z CarService

Wszystkie powyższe kroki to właśnie szczegóły implementacyjne — specyficzna współpraca tych trzech komponentów (co swoją drogą mało nas interesuje na poziomie raportu). W takim wypadku, jeśli przywiążemy się do tego rozwiązania, to naturalnie zaczynamy myśleć w kategoriach:

  • potrzebuję informacji o kierowcach, to idę do DriverService
  • potrzebuję informacji o sesji, to idę do DriverSessionService
  • potrzebuję informacji o samochodach, to idę do CarService
  • jeśli potrzebuję wszystkich tych informacji to muszę koordynować pracę wszystkich trzech serwisów

Taka kategoryzacja utrudnia nam myślenie i w omawianym przykładzie moglibyśmy taki sam raport wygenerować przy użyciu serwisu, do którego samochody cyklicznie rejestrują swoją pozycję, przykładowo DriverTrackingService:

DriverTrackingService

Każdorazowa rejestracja zawiera:

  • imię i nazwisko kierowcy
  • numer rejestracyjny
  • markę samochodu
  • datę użycia samochodu

W takim rozwiązaniu obserwowalne zachowanie (treść raportu) zostało nietknięte, a struktura rozwiązania zmieniła się diametralnie. Stało się to możliwe, tylko dlatego, że odwiązaliśmy cel funkcji biznesowej od jej konkretnej realizacji:

DriverTrackingService

Dla odbiorców raportu oba rozwiązania to szczegóły implementacyjne, które ich nie interesują. Jednak dla komponentu generującego raport, każdy z serwisów wystawia jakieś obserwowalne zachowania, których postanawia użyć. Z kolei te serwisy korzystają z jeszcze innych komponentów niższego poziomu, które z kolei dla raportu są szczegółem implementacyjnym, itd.:

Poziomy

Zachowanie takie możemy zaobserwować na poziomie funkcji, klasy, modułu, czy w końcu całego systemu. Dzięki temu, jeśli ten ostatni wystawia klientom API, do uzyskania raportu, to oni z kolei nie mają pojęcia o istnieniu DriverLastSessionReport. To jest szczegół implementacyjny, dzięki czemu mógłby być przykładowo zastąpiony bezpośrednim wywołaniem kwerendy bazodanowej (dla poprawienia wydajności):

API systemu

Najłatwiej dokonuje się refaktoryzacji modułów, które mają jednego klienta, ponieważ w takim przypadku zmieniany moduł, jest "prywatnym" szczegółem implementacyjnym tylko jednego, innego modułu. Właśnie dlatego warto, w ramach możliwości technologicznych wybranego języka, ukrywać widoczność modułów, klas, funkcji.

Testy

Zrozumienie gdzie, w zależności od miejsca refaktoryzacji, widać separację obserwowalnych zachowań i szczegółów implementacyjnych, pozwala nam nie tylko na znalezienie potencjalnie łatwiejszego rozwiązania tego samego problemu. Pozwala nam także na zabezpieczenie tego zachowania automatycznym testem. Każda refaktoryzacja powinna być bezpieczna. Bezpieczeństwo prostych transformat typu Extract Method czy upraszczanie instrukcji warunkowych zapewnia nam środowisko programistyczne. Bardziej złożone refaktoryzacje wymagają automatycznego testu — powłoki stabilizującej obserwowalne zachowania, która umożliwia nam bezpieczne zmiany w strukturze rozwiązania.

W Legacy Fighter nie skupiamy się na wszystkich dobrych praktykach pisania testów, a czasami może nawet je pomijamy. To dlatego, że dobre praktyki mogą zależeć od kontekstu a ten, kiedy się zmienia, może zmienić też zasady gry. Takie sytuacje będziemy wyraźnie podkreślać.

W systemach legacy często możemy zaobserwować strach przed zmianami, bo nie ma testów, testów z kolei nie ma, bo to system legacy, którego nie da się przetestować. Problem ten jednak można zaatakować, w prosty, algorytmiczny sposób: jeśli wiemy, co chcemy testować (obserwowalne zachowania) i potrafimy je namierzyć w kodzie, to wiemy gdzie napisać test. Może to jednak nie być takie proste z dwóch powodów: niejasnych obserwowalnych zachowań lub rozmytej logiki.

Niejasne obserwowalne zachowania

Gdy mapowanie obserwowalnych zachowań do kodu nie jest łatwe. Przykładowo zmiana statusu klienta odbywa się poprzez metodę update, która w parametrze otrzymuje ClientDto z nowym statusem. Następnie ta sama metoda używana jest do innych obserwowalnych zachowań jak korekty imienia czy nazwiska:

@PostMapping("/clients/{clientId}")
public ResponseEntity<ClientDTO> update(@PathVariable Long clientId, 
                @RequestBody ClientDTO dto) {
   clientService.update(clientId, dto);
   return ResponseEntity.ok(clientService.load(clientId));
}

//zmiana statusu
update(...) //dto z nowym statusem 

//aktualizacja daty urodzenia
update(...) //dto z nową datą

//aktualizacja imienia
update(...) //dto z nowym imieniem


//aktualizacja nazwiska
update(...) //dto z nowym nazwiskiem

Rozmyta logika

Gdy logika biznesowa znajduje się na wielu warstwach. Często warstwa prezentacji zawiera już instrukcje warunkowe, przykładowo:

@PostMapping("/clients/{clientId}")
public ResponseEntity<ClientDTO> update(@PathVariable Long clientId, 
    @RequestBody ClientDTO dto) {
   if (dto.getStatus() != Status.ACTIVE) {
       throw new IllegalStateException("...");
   }
   clientService.update(clientId, dto);
   return ResponseEntity.ok(clientService.load(clientId));
}

Gdyby tego nie robiła, to można byłoby napisać test warstwy niższej niż prezentacyjna, przykładowo test serwisu.

Skoro jednak mamy tę logikę w warstwie prezentacji, to musimy ją przetestować, co w praktyce sprowadza się do napisania testu bardzo "wysoko" - na warstwie API systemu. Jest to często jedyny sposób testowania systemów klasy legacy. Są to testy integracyjne lub akceptacyjne.

Zasada wspinacza

W Legacy Fighter zainspirowaliśmy się pewną dobrą praktyką, pochodzącą z całkowicie innej branży. Jest to praktyka stosowana przez klasycznych wspinaczy górskich: wspinacz zawsze powinien dotykać ściany przynajmniej 3 kończynami. Dlatego, że jak jedna kończyna wykonuje ruch, to pozostałe kończyny są stabilne.

Praktyka ta zastosowana do pisania testów przyjmie następujący charakter:

  • mając stabilny kod produkcyjny, piszemy lub zmieniamy test tego kodu
  • mając stabilny test, zmieniamy kod produkcyjny

Aby było to możliwe, musimy mieć testy, które faktycznie testują obserwowalne zachowania, a nie strukturę rozwiązania. W przeciwnym wypadku test byłby bezużyteczny:

@Test
void shouldCallClientServiceWhenChangingStatus() {
   //sprawdź, że wywołany został serwis z odpowiednimi parametrami
   //jeśli zmienimy rozwiązanie i nie wołamy serwisu, test stanie się bezużyteczny
   verify(clientService).update(1L, new ClientDTO(ACTIVE));
}

Mając test na wysokości obserwowalnego zachowania, będzie on dużo stabilniejszy i odporniejszy na refaktoryzację:

@Test
void canActivateClient() {
   //when
   clientController.update(1L, new ClientDTO(ACTIVE));

   //then
   assertThat(clientController.find(1L).getBody().getStatus()).isEqualTo(ACTIVE);
}

Z takim testami będzie nam łatwiej przeprowadzać kolejne większe zmiany w systemie.

Podsumowanie

Warto zapamiętać:

  • należy odróżnić obserwowalne zachowania od szczegółów implementacyjnych

  • obserwowalne zachowanie modułu niższego poziomu jest szczegółem implementacyjnym modułu wyższego poziomu, który z niego korzysta

  • należy pisać testy na wysokości obserwowalnych zachowań

  • należy pamiętać o zasadzie wspinacza

Materiały: