Wibowit napisał(a):
YetAnohterone napisał(a):
Załóżmy teraz, że zgodnie z promowanymi przez niektórych "najlepszymi praktykami" znaczna część tych klas będzie wstrzykiwana przez DI.
wstrzykiwanie zależności to nie tylko 'najlepsze praktyki', ale przede wszystkim porządna testowalność.
brak porządnych testów prowadzi do destruktywnych refaktorów, a to prowadzi do unikania refaktorów w ogóle i narastania szajsowatego kodu.
Nie rozumiem, co ma jedno do drugiego?
Wstrzykiwanie zależności nie jest konieczne dla testowania klas, tylko do ich mockowania.
Nie jest prawdą, wbrew niektórym, że testowanie = mockowanie.
Zacytuję Martina Fowlera:
But not all unit testers use solitary unit tests. Indeed when xunit testing began in the 90's we made no attempt to go solitary unless communicating with the collaborators was awkward (such as a remote credit card verification system). We didn't find it difficult to track down the actual fault, even if it caused neighboring tests to fail. So we felt allowing our tests to be sociable didn't lead to problems in practice.
Indeed using sociable unit tests was one of the reasons we were criticized for our use of the term "unit testing". I think that the term "unit testing" is appropriate because these tests are tests of the behavior of a single unit. We write the tests assuming everything other than that unit is working correctly.
As xunit testing became more popular in the 2000's the notion of solitary tests came back, at least for some people. We saw the rise of Mock Objects and frameworks to support mocking. Two schools of xunit testing developed, which I call the classic and mockist styles. One of the differences between the two styles is that mockists insist upon solitary unit tests, while classicists prefer sociable tests. Today I know and respect xunit testers of both styles (personally I've stayed with classic style).
Even a classic tester like myself uses test doubles when there's an awkward collaboration. They are invaluable to remove non-determinism when talking to remote services. Indeed some classicist xunit testers also argue that any collaboration with external resources, such as a database or filesystem, should use doubles. Partly this is due to non-determinism risk, partly due to speed. While I think this is a useful guideline, I don't treat using doubles for external resources as an absolute rule. If talking to the resource is stable and fast enough for you then there's no reason not to do it in your unit tests.
(https://martinfowler.com/bliki/UnitTest.html)
Zatem unikanie mocków jest sensownym podejściem do testowania.
Jest to zgodne z moją intuicją, o czym pisałem już w kilku wcześniejszych wątkach na tym forum. Test, wydaje mi się, winien testować zachowanie kodu, a nie sposób, w jaki jest napisany. Mockowanie zależności wiąże test z obecną architekturą kodu, co czyni go podatnym na ciągłe przepisywania. Taki test jest tez mniej wiarygodny, bo nie wykryje błędów wynikłych z niezrozumienia zalezności. Wadą/zaletą mockowania jest, że wymusza ono "testowalną architekturę". Niektórzy uważają taką architekturę za pożądaną samą z siebie, ale ona ma też wady: powoduje eksplozję liczby klas i linijek w kodzie, co czyni cały projekt trudnym do zrozumienia w całości.
W większości mozna mocków uniknąć. Załóżmy na przykład, że stosujemy imperative borders, functional core. Dzielimy kod na zgrubsza dwie części: kod, który musi mieć efekty uboczne i kod, który nie musi mieć efektów ubocznych, więc nie powinien ich mieć - to będzie logika biznesowa. W odróżnieniu od tzw. "czystej architektury" logika biznesowa nie ma (nawet odwróconej) zależności od db. Np. kontroler (który oczywiście należy do imperative borders) otrzymuje zapytanie, pobiera z db konieczne dane, woła BL, BL w czysto funkcyjny sposób wykonuje obliczenia i zwraca wynik, na jego podstawie kontroler zapisuje nowe dane do DB i zwraca odpowiedź na zapytanie.
Wtedy logika biznesowa (czyli functional core) może, w większości, być napisana nawet po prostu metodami statycznymi. Dzięki brakowi efektów ubocznych testowanie BL jest trywialne: dajemy funkcji z góry wyznaczone argumenty, sprawdzamy, czy wynik się zgadza. Nie potrzeba żadnych mocków.
Z kolei sensem i celem imperative borders jest rozmawianie z zaleznościami i sklejanie ich ze sobą. Db, sieć, jakieś inne RESTowe API, może system plików, itp, no i oczywiście BL. Ten kod, w dużej mierze, jest poprawny wtedy i tylko wtedy, jeśli piszący go dobrze rozumie, jak działają wołane przezeń zależnosci i nie zapomniał o rozmaitych założeniach interfejsów. Dlatego mockowanie zależności nie ma sensu: moje rozumienie zależności będę musiał zakodować w mocku, więc test staje się tautologią ("kod, którego poprawność zależy od mojego rozumienia zależności, jest poprawny jeśli dobrze rozumiem zależności i prawidłowo je zakodowałem w mocku"). Sprawdzić jego poprawność mogę tylko i wyłącznie testami integracyjnymi. O ile to możliwe, nie będę mockował nawet db ani innych webservice'ów ani nic. Ponieważ ten obszar kodu powinien zawierać względnie mało logiki, to testy integracyjne wystarczą do pełnego pokrycia.
Czasami mocki są nieuniknione. Na przykład komunikacja z systemem bankowym, tak jak napisał Fowler, musi być zamockowana, z przyczyn oczywistych. Jeśli jakaś metoda logiki biznesowej wykonuje się bardzo długo, bo jest jakiś skomplikowany algorytm, który nie może krócej działać, no to pewnie też go trzeba zamockowac. Ale mockujemy tylko wtedy, gdy musimy, a nie wszystko.
Wadą/zaletą tego podejścia jest to, że (a) testów będzie mniej, ale wystarczą one do pokrycia całego kodu, (b) nie trzeba wszędzie stosować DI ani robić eksplozji liczby klas i linijek kodu - więc całość jest prostsza (choć niektórzy uznają, że wprost przeciwnie, jest bardziej skomplikowana, bo pojedyncza klasa jest na dwa ekrany, a nie tylko na jeden i nie jest zachowany SRP w jego najbardziej dogmatycznej formie).
Wiem, że to, co napisałem wyżej, jest bardzo kontrowersyjne. Wielu kurczowo trzyma się piramidy testów Mike'a Cohna, przy czym słówko "unit" w sformułowaniu "unit test" rozumieją jako "unit of code", a nie "unit of behavior". Jeśli zalezności nie sa zamockowane, to test wg nich przestaje być już jednostkowy, bo testuje więcej niż jedną jednostkę na raz. Oczekuje się więc, że każda metoda będzie sparowana z testem testującym tę metodę, i każda klasa będzie sparowana ze swoim mockiem. Oczekuje się, że każda klasa będzie miała 100% pokrycia testami jednostkowymi tak zdefiniowanymi, a oprócz tego trzeba będzie jeszcze pisać testy na wyższym poziomie integracji - testy modularne, potem integracyjne, potem end-to-end, wreszcie testy scenariuszowe, gdzie boty klikają po apce. Ludzie, którzy to promują, sami przyznają, że ich testy jednostkowe testują implementację kodu, a nie jego zachowanie - ale uważają, że jest to OK. Im na wyższy poziom integracji w testach wchodzimy, tym bardziej testujemy wymogi biznesowe, a nie sposób, w jaki kod jest napisany.
Oczywistą wadą powyższego podejścia jest absolutnie gigantyczna ilość testów i podobnie gigantyczna ilość czasu poświęconego na ich pisanie. Refaktoryzacja kodu wymaga przepisania testów, toteż refaktoryzacja także jest utrudniona i czyni ona testy jednostkowe mniej użytecznymi (skoro są przepisywane właśnie wtedy, gdy nadchodzi refaktoryzacja). Podnosi się natomisat następujące zalety: Failujące testy od razu wskazują dokładnie miejsce, w którym jest bląd bez żadnego dodatkowego debugowania (docelowo: failuje test przypisany tej konkretnie metodze, w której jest bug, a jeśli metody mają, jak chce Uncle Bob, po 4 linijki max, to błąd jest widoczny natychmiast); Unit testy wykazują, że kod jest poprawny w momencie, w którym go piszemy (jeśli stosujemy TDD), podczsa gdy testy integracyjne wymuszają, by kod dalej był poprawny, gdy go refaktoryzujemy; Takie unit testy są niezwykle szybkie, więc wszystkie unit testy można uruchomić w przeciągu pół sekundy max, więc można co 30 sekund uruchamiać cały zestaw unit testów (ale po co?! skoro takie unit testy z założenia testują wył. jedną metodę, góra klasę, więc nie ma szans, by one sfailowały, gdy pracuję nad inną częścią kodu?! Sfailować może wył. test, który przed chwilą sam napisałem)
Są też podejścia próbujące znaleźć złoty środek pomiędzy tymi dwoma ekstremami. Wielu na przykład nie wyobraża sobie, by jakiekolwiek zalezności zewnętrzne (db, inne webservice'y, itp) nie były zamockowane. Z doświadczenia wiem, że w niektórych miejscach pracy żąda się wręcz, by dać pełne pokrycie testami jednostkowymi mockującymi db i w ogóle wszystkie zależności wykraczające poza danego exeka.
Podsumowując:
- Nieprawdą jest, że testy jednostkowe i TDD wymagają mockowania zależności (skoro istnieje podejście unikające mockowania);
- Jeśli nie mockujemy, wówczas DI oraz IoC przestaje być konieczne do testowania;
- Wydaje mi się, że w takim wypadku DI traci sporo sensu i można ograniczać zakres jego stosowania;
- Wydaje mi się, że o ile zarówno mockowanie, jak i unikanie mockowania mają swoje wady i zalety, o tyle szala przechyla się zdecydowanie na korzyść unikania mockowania, o ile to możliwe;
- Wiem, że wielu uważa to, co napisałem wyżej za herezje.
Obecna moja praca zaczyna powoli wymuszać pokrywanie kodu testami (to dobrze, bo dotąd pokrycie było mikroskopijne), ale jednocześnie wymusza podejście bardziej mockujące (to już mi się zdecydowanie mniej podoba). Zatem możliwe, że moje obserwacje, jak działają testy jednostkowe z mockami zmieni moje podejście i za dwa lata nie będę sobie wyobrażał, jak można pisać kod inaczej, niż tylko stosując TDD mockujące każdą zależność. Czas pokaże.