3 rzeczy, których nauczyłem się po napisaniu 300 testów w miesiąc
Zgaduję, że większość z nas znalazła się w takiej sytuacji: dostajesz aplikację, którą masz się zaopiekować. Ma ona fatalne statystyki testowania z prawie zerowym pokryciem testami. Utrzymanie i dalszy rozwój to duży ból, bez możliwości sprawdzenia, czy coś zepsułeś.
To niesamowicie trudna sytuacja: inżynier przed Tobą zdecydował, że nie potrzebuje testów jednostkowych i że został zesłany przez niebiosa, aby być jedynym inżynierem, który pisze bezbłędny kod za pierwszym razem.
Aplikacja, którą odziedziczyłem, miała zero testów jednostkowych. Był to frontend i backend oparty o Flutter i Typescript, w takiej kolejności. Jakimś cudem udało mu się wykonać swoje zadanie z niewielką lub zerową ilością błędów: być może stało się to dzięki godzinom spędzonym na testowaniu manualnym?
Postanowiłem, że zostanę zmianą, której chciałem być świadkiem i po spędzeniu 40 godzin tygodniowo przez 4 tygodnie, w końcu doprowadziłem pokrycie testami do 90%. Oto, czego się dowiedziałem.
1. Przy pisaniu implementacji, koniecznie musisz wziąć pod uwagę testowanie
Czy kiedykolwiek próbowałeś napisać test jednostkowy dla komponentu znajdującego się głęboko w aplikacji bez wstrzykiwania zależności? Jest to prawie niemożliwe - musisz zastosować mocki dla każdej małej interakcji, ponieważ wszystkie zależności komponentu są prawdziwą implemntacją, a nie mockami. Jak ten głupi spędziłem pierwszy tydzień, myśląc, że mogę uciec od refaktoryzacji kodu
Bardzo się myliłem.
Wprowadzenie wstrzykiwania zależności sprawiło, że testowanie stało się wiele łatwiejsze. Możliwość stworzenia takiej interakcji, jakiej chcesz, jest wspaniała i pomaga nam tworzyć solidne, ale i wartościowe testy.
Jeśli piszesz w kompilowanym języku, istnieje prawdopodobieństwo, że nie możesz mockować importów. W TypeScript/JavaScript, możesz po prostu mockować importowaną funkcję i zaimportować zamiast niej swoją mockowaną wersję - to nie do końca działa z Javą lub Dartem, które są kompilowane. Zamiast tego będziesz musiał wstrzyknąć zależności bezpośrednio do obiektu.
Inne rzeczy, które musiałem refaktoryzować podczas testowania, to wyodrębnienie wszystkich ciągów znaków do stałych. Np. jeśli użytkownik się wyloguje, wyświetlimy mu tekst „Wylogowany!”.
Jak sprawdzić, czy tekst pojawił się na ekranie? Może to wyglądać następująco:
expect(find.text("logged out!"), toFindOneWidget);
ale jeśli ludzie UI/UX poproszą o szybką zmianę tekstu po wylogowaniu, będziesz musiał refaktoryzować wszystkie testy, aby również użyć nowej wersji. Zamiast tego wyodrębniam tekst „Wylogowany!” do zmiennej.
Proste, ale dość ważne przy pisaniu testów jednostkowych.
2. 100% pokrycia testowego to (może być) przesada
Kiedy zaczynałem, zdecydowałem, że mogę równie dobrze pójść na całość i celować w 100% pokrycia. Szybko nauczyłem się, że są pewne ciemne zakamarki, które są po prostu bardzo trudne lub bardzo nieznaczące z punktu widzenia testowania. Weźmy np. taką sytuację:
MyListener(
onEvent: (event) => event.join(
(state1) => null,
(state2) => null,
(state3) => doSomething(state3.property)
)
);
Testowanie stanu 3 jest całkowicie uzasadnione i jest to gałąź, którą kończę testować. Ale pisanie testów typu "na stanie1 / stanie2 nie rób nic" jest 1. bezużyteczne, 2. niemożliwe.
Pomyśl o tym: jak zweryfikowałbyś, że funkcja nic nie wykonuje? Czy zamierzasz przechodzić przez każdy pojedynczy mock i upewniać się, że nigdy nie został wywołany? Wymagałoby to bardzo dużo zbędnej pracy.
Mógłbym skończyć na rozszerzaniu wszystkich zdarzeń, które reagowałyby tylko na jeden stan, ale nie miałoby to zbyt wiele sensu. Wolę skupić się na ulepszaniu aplikacji dla moich użytkowników, a nie na odpalaniu dopaminy w mózgu programisty, gdy widzi „pokrycie: 100%”!
Nie oznacza to jednak, że 100% pokrycia jest złe dla wszystkich aplikacji. Testowanie to maraton - nie sprint. Jednak w tym maratonie ja skończyłem na sprincie. Dodałem wiele pozycji na backlogu, które powoli będę usuwał, gdy będę miał wolny czas.
Jednym z tych elementów jest uzyskanie 100% pokrycia.
3. Testowanie backendu łatwiejsze od testowania frontendu
Zanim uniesiecie w powietrze widły: to jest tylko moja opinia! Jeśli się z tym nie zgadzasz, chętnie usłyszę, dlaczego w komentarzach.
Kod backendu jest bardzo deterministyczny. Akcje są w większości liniowe (np. routing wywołuje usługę), a każdy punkt wejścia jest zrozumiały, podobnie jak zrozumiałe są wyjścia.
Frontend taki nie jest.
Pojedynczy ekran aplikacji nie ma tylko jednego punktu wejścia: być może powiadomienie push z głębokim łączem odsyła użytkownika do tego ekranu. Być może niektóre pola były wstępnie uzupełnione albo użytkownik nie był administratorem, więc nie powinien być na tym ekranie.
Możesz napisać kod frontendowy, który jest łatwy do przetestowania, ale jest to kolejny przypadek „Proszę mieć na uwadze testowanie podczas pisania!”. Każdy stan ładowania, niepowodzenia, musi mieć swoje własne testy jednostkowe. Dopiero gdy użytkownik będzie korzystał z aplikacji, będziesz mógł dowiedzieć się więcej o tym, co się psuje.
Przykładem z mojej aplikacji, o którym nie pomyślałem, aby go przetestować, ale spowodował błędy, było to, że gdy użytkownik spamował dwa przyciski w tę i z powrotem, stworzyłby hazard. Ten hazard oznaczałby, że zdarzenie początku i końca ładowania zostałyby odpalone w odwrotnej kolejności, a cała aplikacja zawiesiłaby się na ekranie ładowania na zawsze.
Skąd u licha miałem wiedzieć, żeby sprawdzić tę interakcję? Wiem, że w moim backendzie nie zamierzam wywoływać funkcji na pętli w ten sposób, żeby podobne zachowanie miało miejsce.
Ogólnie rzecz biorąc, ludzie są nieprzewidywalnymi istotami, a maszyny są niewiarygodnie przewidywalne. Testowanie zachowania maszyn jest znacznie łatwiejsze niż testowanie ludzi.
Ostatnią uwagą dotyczącą tego punktu jest to, że mogą wystąpić setki zmian dotyczących jakości życia, które można wprowadzić na frontend, takich jak cache’owanie, czy predykacja. To co najmniej dwa dodatkowe testy (kiedy caching idzie dobrze vs., kiedy idzie źle) dla czegoś, czego użytkownik może nigdy nie zauważyć.
Nie pozwól, aby to zniechęciło Cię do pisania testów. Za każdym razem, gdy dodaję nową funkcję, świadomość, że wszystko inne działa zgodnie z przeznaczeniem, jest jak powiew świeżego powietrza.
Testowanie jednostkowe to dla mnie - programisty - jedna z ulubionych rzeczy, ponieważ czuję się bardzo produktywny, a przy tym zadowolony z mojego kodu. Nie piszę już kodu bez testów, piszę mikroskrypty i jestem dzięki temu o wiele bardziej efektywnym programistą.
A może Ty masz jakieś historie związane z testowaniem, którymi chciałbyś się podzielić? Jakieś pytania lub komentarze? Daj znać! Dzięki za przeczytanie!
Oryginał tekstu w języku angielskim przeczytasz tutaj.