“Trzymaj swoją domenę jak najczystszą się da!”, “Zero zależności do zewnętrznych rozwiązań!” - takie głosy da się słyszeć od wielu osób. Jednak rzeczywistość jest zgoła odmienna. Wchodzisz do losowego serwisu w swojej aplikacji, a tam wiele zależności do zewnętrznych bibliotek. Jak z tym żyć? Jakoś się da. Jeśli była to świadoma decyzja, która nie utrudnia Twojej pracy, to chapeau bas. Brawo, jesteś świadomym programistą! Natomiast często nie bywa tak kolorowo. Zdarza się nam odziedziczyć projekt, który żył swoim własnym życiem i, w którym dopuszczono do nieprzemyślanego korzystania z zewnętrznych bibliotek. Tak jak ma to miejsce na poniższym przykładzie.
1
2
3
4
5
6
7
8
class CalculatorService {
String call(ExternalLibraryObject externalLibraryObject) {
// a lot of logic
return externalLibraryObject.returnSomeValue() + "-000";
}
}
Taki kodzik mógłby sobie żyć i żyć latami. ExternalLibraryObject
z zewnętrznej biblioteki w ogóle by nam nie przeszkadzał. Jednak, aby nie iść w kierunku sielankowego przypadku, zatrząśmy trochę tym przykładem. Okazuje się, że metoda call
zawiera błąd. Zwracany String
powinien mieć na końcu wartość 1 a nie 0. My jako pragmatyczni programiści nie idziemy na skróty. Chcemy dopisać test przed naprawieniem błędu, aby stworzyć siatkę bezpieczeństwa, której ewidentnie tutaj brakuje. Żeby mieć pewność, że taki błąd znowu nas nie zaskoczy.
1
2
3
4
5
6
@Test
void return_proper_value_for_service_call() {
String result = calculatorService.call(new ExternalLibraryObject());
assertEquals("132-001", result);
}
Test jest trywialny. Nie ma tutaj żadnej magii. Wystarczyło spojrzeć w bebechy ExternalLibraryObject
i zauważyć, że metoda returnSomeValue
zwraca nam wartość 132
. Dobra, to pozostaje nam uruchomić test. Tak jak się spodziewaliśmy, nie przechodzi. Ale zaraz… nie z powodu asercji, tylko z powodu timeoutu!
1
2
3
4
5
6
7
8
9
10
11
12
class ExternalLibraryObject {
public ExternalLibraryObject() {
// creating connection to database or external service
throw new UnsupportedOperationException("timeout"); // 1
}
String returnSomeValue() {
return "132";
}
}
Aha… nasz zewnętrzny obiekt przy tworzeniu nawiązuje połączenie z zewnętrznym systemem. Nie może tego dokonać, bo nie przekazaliśmy mu żadnych niezbędnych danych, aby mógł to zrobić. Zapytam znowu - jak żyć? Przecież nic nie możemy zrobić z tą klasą, bo to klasa z zewnętrznej biblioteki! Refleksja? Pozwolić na nawiązanie połączenia? Tylko czy taki test, przy takich rozwiązaniach, nie będzie kruchy albo będzie zależny od kondycji zewnętrznego systemu, czy też nie będzie trwał zbyt długo? Jest to bardzo możliwe. Co więc zrobić z takim fantem? Fajnie byłoby móc podstawić jakiś swój obiekt w miejsce ExternalLibraryObject
, nad którym mamy kontrolę.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface UnitObject {
String returnSomeValue();
@RequiredArgsConstructor
class External implements UnitObject {
private final ExternalLibraryObject externalLibraryObject;
@Override
public String returnSomeValue() {
return externalLibraryObject.returnSomeValue();
}
}
}
Odpowiednim podejściem jest wprowadzenie własnej abstrakcji, która będzie miała takie samo API jak klasa z zewnętrznej biblioteki. Najlepiej jak tą abstrakcją będzie interfejs, ponieważ skupiamy się w tym przypadku tylko na zachowaniu. Pierwszą implementacją tego interfejsu niech będzie klasa External
, której odpowiedzialnością będzie tłumaczenie API klasy ExternalLibraryObject
na nowe API tego interfejsu. W ten sposób mamy przygotowaną implementację pod produkcyjne rozwiązanie, która nie powinno zmienić obserwowalnego zachowania systemu.
Ktoś mógłby zadać pytanie co zrobić w przypadku, gdy byłoby wykorzystywane więcej metod klasy ExternalLibraryObject
w metodzie call
. Oczywiście jest to dobre pytanie. Wydaje mi się, że można by wtedy wprowadzić bardziej rozbudowany interfejs. Żeby zamiast jednej metody miał ich np. 3 czy 4. Z drugiej strony być może opisywana tutaj technika nie byłaby odpowiednia. Wszystko zależy od stopnia skomplikowania danej sytuacji.
Wróćmy jednak do naszego przypadku. Przydałoby się dzięki refaktoryzacji mechanicznej przygotować CalculatorService
do przyjęcia naszej abstrakcji w postaci UnitObject
. Polegałaby ona na prostym przeciężeniu metody call
oraz wyekstrahowaniu innej. Po zmianach całość prezentuje się następująco.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CalculatorService {
String call(UnitObject unitObject) {
// a lot of logic
return prepareResult(unitObject.returnSomeValue());
}
String call(ExternalLibraryObject externalLibraryObject) {
// a lot of logic
return prepareResult(externalLibraryObject.returnSomeValue());
}
private String prepareResult(String value) {
return value + "-000";
}
}
Klienty korzystające z CalculatorService
nic się nie dowiedziały o naszej zmianie. Z ich perspektywy API pozostało niezmienione tak samo jak obserwowalne zachowanie. My natomiast przy tym refaktoringu zyskaliśmy wiele. Możemy napisać działający test! A w jaki sposób? Już prezentuję.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
void return_proper_value_for_service_call_wrapper() {
String result = calculatorService.call(new TestUnitObject());
assertEquals("abc-001", result);
}
class TestUnitObject implements UnitObject {
@Override
public String returnSomeValue() {
return "abc";
}
}
Tworzymy fake w postaci klasy TestUnitObject
implementującej UnitObject
. Uczymy ją, aby zawsze zwracała wartość abc
, gdy zostanie wywołana metoda returnSomeValue
. Dzięki temu mamy pewność, że ta część wyniku metody call
będzie zawsze miała taką samą wartość. W tym momencie test wywala się już nie z powodu wyjątku, tylko z powodu nie przechodzącej asercji. Właśnie taki efekt chcieliśmy osiągnąć. Siatka bezpieczeństwa została utworzona!
Teraz zdejmujemy “czapkę” refaktoryzatora i wchodzimy w postać naprawiacza błędów. Bo walczenie z bugami to zmiana obserwowalnego zachowania, a jak wiemy, jego nie możemy zmieniać podczas refaktoringu. Robimy więc commit i naprawiamy błąd poprzez zmianę odpowiedniej linijki kodu. Uruchamiamy test, który się zazielenia.
Niektórzy mogą powiedzieć, że kod po tych zmianach uległ znacznej degradacji. Nie ma się co oszukiwać, tak właśnie się stało. Jednak pamiętajmy jaki problem tutaj rozwiązywaliśmy. Skupialiśmy się głównie na zwiększeniu testowalności naszego systemu. Efektem ubocznym tego jest miejscowy “bałagan”, ale ten “bałagan” daje nam wiele możliwości. Jeśli będziemy dalej propagować tą zmianę to być może uda nam się zepchnąć wykorzystanie zewnętrznej biblioteki gdzieś na peryferia aplikacji. Wtedy jej wymiana będzie praktycznie bezbolesna. Dodatkowo jeśli podbilibyśmy wersję tej biblioteki, gdzie API ulegało by znacznej zmianie to też, poprawa tego będzie dotykała tylko nieznacznej części aplikacji. Jak dla mnie ten refaktoring ma tak wiele plusów, które przysłaniają ten mały minus. W przypadki, gdy nasz zespół jest dogadany co to refaktoringu to ten miejscowy “bałagan” szybko zniknie!