Będzie dłuższy post, kod będzie w pseudokodzie przypominającym Javę.
Mamy taką metodę pomocniczą w zewnętrznej bibliotece:
public <T, Client> T doWithOptionalBackupConnection(Client mainClient, Client backupClient, Function<Client, T> action){
try {
return action.apply(mainClient);
} catch(Exception e){
logger.log(e);
}
return action.apply(backupClient)
}
Czyli ta metoda próbuje wykonać dowolną lambdę, najpierw przy pomocy głównego połączenia, a jeżeli cokolwiek się popsuje, to próbuje jeszcze raz przy pomocy zapasowego. Jak drugie podejście się nie uda, to po prostu propaguje wyjątek i tyle.
Treść tej metody jest tak naprawdę nieistotna. Liczy się tylko to, że ona próbuje jakoś załapać wyjątek i obsłużyć, ale na potrzeby dalszej dyskusji przyjmujemy, że nie znamy ciała tej metody, jest ona w zewnętrznym komponencie, który dodatkowo wymaga konfigurowania, połączenia z bazą, stanu i takich tam. Równie dobrze może to być jakieś bardzo drogie wywołanie sieciowe, bez różnicy. Ważny jest tylko kontrakt, że jak lambda wywali się wyjątkiem przy pierwszym kliencie, to próbujemy to odpalić z drugim klientem.
Teraz chcemy użyć tej metody na przykład z czymś takim:
interface Client {
String getData(String parameter);
}
public String doWork(Client mainClient, Client backupClient){
String first = utilInstance.doWithOptionalBackupConnection(mainClient, backupClient, client -> client.getData("First"));
String second = utilInstance.doWithOptionalBackupConnection(mainClient, backupClient, client -> client.getData("Second"));
String third = utilInstance.doWithOptionalBackupConnection(mainClient, backupClient, client -> client.getData("Third"));
return first + second + third;
}
Czyli odpalamy metodę pomocniczą trzy razy, za każdym razem przekazując inną lambdę.
Teraz chcemy to przetestować, ale nie chcemy używać prawdziwego doWithOptionalBackupConnection (bo on jest w zewnętrznym pakiecie, jest on ciężki, wolny, nie znamy jego bebechów i takie tam). Czyli go mockujemy w jakikolwiek sposób i gotowe. Od razu zaznaczę, że oczywiście mamy też testy end to end, ale w tym temacie interesują mnie tylko testy jednostkowe.
Kod działa, testy na zielono, ale mija trochę czasu i zaczynamy narzekać, że te wszystkie operacje wykonują się synchronicznie i po kolei. Chcemy to załatwić asynchronicznie, więc zmieniamy naszego klienta i metodę go wołającą:
interface Client {
CompletableFuture<String> getData(String parameter);
}
public String doWork(Client mainClient, Client backupClient){
CompletableFuture<String> first = utilInstance.doWithOptionalBackupConnection(mainClient, backupClient, client -> client.getData("First"));
CompletableFuture<String> second = utilInstance.doWithOptionalBackupConnection(mainClient, backupClient, client -> client.getData("Second"));
CompletableFuture<String> third = utilInstance.doWithOptionalBackupConnection(mainClient, backupClient, client -> client.getData("Third"));
return first.WaitForResult() + second.WaitForResult() + third.WaitForResult();
}
Czyli zmieniliśmy klienta tak, żeby zwracał jakąś promesę, następnie po wywołaniu metody doWithOptionalBackupConnection zapisujemy ją do zmiennej. Jak mamy trzy takie promesy, to czekamy na nie po kolei. W efekcie operacje lecą asynchronicznie i wszystko jest szybsze.
Odpalamy testy jednostkowe, przechodzą. Następnie wrzucamy kod na testy end to end (albo od razu na produkcję, a co tam ;) ) i niespodzianka — nie działa.
Najpierw proponuję zagadkę — dlaczego to nagle przestało działać?
Milion enterów…
Różnica jest taka, że teraz lambda zwraca promesę, przez co jeżeli główny klient nie zadziała, to i tak nie ma to znaczenia, bo wszystko jest opakowane. Wyjątek zostanie spropagowany dopiero w momencie wołania WaitForResult, w efekcie czego klient pomocniczy nigdy nie zostanie wykorzystany i cały mechanizm traci sens.
I teraz pytanie właściwe — jak od samego początku napisalibyście test jednostkowy, żeby w razie czego wyłapał taki błąd?
Od razu dodam, że ja wiem, jak to przetestować jednostkowo, żeby wyłapać taki problem, ale nie chcę sugerować rozwiązania.