Podczas pisania testów jednostkowych nie używaj makiet
Opublikowany: 2018-05-02Uwaga: to nasz najnowszy post dotyczący inżynierii technicznej, napisany przez głównego inżyniera, Setha Ammonsa. Specjalne podziękowania dla Sama Nguyena, Kane Kima, Elmera Thomasa i Kevina Gillette'a za recenzowanie tego posta . Aby uzyskać więcej takich postów, sprawdź nasz techniczny blog.
Bardzo lubię pisać testy dla mojego kodu, zwłaszcza testy jednostkowe. Poczucie pewności, jakie mi daje, jest świetne. Wybranie czegoś, nad czym nie pracowałem od dłuższego czasu, i możliwość przeprowadzenia testów jednostkowych i integracyjnych daje mi wiedzę, że mogę bezwzględnie dokonać refaktoryzacji w razie potrzeby i tak długo, jak moje testy mają dobry i znaczący zasięg i nadal zdają , nadal będę miał działające oprogramowanie.
Testy jednostkowe kierują projektowaniem kodu i pozwalają nam szybko zweryfikować, czy tryby awarii i przepływy logiczne działają zgodnie z przeznaczeniem. W związku z tym chcę napisać o czymś może nieco bardziej kontrowersyjnym: pisząc testy jednostkowe, nie używaj mocków.
Zbierzmy kilka definicji na stole
Jaka jest różnica między testami jednostkowymi a integracyjnymi? Co rozumiem przez kpiny i czego należy użyć zamiast tego? Ten post skupia się na pracy w Go, więc moje podejście do tych słów jest w kontekście Go.
Kiedy mówię o testach jednostkowych , mam na myśli te testy, które zapewniają właściwą obsługę błędów i kierują projektowaniem systemu poprzez testowanie małych jednostek kodu. Według jednostki możemy odnosić się do całego pakietu, interfejsu lub pojedynczej metody.
Testowanie integracyjne to miejsce, w którym faktycznie wchodzisz w interakcję z zależnymi systemami i/lub bibliotekami. Kiedy mówię „mock”, mam na myśli konkretnie termin „Mock Object”, czyli „zastępujemy kod domeny fikcyjnymi implementacjami, które zarówno emulują rzeczywistą funkcjonalność, jak i wymuszają twierdzenia dotyczące zachowania naszego kodu [1]” (podkreślenie mój).
Mówiąc nieco krócej: kpiący twierdzą zachowanie, takie jak:
MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")
Opowiadam się za „podróbkami, a nie drwiną”.
Fałszerstwo to rodzaj testowego sobowtóra, który może zawierać zachowanie biznesowe [2]. Podróbki to jedynie struktury, które pasują do interfejsu i są formą wstrzykiwania zależności, w której kontrolujemy zachowanie. Główną zaletą podróbek jest to, że zmniejszają one łączenie w kodzie, gdzie mocki zwiększają łączenie, a sprzęganie utrudnia refaktoryzację [3].
W tym poście zamierzam pokazać, że podróbki zapewniają elastyczność i pozwalają na łatwe testowanie i refaktoryzację. Zmniejszają zależności w porównaniu z próbami i są łatwe w utrzymaniu.
Zanurzmy się w przykładzie, który jest nieco bardziej zaawansowany niż „testowanie funkcji sumy”, jak można zobaczyć w typowym poście tego rodzaju. Muszę jednak podać ci kontekst, abyś mógł łatwiej zrozumieć kod, który następuje w tym poście.
W SendGrid jeden z naszych systemów tradycyjnie posiadał pliki w lokalnym systemie plików, ale ze względu na potrzebę większej dostępności i lepszej przepustowości przenosimy te pliki do S3.
Mamy aplikację, która musi być w stanie odczytać te pliki i zdecydowaliśmy się na aplikację, która może działać w dwóch trybach „lokalny” lub „zdalny”, w zależności od konfiguracji. Zastrzeżenie, które zostało ominięte w wielu przykładach kodu, polega na tym, że w przypadku zdalnej awarii wracamy do lokalnego odczytu pliku.
Pomijając to, ta aplikacja ma pobieracz pakietów. Musimy upewnić się, że pobieracz pakietów może pobierać pliki ze zdalnego lub lokalnego systemu plików.
Naiwne podejście: wystarczy wywołać bibliotekę połączeń i wywołania na poziomie systemu
Naiwnym podejściem jest to, że nasz pakiet implementacyjny wywoła getter.New(...) i przekaże mu informacje potrzebne do skonfigurowania zdalnego lub lokalnego pobrania pliku i zwróci Getter . Zwrócona wartość będzie wtedy mogła wywołać MyGetter.GetFile(...) z parametrami potrzebnymi do zlokalizowania pliku zdalnego lub lokalnego.
To da nam naszą podstawową strukturę. Kiedy tworzymy nowy Getter , inicjujemy parametry, które są potrzebne do dowolnego potencjalnego zdalnego pobierania plików (klucz dostępu i sekret), a także przekazujemy niektóre wartości, które pochodzą z konfiguracji naszej aplikacji, takie jak useRemoteFS , które powiedzą kodowi, aby spróbował zdalny system plików.
Musimy zapewnić podstawową funkcjonalność. Sprawdź naiwny kod tutaj [4]; poniżej jest zmniejszona wersja. Uwaga, jest to niedokończony przykład i zamierzamy dokonać refaktoryzacji rzeczy.
Podstawowa idea polega na tym, że jeśli jesteśmy skonfigurowani do odczytu ze zdalnego systemu plików i otrzymujemy szczegóły zdalnego systemu plików (host, zasobnik i klucz), powinniśmy spróbować czytać ze zdalnego systemu plików. Po upewnieniu się, że system odczytuje zdalnie, przeniesiemy wszystkie odczyty z plików do zdalnego systemu plików i usuniemy odniesienia do odczytu z lokalnego systemu plików.
Ten kod nie jest zbyt przyjazny dla testów jednostkowych; zauważ, że aby sprawdzić, jak to działa, musimy trafić nie tylko na lokalny system plików, ale także na zdalny system plików. Teraz moglibyśmy po prostu przeprowadzić test integracji i skonfigurować trochę magii Dockera, aby mieć instancję s3, która pozwoli nam zweryfikować szczęśliwą ścieżkę w kodzie.
Posiadanie tylko testów integracyjnych nie jest jednak idealne, ponieważ testy jednostkowe pomagają nam projektować bardziej niezawodne oprogramowanie, łatwo testując alternatywny kod i ścieżki awarii. Powinniśmy zachować testy integracyjne na większe testy typu „czy to naprawdę działa”. Na razie skupmy się na testach jednostkowych.
Jak możemy sprawić, by ten kod był bardziej testowalny jednostkowo? Istnieją dwie szkoły myślenia. Jednym z nich jest użycie generatora makiet (takiego jak https://github.com/vektra/mockery lub https://github.com/golang/mock), który tworzy szablonowy kod do użycia podczas testowania makiet.
Możesz pójść tą drogą i wygenerować wywołania systemu plików i wywołania klienta Minio. A może chcesz uniknąć zależności, więc swoje makiety generujesz ręcznie. Okazuje się, że naśmiewanie się z klienta Minio nie jest proste, ponieważ masz klienta z konkretnym typem, który zwraca obiekt z konkretnym typem.
Mówię, że jest lepszy sposób niż kpiny. Jeśli zrestrukturyzujemy nasz kod, aby był bardziej testowalny, nie będziemy potrzebować dodatkowych importów dla mocków i powiązanego crufta i nie będzie potrzeby znajomości dodatkowych testowych DSL, aby pewnie przetestować interfejsy. Możemy ustawić nasz kod tak, aby nie był nadmiernie powiązany, a kod testowy będzie po prostu normalnym kodem Go przy użyciu interfejsów Go. Zróbmy to!
Podejście interfejsu: większa abstrakcja, łatwiejsze testowanie
Co to jest, co musimy przetestować? W tym miejscu niektórzy nowi Swistacy się mylą. Widziałem, jak ludzie rozumieją wartość korzystania z interfejsów, ale czują, że potrzebują interfejsów, które pasują do konkretnej implementacji pakietu, z którego korzystają.
Mogą zobaczyć, że mamy klienta Minio, więc mogą zacząć od stworzenia interfejsów, które pasują do WSZYSTKICH metod i zastosowań klienta Minio (lub dowolnego innego klienta s3). Zapominają o przysłowiu Go [5][6]: „Im większy interfejs, tym słabsza abstrakcja”.
Nie musimy testować z klientem Minio. Musimy sprawdzić, czy możemy pobrać pliki zdalnie lub lokalnie (i zweryfikować niektóre ścieżki awarii, takie jak awarie zdalne). Zmieńmy to początkowe podejście i przekształćmy klienta Minio w zdalny pobieracz. Podczas gdy robimy to, zróbmy to samo z naszym kodem do lokalnego odczytu plików i stwórzmy lokalny getter. Oto podstawowe interfejsy i będziemy mieli typ do zaimplementowania każdego z nich:
Mając te abstrakcje, możemy dokonać refaktoryzacji naszej początkowej implementacji. Umieścimy localFetcher i remoteFetcher w strukturze Getter i dokonamy refaktoryzacji GetFile , aby ich użyć. Sprawdź pełną wersję zrefaktoryzowanego kodu tutaj [7]. Poniżej znajduje się nieco uproszczony fragment z wykorzystaniem nowej wersji interfejsu:
Ten nowy, zrefaktoryzowany kod jest znacznie bardziej testowalny jednostkowo, ponieważ przyjmujemy interfejsy jako parametry struktury Getter i możemy zmienić konkretne typy na fałszywe. Zamiast kpić z wywołań systemu operacyjnego lub potrzebować pełnego podszycia klienta Minio lub dużych interfejsów, potrzebujemy tylko dwóch prostych podróbek: fakeLocalFetcher i fakeRemoteFetcher .
Te podróbki mają na sobie pewne właściwości, które pozwalają nam określić, co zwracają. Będziemy mogli zwrócić dane pliku lub dowolny błąd, który nam się podoba i możemy zweryfikować, czy wywołująca metoda GetFile obsługuje dane i błędy zgodnie z naszymi zamierzeniami.
Mając to na uwadze, sercem testów stają się:
Dzięki tej podstawowej strukturze możemy to wszystko zamknąć w testach opartych na tabeli [8]. Każdy przypadek w tabeli testów będzie testem lokalnego lub zdalnego dostępu do plików. Będziemy mogli wprowadzić błąd przy zdalnym lub lokalnym dostępie do pliku. Możemy zweryfikować propagowane błędy, czy zawartość pliku jest przekazywana i czy występują oczekiwane wpisy w logu.
Poszedłem dalej i uwzględniłem wszystkie potencjalne przypadki testowe i permutacje w teście opartym na jednej tabeli, dostępnym tutaj [9] (możesz zauważyć, że niektóre sygnatury metod są nieco inne — pozwala nam to robić takie rzeczy jak wstrzykiwanie loggera i asercja wobec instrukcji logów ).
Fajnie, co? Mamy pełną kontrolę nad tym, jak chcemy, aby GetFile zachowywał się i możemy domagać się wyników. Zaprojektowaliśmy nasz kod tak, aby był przyjazny dla testów jednostkowych i teraz możemy weryfikować ścieżki sukcesu i błędów zaimplementowane w metodzie GetFile .
Kod jest luźno powiązany i refaktoryzacja w przyszłości powinna być dziecinnie prosta. Zrobiliśmy to, pisząc zwykły kod Go, który każdy programista zaznajomiony z Go powinien być w stanie zrozumieć i rozszerzyć w razie potrzeby.
Mocks: co z drobiazgowymi, drobiazgowymi szczegółami implementacji?
Co by nam kupiły drwiny, czego nie dostaniemy w proponowanym rozwiązaniu? Świetnym pytaniem pokazującym korzyści z tradycyjnej makiety może być: „Skąd wiesz, że zadzwoniłeś do klienta s3 z poprawnymi parametrami? Dzięki próbom mogę zapewnić, że przekazałem wartość klucza do parametru klucza, a nie do parametru wiadra”.
Jest to zasadna obawa i powinna być gdzieś objęta testem. Podejście testowe, które tutaj zalecam, nie weryfikuje, czy wywołałeś klienta Minio z zasobnikiem i kluczowymi parametrami we właściwej kolejności.
Świetny cytat, który niedawno przeczytałem, mówił: „Prześmiewanie wprowadza założenia, co wprowadza ryzyko [10]”. Zakładasz, że biblioteka klienta jest poprawnie zaimplementowana, zakładasz, że wszystkie granice są stałe, zakładasz, że wiesz, jak faktycznie zachowuje się biblioteka.
Mocowanie biblioteki tylko kpi z założeń i sprawia, że twoje testy są bardziej kruche i mogą ulec zmianie podczas aktualizacji kodu (co Martin Fowler stwierdził w Mocks Aren't Stubs [3]). Kiedy guma trafi na drogę, będziemy musieli zweryfikować, czy faktycznie używamy klienta Minio poprawnie, a to oznacza testy integracyjne (mogą one działać w konfiguracji Dockera lub środowisku testowym). Ponieważ będziemy mieć zarówno testy jednostkowe, jak i integracyjne, nie ma potrzeby, aby test jednostkowy obejmował dokładną implementację, ponieważ test integracyjny to obejmie.
W naszym przykładzie testy jednostkowe kierują naszym projektem kodu i pozwalają nam szybko przetestować, czy błędy i przepływy logiczne działają zgodnie z założeniami, robiąc dokładnie to, co muszą.
Niektórzy uważają, że to za mało pokrycia testami jednostkowymi. Martwią się o powyższe punkty. Niektórzy będą nalegać na interfejsy w stylu rosyjskich lalek, w których jeden interfejs zwraca inny interfejs, który zwraca inny interfejs, być może podobny do następującego:
Następnie mogą wyciągnąć każdą część klienta Minio do każdego opakowania, a następnie użyć generatora próbnego (dodając zależności do kompilacji i testów, zwiększając założenia i czyniąc rzeczy bardziej kruchymi). Na koniec prześmiewca będzie mógł powiedzieć coś takiego:
myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, bucket) – i to jeśli możesz przypomnieć sobie poprawną inkantację dla tego konkretnego DSL.
Byłoby to dużo dodatkowej abstrakcji związanej bezpośrednio z wyborem implementacji przy użyciu klienta Minio. Spowoduje to kruche testy, gdy dowiemy się, że musimy zmienić nasze założenia dotyczące klienta lub całkowicie potrzebujemy innego klienta.
Wydłuża to czas opracowywania kodu end-to-end teraz i w przyszłości, zwiększa złożoność kodu i zmniejsza czytelność, potencjalnie zwiększa zależność od generatorów próbnych i daje nam wątpliwą dodatkową wartość wiedzy, czy pomieszaliśmy parametry wiadra i kluczowe których i tak byśmy odkryli podczas testów integracyjnych.
W miarę jak wprowadzanych jest coraz więcej obiektów, sprzężenie staje się coraz mocniejsze. Mogliśmy zrobić makiety z rejestratora, a później zaczynamy mieć makiety z metrykami. Zanim się zorientujesz, dodajesz wpis do dziennika lub nową metrykę i właśnie złamałeś dziesiątki testów, które nie spodziewały się pojawienia się dodatkowej metryki.
Ostatnim razem, gdy mnie to ugryzło w Go, framework do szyderstwa nawet nie powiedział mi, który test lub plik nie powiódł się, ponieważ spanikował i umarł okropną śmiercią, ponieważ natrafił na nową metrykę (wymagało to binarnego przeszukiwania testów poprzez ich komentowanie aby móc znaleźć miejsce, w którym musimy zmienić pozorne zachowanie). Czy kpiny mogą wnieść wartość dodaną? Pewny. Czy to jest warte kosztów? W większości przypadków nie jestem przekonany.
Interfejsy: prostota i testowanie jednostkowe dla wygranej
Pokazaliśmy, że możemy pokierować projektowaniem i zapewnić, że właściwy kod i ścieżki błędów są przestrzegane za pomocą prostych interfejsów w Go. Pisząc proste podróbki, które trzymają się interfejsów, widzimy, że do tworzenia kodu przeznaczonego do testowania nie potrzebujemy mocków, frameworków mockujących, ani generatorów mocków. Zauważyliśmy również, że testy jednostkowe to nie wszystko i musisz pisać testy integracyjne, aby upewnić się, że systemy są ze sobą odpowiednio zintegrowane.
Mam nadzieję, że w przyszłości otrzymam post o ciekawych sposobach przeprowadzania testów integracyjnych; bądźcie czujni!
Bibliografia
1: Endo-Testing: Unit Testing with Mock Objects (2000): Zobacz wprowadzenie do definicji fikcyjnego obiektu
2: Mały Prześmiewca: Zobacz część dotyczącą podróbek, a konkretnie „Podróbka zachowuje się biznesowo. Możesz sprawić, że podróbka będzie zachowywać się na różne sposoby, podając jej inne dane”.
3: Drwiny nie są skrótami: Zobacz sekcję „Więc powinienem być klasykiem czy drwiną?” Martin Fowler stwierdza: „Nie widzę żadnych przekonujących korzyści dla pozorowanego TDD i jestem zaniepokojony konsekwencjami łączenia testów z wdrożeniem”.
4: Naive Approach: uproszczona wersja kodu. Patrz [7].
5: https://go-proverbs.github.io/: Lista Przysłów Go z linkami do rozmów.
6: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s: Bezpośredni link do rozmowy Roba Pike'a na temat rozmiaru interfejsu i abstrakcji.
7: Pełna wersja kodu demo: możesz sklonować repozytorium i uruchomić `go test`.
8: Testy sterowane tabelą: Strategia testowania organizująca kod testowy w celu ograniczenia duplikacji.
9: Testy dla pełnej wersji kodu demo. Możesz je uruchomić za pomocą `go test`.
10: Pytania, które należy sobie zadać podczas pisania testów Michała Charemzy: Naśmiewanie się wprowadza założenia, a założenia wprowadzają ryzyko.