O, @Shalom wreszcie zadał ciekawe pytanie, a nie znowu o formatki w swingu.
Różnica wychodzi kiedy postanawiamy napisać testy jednostkowe dla naszej metody.
Panie, ten kod to prosty kontroler, który odpala odpowiednie metody faktycznych serwisów w odpowiedniej kolejności. Kto testuje jednostkowo kontrolery? Chyba tylko seniorzy z dwuletnim doświadczeniem.
Tu nie ma co testować jednostkowo, bo tu nie ma logiki. A skoro to jest coś na szczycie aplikacji, to i tak trzeba to przetestować integracyjnie. Testy jednostkowe niczego tu nie wniosą, właściwie przetestuje się tylko działanie frameworka mockującego.
Dodatkowo pojawia się potencjalny problem z testowaniem wyjątków, bo nie każdy jest łatwo wywołać przy pracy z prawdziwymi obiektami.
Jeśli chcemy przetestować realne sytuacje i prawdziwe wyjątki, to i tak nie zrobimy tego jednostkowo.
Co więcej jeśli pojawi się bug w którejś z naszych zależności do wywalą się zarówno jej testy jak i test dla naszej klasy, co potencjalnie utrudni poszukiwanie źródła problemu.
Dlatego najpierw odpalamy unit testy naszych składowych klocków, a jak one przejdą, to testy integracyjne całości.
Możemy za to mieć bardzo skomplikowanym setup testu, kiedy trzeba ustalić bardzo dużo zachowań, w efekcie taki test jest mało czytelny.
Główny problem z przygotowywaniem testów jest to, że ludzie tracą czas na robienie tego ręcznie zamiast użyć automockującego frameworka.
No i wspomniana już przez @Shalom wada DI - złożoność kodu. Żeby zmienić nasz MagicService
na DI-friendly musimy dołożyć obrzydliwy, 6-argumentowy konstruktor, 6 interfejsów (które bardzo często mają tylko jedną implementację), pare fabryk i IoC. Też niefajne.
Sensowne kontenery IoC nie wymagają interfejsów do działania. Zresztą, nie spotkałem jeszcze nawet bezsensownego kontenera IoC, który by tego wymagał. :P
Sensowny kontener IoC nie wymaga też żadnych zmian w konfiguracji po zmianie sygnatury konstruktora.
Czy taki Introspektor powinien być wstrzykiwanym serwisem czy powinien być stworzony przez new w naszym walidatorze?
To zależy od tego, czy jest zależnością czy też własnością albo narzędziem.
To taki follow up question jeszcze: czy nie uważacie że takie opieranie aplikacji na wstrzykiwanych bezstanowych serwisach to nie jest trochę takie programowanie strukturalne, podobne do tego jakbyście wszystko opierali o statici? I jak się to ma też do postulatów z zakresu DDD i lokowania logiki biznesowej w obiektach domenowych?
Moim zdaniem DDD z tymi swoimi encjami, które operują same na sobie jest przereklamowane. Ale to bez znaczenia, bo z DDD jest jak z Agile - nikt go w praktyce nie używa, mimo że wszyscy o tym gadają.
Dobry kod jest bezstanowy, po prostu przetwarza wejście w wyjście. Jeśli nie ma stanu, to jest statyczny (nawet bez magicznego słowa kluczowego). Ale to nie znaczy, że jest strukturalny, jest raczej funkcyjny. Niestety w praktyce nie wszystko może takie być, bo poza przetwarzaniem danych musimy jeszcze obsługiwać efekty uboczne.
Był argument o dwudziestu parametrach w konstruktorze - jeśli tak jest to trzeba je opakować w klasę. To podstawowa zasada czystego kodu.
Jeśli wsadzisz te 20 parametrów do jednej klasy, to będziesz musiał jej dodać konstruktor z 20 parametrami, więc wiele to nie zmienia.
W przypadku jakichś kontrolerów (jak w pierwszym poście), te 20 parametrów to nie jest tragedia, bo to normalne, że coś, co składa ileś wywołań i je wykonuje, musi najpierw dostać te wywołania.
W przypadku serwisu to faktycznie może świadczyć o złamaniu SRP. Ale technicznie to i tak nie problem, bo to rozwiązuje kontener IoC.
Był argument o wydajności testów integracyjnych, a więc bajka o dostępie do bazy danych. Tu sprawa jest prosta - można sklecić testową infrastrukturę imitującą dostęp do bazy danych. Podobnie można zrobić imitacje zewnętrznych serwisów. Z czymś takim testy są wystarczająco szybkie.
Jak dla mnie, to imitacja i mock to to samo.
Mocki mają tę wadę, że utrudniają refaktoryzację. Zmiana sygnatury metody wymusza zmianę mockowania w wielu miejscach (w testach dla wielu klas), podczas gdy przy stosowaniu testów integracyjnych zmiana jest tylko w teście dla klasy w której jest ta metoda.
Tylko jeśli mockujesz ręcznie.
Na koniec muszę dodać, że nasza architektura jest oparta na mikroserwisach, a więc testowanie może wyglądać inaczej niż w kobyłach. Mikroserwis ma to do siebie, że zwykle ma niewielkie warstwy dostępu do I/O (baza, http, kolejki, etc), a co za tym idzie łatwo jest stworzyć imitacje tych warstw i je używać w testach.
Nie sądzę, aby to miało znaczenie. Mikroserwis oznacza rozmiar w szerokości, a nie w głębokości czy w poziomie skomplikowania kodu.
Przykład z pierwszego postu jest trochę nieadekwatny, bo to jest coś, co akurat faktycznie ma zależności, więc można je wstrzyknąć, jeśli nam to coś daje. Czyli nie w celu bezsensownego testowania frameworka mockującego, tylko np. po to, aby kontener IoC opakował te klasy w interceptory obsługujące wyjątki.
Głównym problemem jest to, że wielu ludzi każdą pomocniczą klasę traktują jako zależność i chcą ją wstrzykiwać, nawet jeśli nie mają z tego żadnego zysku. Efektem jest tylko kupa problemów, np. z pisaniem testów (wspomniałem już o automatycznym mockowaniu, którego ludzie nie stosują?). Po spytaniu, czemu wszystko wstrzykują otrzymuję zazwyczaj odpowiedź, że to "general rule of programming" i żebym sobie książki poczytał.
Efekt jest taki, że kod wygląda, jakby autor miał płacone od linijki. A ja później jestem w stanie skasować 70% kodu zwiększając jednocześnie pokrycie rzeczywistymi testami dwukrotnie.
Drugi problem to to, że ludzie zmieniają kod biznesowy pod kod testów. Jestem tego przeciwnikiem. No, ale to wszystko wynika z TDD (Tutorial Driven Development). Ponieważ w tutorialu było napisane, że wszystko trzeba mockować i wszystko ma mieć intefejsy, to tak trzeba robić. No przecież nikt by nie napisał głupoty w internecie, prawda? ;)
Dodatkowym problemem jest to, że ludzie mają dziwne podejście do organizacji kodu w klasy. Wyobraźmy sobie takie zadanie - trzeba wczytać z bazy listę przelewów do wykonania i wygenerować na ich podstawie pliki eksportu do różnych banków. (Pliki tekstowe, struktura zależna od banku.)
Typowa implementacja będzie wyglądała tak:
- ExportService, który w zależnościach przyjmie:
- ITransfersRepository;
- IBankTypeFactory;
IBankTypeFactory będzie zwracało obiekty typu IBankTypeProcessor, które w swoich zależnościach z kolei przyjmą: IHeaderGenerator, ITransferGenerator i IFileSaver. Główna metoda w ExportService wczyta dane z repozytorium i w pętli będzie sprawdzało typ banku docelowego, tenże przekazany do fabryki spowoduje zwrócenie procesora dla danego banku, z odpowiednio wygenerowanymi generatorami nagłówka, przelewu, itd.
I już mamy drabinkę rzeczy do mockowania, fabrykowania i składania. Nie da się inaczej, bo taka jest architektura - nie przetestuje się jednostkowo PkoBpTypeProcessor, dopóki nie zamockujemy FileSavera (który w tym przypadku będzie zwracany przez jakiegoś obrzydliwego mocka jakiejś obrzydliwej fabryki).
Tymczasem, u mnie ExportService zawierałoby po prostu:
- TransfersSource (prostą klasę opakowującą ORMa, bo źródłem danych i tak jest baza);
- mapę bank -> BankTypeProcessor;
- FileSaver.
Generatory nagłówków i pojedynczych przelewów każdy konkretny BankTypeProcessor utworzy sobie sam - bo to jest i tak coś, co ma jedną implementację, i tak przydatną tylko wewnątrz tej konkretnej klasy.
I napiszę sobie testy konkretnych BankTypeProcesorów - bo to jest logika, którą przetwarza wejście w wyjście, nie mająca żadnych zewnętrznych zależności, a potem test integracyjny całego serwisu. Jedyny interfejs, który może się tu pojawić to wynikający z implementacji wzorca strategia przez poszczególne procesory (żeby dało się je trzymać w słowniku, a serwis mógł po prostu zawołać metodę).
W skrócie:
- Zależność to jest coś, co łączy kod ze światem zewnętrznym - plikiem, bazą, siecią, piwnicą msma, albo innym modułem aplikacji. Nie każda klasa, do której delegujemy kawałek pracy jest zależnością, większość z nich to po prostu własność klasy wołającej; lokalna klasa pomocnicza. Tego się nie wstrzykuje, bo istnieje tylko ze względu na SRP; tylko po to, aby główna klasa nie miała 500 linii lecz 50.
- Interfejsy służą do: oddzielania modułów aplikacji i wydzielania zewnętrznych zależności (patrz punkt poprzedni), ewentualnie jako podstawa we wzorcach projektowych. Definiowanie interfejsu do każdej klasy, bo "tak wymaga framework IoC/mockujący/testujący" albo bo "tak było na blogu" to po prostu strata czasu i robienie burdelu w kodzie.