Testy i mockowanie

3

Ostatnio w dyskusji o Spring Beans wyszła ciekawa dyskusja (choć przez niektórych uznana za trolling) o testowaniu i mockowaniu. Jestem zwolennikiem mockowania, ale jako że zdanie @jarekr000000 jest odmienne, to chciałbym je lepiej poznać i podyskutować o tym.
Na wstępie zapraszam @jarekr000000 @somekind @scibi92 jako uczestników tamtej rozmowy, ale offc, post każdego jest mile widziany. W dyskusji prosiłbym o poruszenie jedynie tematów testowania i mockowania, ale nie konkretnych frameworków, w szczególności spring ;).

Załóżmy że stosujemy IoC bez żadnego frameworka DI i mamy do przetestowania klasę A.

class A{
    private final B b;
    private final C c;
    public A(B b, C c){/*przypisania*/}
    public X doSomething(Y y);
}

Klasa ta ma jedną metodę i w niej za pomocą 2 innych obiektów tworzony jest z parametru y wynik x. Ja w tym podejściu zamockowałbym B i C, tak żeby do testów trafiła tylko logika y. Robiąc ten test zakładam że:

  • JVM dla 2 + 2 będzie zawsze zwracała 4 i ogólnie działała tak jak to przewiduję.
  • Biblioteka testów jest napisana bezbłędnie
  • Biblioteka mocków jest napisana bezbłędnie

Dla mnie jest to idealny test jednostkowy, ponieważ zrobiłem minimum założeń bez których nie dałoby się napisać żadnych testów. Oczywiście te założenia mogą okazać się nieprawidłowe, ale wtedy problem jest globalny co do aplikacji i to tam trzeba go rozwiązać a nie w specyficznym teście.

Gdybym jednak przakazał tam prawdziwe instancje B i C to:

  • Nie byłby to test jednostkowy ale integracyjny. Uważam że test jednostkowy testuje możliwie najmniejszą jednostkę, a za integracyjny uważam taki który testuje zestawienie już 2 jednostek. Wiem że potocznie integracyjny to czasem od webserwisu do bazy danych albo jeszcze zewnętrznego systemu, ale gdy zestawiasz 2 klasy to już masz 2 odrębne moduły.
  • Test jest zależny tylko od testowanej klasy. Jeżeli B ma implementację B1 a zostanie ona zamieniona B2, który działa inaczej i w pewnym momencie zaczęłaby szwankować to mój test zacząłby się wywalać. Moim zdaniem jest nieprawidłowe działanie bo jeżeli nie działa X, to test X powinien się wywalić a nie test Y. Jeżeli B byłaby bardzo powszechną klasą i była zależnością 100 innych, to w przypadku jej awarii wywaliłoby się 101 testów zamiast jednego. Dla mnie jest to informacja że coś nie działa, a nie że klasa X nie działa.

Oczywiście można założyć że jest to dodatkowy test, ale moim zdaniem zamiast testować B w 100 klasach tak samo, lepiej napisać 10 dodatkowych testów do samego B żeby upewnić się że jest idealna skoro zależy od niej tak wiele.

1

Pomijając ,że ta desuksja już była...

Załóżmy że stosujemy IoC bez żadnego frameworka DI i mamy do przetestowania klasę A.

Jeśli w jakimś momencie t - masz do przetestowania klasę A albo metodę M - to już coś popsułeś wcześniej. I to nie chodzi o TDD - tylko nonsens pojęcia testu metody albo klasy. Testuj funkcjonalność i będzie Ci łatwiej.

1

Metoda odpowiada za jakąś funkcjonalność, więc testując ją, sprawdzam czy faktycznie robi to co zakładałem.

2

Jak tak zaczniesz robić - to Ci problem mocków mocno zniknie. Przede wszystkim przestaniesz zakładać, że unit test - to test "jednej" klasy.

3

@jarekr000000 No jak ktoś zacznie robic testy integracyjne zamiast jednostkowych to faktycznie problem mocków zniknie, trudno się z tym nie zgodzić, niemniej sensu ani logiki w tym nie ma za grosz. Szczególnie kiedy "funkcjonalność" jest złożona i wymaga interakcji wielu obiektów a moze i różnych systemów. W ten sposób to można (i tak się robi) definiować testy akceptacyjne -> tzn mamy zestaw testów które dowodzą, że wymaganie funkcjonalne XYZ jest spełnione przez nasz system. I to jest faktycznie wystarczająco dobre, żeby stwierdzić czy system nam działa czy też nie. Ale nie sprawdza się w ogóle jeśli chcemy stwierdzić co konkretnie nie działa w systemie, kiedy jednak jakiś test akceptacyjny/integracyjny nie działa.
Oczywiście zaraz pojawią się głosy, że w takim razie testowany obiekt jest zbyt skomplikowany i należałoby podzielić to na jakieś fragmenty, tylko że znów testy tych fragmentów wcale nie dowodzą, że interakcja pomiędzy nimi jest poprawna, a to w tej chwili sprawdzamy tylko integracyjnie.
Wartość dodana z testu takiej klasy za pomocą mockowania zależności jest taka, że jak się cos wywala to wywala się dla bardzo małej jednostki -> jednej klasy czy jednej metody i od razu wiadomo co i gdzie się popsuło. Szczególnie że mocki dają dość potężne możliwości sprawdzenia warunków wykonania i zachowania. Jak się wywala test integracyjny to niekoniecznie jest to takie oczywiste co się stało. A już szczególnie kiedy wykonanie testu wymaga np. jakiejś interakcji zewnętrznej.

1

Jak mam komponent (unit) , który gdzieś potrzebuję - to przygotowuje dla niego testy funkcjonalne - klient nawet nie ma pojęcia, ze coś takiego jest - więc nie są to testy akceptacyjne.
Fakt - mniej więcej tak staram się je robić - minimum narzucania jak komponent ma działać - a tylko co ma zwracać w wyniku dla podanych inputów. Bo jak tak nie robisz... to poważnie utrudniasz sobie refaktoring (czyli nonsens).

To są po prostu testy jednostkowe. I kij mnie obchodzi z ilu potem klas zrobię ten komponent - może być i 10 (ale raczej z 1-5 -szczegół implementacyjny) - i nie mokuje sobie wszystkiego pod spodem - zwłaszcza jeśli tego nie widać na zewnątrz.
I jeszcze jedno - to jest zwykle bardzo mała jednostka....(w sensie linii kodu).

Ale rzucam Ci pewien hint : dużo masz metod void? Bo to też problem (poza samym Springiem, który Ci rozwala architekturę).

I po raz kolejny jak mam zewnętrzny system / IO to go zamockuję - po to mam mocki. Ale nie własne klasy i klasy kolegów z zespołu. Ale przykładowo bazy danych prywatnej dla systemu nie uznaję za zewnętrzny system (to szczegół implementacyjny, który warto pokryć testami).

Zresztą - ogólnie to rozumie twoje podejście. X lat temu tez byłem mockistą i używałem Springa. I dobrze mi to działało .. - do czasu refaktoringu.
Druga rzecz - 10 lat temu problemem był głównie brak testów - więc wolałem już uczyć zespół pisać trywialne testy na mockach, żeby coś chociaż było przetestowane.
Obecnie testy są standardem - natomiast pojawił się problem ich jakości. Testy, które testują tylko Mockito - to jeszcze pół roku temu był w zasadzie standard w jednym z moich projektów (takie cudo odziedziczyliśmy -przyznam, regularnie powód był do śmiechu). ( A widziałem podobnie testowane systemy kilka razy - jeden to nawet głównie moja wina - byłem mockistą :-) ).

Testy powinny jednak testować system -( jak najszerzej) + powinny ułatwiać refaktoring. To m.in. spowodowało, że stałem się przeciwnikiem mockowania jak popadnie.

0

@vpiotr: Problem brzmi: Mockować czy nie i dlaczego.

@jarekr000000
Tylko jeżeli unit nie będzie zależał od niczego, to zwykle będzie bardzo wysoko i miał mnóstwo komponentów w środku. Możliwych ścieżek w takim wykonaniu może być setki, a przetestować trzeba każdą z nich. Testując małe komponenty jednostkowo i mockując zależności, masz zwykle kilka, lub nawet jedną ścieżkę.

Metod void unikam, bo nie da się ich przetestować. A springa w tej dyskusji nie ma :). IoT jest ręczne.

1

mnóstwo komponentów w środku

Własnie dlatego uważam, taką oderwaną od kodu dyskujsę za besens. Na prostym, podanym przez autora przykładzie - nie widać żadnego sensu mockowania. Bo kod nie ma żadnego sensu.

Bo oczywiście, gdzieś czasem pojawia się granica gdzie warto Mocka wrzucić. Ale na pewno nie jest to na granicy klas.

Po drugie jeśli korzystacie nawet z dużego komponentu, który jest dobrze zdefiniowany i przetestowany - to po co zastępować go mockiem? Przecież to dużo niepotrzebnej pracy, skoro można użyć gotowego :-). Przecież jak będzie miał błąd - to najpierw wywalą się jego testy. Nie piszę, że tak zawsze trzeba robić - ale może warto czasem o tym pomyśleć?

0

On jest jedynie zdefiniowany, ale niekoniecznie zaimplementowany lub tym bardziej przetestowany. Wiemy jedynie że ma jakąś funkcjonalność, ale sami jej teraz nie np. nie mamy, bo powstanie za 2 tygodnie. Będziesz czekał tak długi z otwartym tematem jednej funkcjonalności, skoro jej poprawność zależy od osoby która siedzi w budynku obok? A co jeżeli po tych dwóch tygodniach okaże się że zależność nie działa tak jak powinna, ale to Twój test się sypie? Oczywiście test jednostkowy.

2
krzysiek050 napisał(a):

A co jeżeli po tych dwóch tygodniach okaże się że zależność nie działa tak jak powinna, ale to Twój test się sypie?

To by znaczyło, że po coś ten test się przydał :-) Doskonale !

0

No właśnie, tylko ten test przydał się po "coś", a nie do przetestowania działania naszej funkcjonalności.

1

Dobrze, ale co w takim razie ten test przetestował ? I co proponujesz zrobić ?

1

Jak napiszesz test funkcjonalności i korzystasz z jakichś innych klas, bądź użyjesz innej implementacji interfejsu i test się wysypie to znaczy że funkcjonalność nie działa. Chyba o to chodzi? Jeżeli zamockujesz to właściwie nie wiadomo czy ta funkcjonalność działa. Wiadomo że Twoja klasa działa, ale teraz pytanie czy testujemy klasy czy funkcjonalności.

0

Załóżmy że zależność robi jakieś sprawdzenia i w przypadku błędu rzuca wyjątek. Nasza funkcjonalność to przetworzenie danych. I to właśnie testuję. To czy dane się nadają do przeprocesowania z punktu biznesowego mnie nie obchodzi. Dlatego zakładam mocka na walidator i wpuszczam zawsze + upewniam się że nie dojdzie do procesowania jeżeli wyleci z niego błąd.

0

Jeśli masz testy jednostkowe to łatwiej jest złapałać błąd. Po 1 są szybsze po 2 sprawdzają mniejszą ilość kodu. Ot co :)

3
krzysiek050 napisał(a):

Załóżmy że zależność robi jakieś sprawdzenia i w przypadku błędu rzuca wyjątek. Nasza funkcjonalność to przetworzenie danych. I to właśnie testuję. To czy dane się nadają do przeprocesowania z punktu biznesowego mnie nie obchodzi. Dlatego zakładam mocka na walidator i wpuszczam zawsze + upewniam się że nie dojdzie do procesowania jeżeli wyleci z niego błąd.

Czyli jeśli dobrze rozumiem:

  • testujesz przetworzenie danych.
  • Ale dane sa błędne (mockowane) - więc wyłączasz walidator.
  • Ponieważ walidator wyłączony, a dane błędne to oczywiście trzeba upewnić się, że: do procesowania nie dojdzie.

Ale testy procesowania będą na zielono.

Gratuluję!

Zrobiłeś właśnie jeszcze jeden test Mockito. To jest dokładnie to z czym walczę.

1

Może można uznać że funkcjonalność to przetwarzanie danych pod warunkiem że są prawidłowe i jeden test sprawdza czy dane są odpowiednio przetworzone kiedy są prawidłowe a drugi czy rzucany jest wyjątek (tak wiem, że pewnie nie trzeba rzucać wyjątków ale nie znam jeszcze tych eitherów i reszty) kiedy dane są nieprawidłowe. Może być więcej testów, albo po prostu data providery żeby sprawdzić różne dane. A walidator może nie musi być wstrzykiwany tylko tworzony wewnątrz? Imho o wiele mniej kodu, łatwiejszy refactoring i sprawdzasz czy funkcjonalność rzeczywiście działa.

3

@jarekr000000 nie zgodzę się co do tej analizy. Załóżmy że mamy tutaj testy dla walidatora, testy dla naszego serwisu z mockiem walidatora i testy integracyjne.

  • Jeśli walidator się zepsuje to wywali nam się test walidatora (plus test integracyjny) -> Wiadomo gdzie szukać problemu bo wysypany test jednostkowy walidatora jasno to pokazuje. Jednocześnie test jednostkowy naszego serwisu nadal działa bo zakładamy tam poprawność walidatora. Więc zamiast wielu wywalonych testów jednostkowych mamy jeden konkretny.
  • Jeśli zepsuje sie nasz serwis to wywali się test naszego serwisu (plus test integracyjny) -> Znów wiadomo od razu gdzie jest problem bo wywalił sie tylko jeden test jednostkowy.
  • Jeśli źle się dogadaliśmy z dostawcą walidatora i np. oczekiwaliśmy od niego odwrotnych wyników (true <-> false) to wywali się test integracyjny -> Ponownie wiadomo co się dzieje bo jedyne co mogło pójść nie tak to "interakcja", skoro testy jednostkowe działają.

W sytuacji kiedy mamy tylko i wyłącznie test integracyjny to będzie on jedynym który się wywalił we wszystkich 3 przypadkach i przestaje być oczywiste co się faktycznie stało i która część nie działa.

0

Źle rozumiesz.

testuje przetworzenie danych

wiem że mogę przetworzyć dane tylko jeżeli są poprawne, ale nie interesuje mnie jakie dokładnie dane są poprawne, bo jw w punkcie testuje przetwarzanie. Mam zatem 2 ścieżki. Dane są poprawne i przetwarzam lub dane są niepoprawne i nie przetwarzam.

mockuję walidator żeby przepuszczał zawsze i sprawdzam czy przetworzyło dane

mockuję walidator żeby nie przepuszczał zawsze i sprawdzam czy nie przetworzyło danych

Przykład.
Przypadek użycia - Dla danych użytkownika i kosyzka (input) stwórz zamówienie. Jeżeli użytkownik ma mniej niż 18 lat to nie pozwól na zakup porno.
2x funkcjonalność

sprawdzanie możliwości złożenia zamówienia

zakładanie zamówienia

Więc testując funkcjonalność numer 2, nie interesuje mnie czy dane są poprawne. Za rok wejdzie prawo że porno jest dostępne od 16 lat. Czy taka zmiana powinna wpłynąć na wynik założenia zamówienia? Nie. W takim wypadku modyfikujemy testy walidatora tak, żeby oczekiwał sukcesu dla 16 lat i odpalamy. Nie przechodzi, to poprawiamy walidator, a nie samo zakładanie zamówienia które nie ma tutaj nic do rzeczy.

3

W takim przypadku imho ten walidator w ogole nie powinien być wstrzykiwany do tego serwisu przetwarzającego dane. Ja bym to zrobił może tak, że jedna funkcjonalność to przetwarzanie danych (zamówienie) druga funkcjonalność to ten walidator (sprawdzanie wieku itp) i to wszystko opakował w kolejny serwisy który najpierw waliduje dane a potem składa zamówienie. Mając testy walidatora i przetwarzania danych w tym trzecim serwisie może wystarczyłoby napisać prosty test (nie potrzeba tutaj sprwadzać wszystkich przypadków) który sprawdza czy rzeczywiście dla niepoprawnych danych zamówienie nie jest składane.

0

@tdudzik a jak masz takich walidatorów i przetwarzaczy n to sobie będziesz wesoło kopiował 100 razy

if validator.validates():
  processor.process

? Czy zrobisz jedną wspólną metodę:

public S processIfValidates(Predicate<T> validator, Function<T, S> processor, T data) throws CośtamException{
    if(validator.apply(data)){
        return processor.apply(data).toEither();
   }
   throw new CośtamException(); // albo jakis Either
}

albo klasę która przyjmuje sobie ten predykat/walidator i funkcje/procesor? Nie kombinujmy tutaj z zalozeniami (tzn czy cośtam wstrzykiwać czy nie) - jeśli takie bylo założenie wstępne to sie go trzymajmy, bo próbujesz teraz rozwiazywać problem który sam wymyśliłeś :D

3

Tu już pisałem, że rozważania na "wyimaginowanym" kodzie do niczego nie prowadzą, bo sobie w trakcie dyskusji możemy dowolnie zmieniać ten kod:-)
@krzysiek050 Zobacz od czego wyszedłeś, a na czym skończyłeś. A to będzie się ciągło... i w końcu wyjdzie, że potrzebujesz mocka ! Ależ super.

Jeszcze raz - nie jestem całkowitym przeciwnikiem mocków - tylko właśnie mi o to chodzi, żeby były z sensem używane. Bo większość mocków jakie widze: nadal jest bez sensu.
Dość cześto wynikają z:

  • z kiepskości (albo nadużywania) frameworku : omg wszystko jest beanem i injectowane! - tego się nawet bez mocków nie zainstancjonuje - pozamiatane,
  • z londyńskiego TDD - niby pisze test najpierw, ale zakładając jak będzie działać metoda (czyli nie sprawdzam co ma wyjść w wyniku, tylko sprawdzam czy wywołano X, bo tak sobie zaplanowałem a priori).

Pierwsze to katastrofa architektoniczna, gdzie durne testy to tylko skutek.
Drugi przypadek to niepotrzebne betonowanie sobie implementacji - czyli mamy testy, które nijak przy refaktoringu nie pomogą (bo najpierw trzeba je będzie przepisać... ).

15
krzysiek050 napisał(a):

Ostatnio w dyskusji o Spring Beans wyszła ciekawa dyskusja (choć przez niektórych uznana za trolling) o testowaniu i mockowaniu.

Trolling - sytuacja, w której programista Springa może przypadkiem się nauczyć programować. ;)

Załóżmy że stosujemy IoC bez żadnego frameworka DI i mamy do przetestowania klasę A.

class A{
    private final B b;
    private final C c;
    public A(B b, C c){/*przypisania*/}
    public X doSomething(Y y);
}

Klasa ta ma jedną metodę i w niej za pomocą 2 innych obiektów tworzony jest z parametru y wynik x. Ja w tym podejściu zamockowałbym B i C, tak żeby do testów trafiła tylko logika y. Robiąc ten test zakładam że:

  • JVM dla 2 + 2 będzie zawsze zwracała 4 i ogólnie działała tak jak to przewiduję.
  • Biblioteka testów jest napisana bezbłędnie
  • Biblioteka mocków jest napisana bezbłędnie

Jeśli B służy do operacji na plikach XML, a C jest wrapperem na API PayPala, to owszem, jest sens je mockować. A jeżeli B i C to klasy przetwarzające dane na potrzeby A, to zazwyczaj nie ma sensu ich nawet wstrzykiwać.

Gdybym jednak przakazał tam prawdziwe instancje B i C to:

  • Nie byłby to test jednostkowy ale integracyjny. Uważam że test jednostkowy testuje możliwie najmniejszą jednostkę, a za integracyjny uważam taki który testuje zestawienie już 2 jednostek. Wiem że potocznie integracyjny to czasem od webserwisu do bazy danych albo jeszcze zewnętrznego systemu, ale gdy zestawiasz 2 klasy to już masz 2 odrębne moduły.

Wychodzisz od błędnego założenia, że każda klasa jest jednostką. Jednostką jest klasa, która robi coś sensownego, co da się wydzielić, i albo stanowi wartość biznesową, albo można jej wielokrotnie używać.Albo inaczej - jeśli B i C istnieją po to, aby A nie była jedną wielką klasą, to nie są jednostkami tylko częścią A.

Jeżeli B byłaby bardzo powszechną klasą i była zależnością 100 innych, to w przypadku jej awarii wywaliłoby się 101 testów zamiast jednego. Dla mnie jest to informacja że coś nie działa, a nie że klasa X nie działa.

Czyli tak:

  • zmieniasz kod w B,
  • wywala Ci się 100 testów B i jeden test X,
    i nie wiesz co trzeba naprawić?

Oczywiście można założyć że jest to dodatkowy test, ale moim zdaniem zamiast testować B w 100 klasach tak samo, lepiej napisać 10 dodatkowych testów do samego B żeby upewnić się że jest idealna skoro zależy od niej tak wiele.

Oczywiście, że tak trzeba, bo B jako reużywalny komponent jest jednostką. Tylko to nie znaczy, że trzeba ją koniecznie mockować w innych testach, jeśli tylko przetwarza dane i nie jest zależny od zewnętrznych źródeł, to mockowanie można uznać za stratę czasu.

krzysiek050 napisał(a):

testuje przetworzenie danych

wiem że mogę przetworzyć dane tylko jeżeli są poprawne, ale nie interesuje mnie jakie dokładnie dane są poprawne, bo jw w punkcie testuje przetwarzanie. Mam zatem 2 ścieżki. Dane są poprawne i przetwarzam lub dane są niepoprawne i nie przetwarzam.

mockuję walidator żeby przepuszczał zawsze i sprawdzam czy przetworzyło dane

mockuję walidator żeby nie przepuszczał zawsze i sprawdzam czy nie przetworzyło danych

Efektem przetwarzania danych jest wynik, poprawny albo nie. Sprawdzaj wyniki, a nie to, czy "dane są przetwarzane".

Przykład.
Przypadek użycia - Dla danych użytkownika i kosyzka (input) stwórz zamówienie. Jeżeli użytkownik ma mniej niż 18 lat to nie pozwól na zakup porno.
2x funkcjonalność

sprawdzanie możliwości złożenia zamówienia

zakładanie zamówienia

Więc testując funkcjonalność numer 2, nie interesuje mnie czy dane są poprawne. Za rok wejdzie prawo że porno jest dostępne od 16 lat. Czy taka zmiana powinna wpłynąć na wynik założenia zamówienia? Nie. W takim wypadku modyfikujemy testy walidatora tak, żeby oczekiwał sukcesu dla 16 lat i odpalamy. Nie przechodzi, to poprawiamy walidator, a nie samo zakładanie zamówienia które nie ma tutaj nic do rzeczy.

No, właśnie - skoro Cię nie interesuje, czy dane są poprawne, to po co tu w ogóle jakiś walidator? Chcesz go wstrzykiwać do koszyka, czy co? Coś nie tak z archiekturą.

0

Przecież w Springu banalnie tworzy się nowe instancje beanów czy to za pomocą mocków czy stawiając kontekst.

0

"z kiepskości (albo nadużywania) frameworku : omg wszystko jest beanem i injectowane! - tego się nawet bez mocków nie zainstancjonuje - pozamiatane,"

to też nie wiem czy troll czy nie.

1

@somekind: Dzięki za wypowiedź (jedyną z uzasadnieniem). W końcu wiem o co chodzi. Przetestuję i ocenię.
@jarekr000000: Widać że łebski z Ciebie gość który przeciera nowe szlaki i chce się podzielić odkryciami. Problem w tym że nie mówisz która ścieżka jest właściwa, ani nie naprowadzasz, a jedynie mówisz że ta jest zła bez uzasadnień. Do tego z jakiegoś powodu uważasz że każda nie przez Ciebie wybrana ścieżka prowadzi do springa. Tutaj springa nie ma. Załóżmy że jest to play 2.3 bez DI. A Ty cały czas o tym springu. Jakbyś miał springfobie. Jak widać po wypowiedzi @somekind da się odpowiedzieć bez przykładu z produkcji, tylko z założeniami.

Jak napisałem. Spróbuję napisać coś wg. tego co powiedział @somekind i zobaczę czy nie zarypię się z testami.

1

Fajna prezentacja

Toruń JUG #28 - "Keep IT clean, or how to hide your shit" Jakub Nabrdalik

https://jakubn.gitlab.io/keepitclean/#1

W zasadzie to testowanie jest bardziej tylko wspomniane ale sporo o tym jak mozna sobie poukladac projekt, zeby bylo latwiej testowac.

1 użytkowników online, w tym zalogowanych: 0, gości: 1