Funkcja może mieć 100% pokrycia testami jednostkowymi, a strona, która ją wywołuje, i tak może zwrócić w produkcji błąd 500. To nie jest sprzeczność. Testy jednostkowe, integracyjne i end-to-end sprawdzają różne rzeczy, a zestaw testów mocny w jednym i słaby w drugim ma konkretną, przewidywalną dziurę.
Co sprawdzają testy jednostkowe, integracyjne i end-to-end
Weźmy funkcję rejestracji: funkcja waliduje dane wejściowe, zapisuje wiersz w bazie danych i wysyła email powitalny. Trzy testy mogą to pokryć, każdy odpowiada na inne pytanie.
| Poziom | Na jakie pytanie odpowiada | Co jest prawdziwe, co jest podmienione |
|---|---|---|
| Jednostkowy | Czy ta jedna funkcja robi to, co powinna? | Wszystko wokół funkcji jest podmienione: brak prawdziwej bazy danych, brak prawdziwej sieci |
| Integracyjny | Czy ta funkcja i prawdziwa zależność współpracują ze sobą? | Baza danych jest prawdziwa; części wciąż poza testowaną granicą (serwer pocztowy) są podmienione |
| End-to-end | Czy całe żądanie działa, patrząc z zewnątrz? | Nic nie jest podmienione. Prawdziwe żądanie HTTP trafia do prawdziwego działającego procesu |
Test jednostkowy mówi ci, że walidacja formatu emaila odrzuca to, co powinna odrzucić. Test integracyjny mówi ci, że wiersz naprawdę trafia do bazy danych, do kolumn, które — jak zakładasz — tam są. Test end-to-end mówi ci, że klient może wywołać endpoint i dostać w odpowiedzi 201. Żaden z nich nie zastąpi pozostałych.
Testowanie jednej funkcji we Flasku na trzech poziomach
Oto funkcja: sign_up, która waliduje email, tworzy użytkownika i wysyła wiadomość powitalną.
| |
sign_up przyjmuje db i smtp_client jako argumenty zamiast korzystać z globalnego połączenia. Ta jedna decyzja umożliwia wszystkie trzy testy poniżej: każdy z nich przekazuje w tych dwóch parametrach coś innego. get_db() i get_smtp_client() przy każdym wywołaniu odczytują swój cel ze zmiennej środowiskowej, zamiast zaszywać go na stałe. Dzięki temu test end-to-end poniżej może skierować działającą aplikację na jednorazową bazę danych i lokalny testowy serwer pocztowy, bez dotykania app.py.
Test jednostkowy: podmień obie zależności
Test jednostkowy całkowicie izoluje sign_up. Ani baza danych, ani klient SMTP nie są prawdziwe; oba to obiekty Mock ze standardowej biblioteki Pythona.
| |
Wchodzą tu w grę dwa rodzaje test double, a nazwy mają znaczenie, bo ustawiają różne oczekiwania. db w pierwszym teście jest bliskie dummy: jest przekazywane, bo sygnatura wymaga czegoś, a testowi zależy tylko na tym, żeby nigdy nie zostało dotknięte. smtp_client w drugim teście to mock w ścisłym sensie. Test z góry definiuje oczekiwanie (sendmail zostanie wywołane z tym adresem) i kończy się niepowodzeniem, jeśli rzeczywistość się z nim nie zgadza. To weryfikacja zachowania: sprawdzasz, że wywołanie się odbyło, a nie co z niego wróciło.
Te testy działają w milisekundach i nigdy nie dotykają dysku ani socketu. Nie powiedzą ci też, czy INSERT INTO users odpowiada rzeczywistym kolumnom tabeli. db nigdy nie wykonuje niczego prawdziwego, więc literówka w SQL przeszłaby przez każdy napisany przez ciebie test jednostkowy.
Test integracyjny: prawdziwa baza danych, fałszywy serwer pocztowy
Test integracyjny zamyka tę lukę, ale jedną zależność naraz. Tutaj baza danych jest prawdziwa — połączenie SQLite w pamięci z rzeczywistym schematem — a serwer pocztowy nadal jest podmieniony. Tym razem to fake: mała, działająca implementacja zamiast zaskryptowanego zastępstwa.
| |
FakeSMTP nie ma z góry zaskryptowanych oczekiwań, tak jak robiło to smtp_client.sendmail.assert_called_once(). Trzyma prawdziwą, choć maleńką, listę tego, co zostało wysłane, a test odczytuje ją później. To jest różnica między mockiem a fake’em: mockowi mówisz, czego się spodziewać, zanim kod się wykona; fake zachowuje się jak mniejsza wersja prawdziwej rzeczy i sprawdzasz go dopiero potem.
Ten test wyłapie złą nazwę kolumny albo brakującą tabelę. Nie wyłapie błędu w parsowaniu żądań przez Flask ani złego URL-a w dekoratorze trasy, bo nic tutaj nie przechodzi przez HTTP.
Test end-to-end: prawdziwe żądanie do działającego procesu
Test end-to-end usuwa obie podmiany. Uruchamia prawdziwą aplikację Flask jako osobny proces, wysyła do niej prawdziwe żądanie HTTP przez prawdziwy socket i sprawdza wynik tak, jak zrobiłby to zewnętrzny klient: wywołując ponownie API, a nie zaglądając do bazy danych.
| |
aiosmtpd zastępuje przechwytywacz poczty, który uruchomiłbyś w środowisku staging: coś, co naprawdę mówi po SMTP przez socket, dzięki czemu prawdziwe wywołanie smtplib.SMTP(...) w aplikacji ma się gdzie połączyć. Test tworzy też sobie jednorazowy plik bazy danych zamiast ponownie używać app.db: uruchomienie tego testu dwa razy na współdzielonym pliku zakończyłoby się błędem za drugim razem na tym samym PRIMARY KEY, który już zajmuje [email protected] — dokładnie taki rodzaj niestabilności wynikającej z pozostałości stanu, o którym artykuł mówi w dalszej części. Nic w tym teście nie wie, że sign_up czy create_user w ogóle istnieją. Zna tylko kontrakt HTTP. Zmień nazwę trasy albo kształt JSON-a, a to właśnie ten test się wysypie, podczas gdy testy jednostkowe i integracyjne powyżej nadal będą przechodzić.
Zainstaluj zależności poleceniem pip install flask requests aiosmtpd pytest i uruchom wszystkie trzy przez pytest. Kiedy coś się psuje, sam fakt, który plik testów zawiódł, mówi ci, z jaką klasą błędu masz do czynienia. To dobry powód, żeby pisać testy na więcej niż jednym poziomie. Procent pokrycia takim powodem nie jest.
Piramida testów vs trofeum testów
Klasycznym kształtem dla zestawu testów jest piramida testów Mike’a Cohna: szeroka podstawa z szybkich testów jednostkowych, mniejsza warstwa testów integracyjnych i cienki czubek wolnych, kosztownych testów end-to-end. Liczy się proporcja, nie liczba. Większość twoich testów powinna być tego taniego rodzaju.
Trofeum testów Kenta C. Doddsa nie zgadza się co do tego, gdzie powinien leżeć ciężar. Jego argument: izolowane testy jednostkowe dają pewność, którą łatwo zdobyć, ale która nie odzwierciedla tego, jak oprogramowanie jest naprawdę używane, więc zestaw testów złożony głównie z testów jednostkowych może przeoczyć realne błędy na styku komponentów. Trofeum kładzie analizę statyczną (linting, sprawdzanie typów) na dole, warstwę jednostkową nad nią, testy integracyjne jako najszerszą część, a cienką warstwę end-to-end na samej górze. Trofeum chce więcej testów w stylu test_sign_up_persists_user_and_records_email powyżej.
| Piramida (Cohn) | Trofeum (Dodds) | |
|---|---|---|
| Największa warstwa | Jednostkowa | Integracyjna |
| Warstwa u podstawy | Testy jednostkowe | Analiza statyczna (lint, typy) |
| Pytanie przewodnie | Jak tani jest ten test do napisania i uruchomienia? | Jak bardzo ten test przypomina prawdziwe użycie? |
| Słaby punkt, w który celuje | Wolne, kruche zestawy end-to-end | Testy jednostkowe, które przechodzą, mimo że styki są zepsute |
Nie trzymaj się żadnego z tych kształtów dosłownie. Zgadzają się co do kolejności: testy end-to-end są najdroższe i powinno ich być najmniej, testy jednostkowe są najtańsze. Gdzie skierujesz największy wysiłek, zależy od historii błędów w twoim projekcie. Jeśli większość błędów to komponenty źle zachowujące się same z siebie, przechyl się w stronę piramidy. Jeśli większość to komponenty, które nie zgadzają się co do sposobu, w jaki ze sobą rozmawiają, przechyl się w stronę trofeum.
Dlaczego testy end-to-end stają się niestabilne
Zestaw z rosnącym stosem testów end-to-end w końcu zyskuje złą reputację: ponownie uruchamiasz test, który padł, i przechodzi. To jest niestabilność (flakiness) i bierze się z tej samej właściwości, która czyni testy end-to-end wartościowymi. Dotykają prawdziwej infrastruktury.
Zwykłe przyczyny to sztywny sleep(), który wystarcza na twoim laptopie, a jest za krótki na obciążonym runnerze CI, dane testowe pozostawione przez poprzednie uruchomienie, które zderzają się z kolejnym, wywołanie sieciowe do czegoś poza twoją kontrolą oraz stan współdzielony między testami, który ujawnia się dopiero, gdy uruchamiają się w innej kolejności albo równolegle. time.sleep(0.5) w teście powyżej to pierwszy rodzaj; plik tymczasowy, który ten sam test tworzy dla DB_PATH, to bezpośrednia poprawka drugiego. Żaden z nich nie jest błędem w funkcjonalności. To błędy w tym, jak test radzi sobie z prawdziwą infrastrukturą — problem, którego testy jednostkowe nigdy nie mają.
Ponawianie testu, aż będzie zielony, nie jest rozwiązaniem; to chowa następną prawdziwą awarię w szumie. Trzymaj warstwę end-to-end cienką, pokrywając krytyczne ścieżki, a nie każdą gałąź. Daj każdemu testowi jego własne dane zamiast współdzielonych fixture’ów. Zamień sztywne opóźnienia na pętlę sprawdzającą konkretny warunek, na przykład to, czy port już przyjmuje połączenia. Czas CI to drugi koszt: jeśli już przyspieszyłeś pipeline, cache’ując zależności w GitHub Actions, warstwa end-to-end, która potrzebuje trzech ponownych uruchomień, żeby zrobić się zielona, oddaje sporą część tego czasu.
Jaki test napisać dalej
Jeśli błąd właśnie wyciekł na produkcję, zanim dodasz jakikolwiek test, zapytaj, który poziom by go złapał. Błędne obliczenie albo nieobsłużony przypadek brzegowy w jednej funkcji woła o test jednostkowy. Dwa fragmenty twojego własnego kodu, które nie zgadzają się co do kształtu albo kontraktu — na przykład API zwracające pole, którego frontend się nie spodziewa, albo zapytanie zakładające kolumnę, która została zmieniona — wołają o test integracyjny. Jeśli funkcja przechodzi każdy test, ale zawodzi u prawdziwego użytkownika, zwykle brakuje jej pokrycia end-to-end na ścieżce, którą przeszedł ten użytkownik.
Napisz najpierw test jednostkowy. To najtańszy sygnał i najszybciej mówi ci, kiedy zepsułeś logikę. Dodaj test integracyjny, kiedy dwa twoje własne elementy muszą udowodnić, że się ze sobą zgadzają. Zachowaj testy end-to-end dla garstki przepływów, w których „działa” znaczy coś wyłącznie z perspektywy kogoś z zewnątrz: rejestracja, logowanie, checkout, resetowanie hasła.
Trzymaj się tego podziału, a dostaniesz zestaw testów, którego awarie coś znaczą. Ma to znaczenie poza CI. Git bisect z git bisect run potrzebuje komendy testowej, która zawodzi tylko wtedy, gdy kod jest naprawdę zepsuty, a niestabilny test end-to-end wyśle go do złego commita.