Rozważ taką funkcję:
function savePeople(dataStore, people) {
people.forEach(person => dataStore.savePerson(person));
}
Można go użyć w następujący sposób:
myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);
Pozwól nam zakładać, że Store
ma swoje własne testy jednostkowe, czy sprzedawca-warunkiem. W każdym razie ufamy Store
. Załóżmy ponadto, że obsługa błędów - np. Błędów rozłączenia bazy danych - nie jest odpowiedzialna za savePeople
. Rzeczywiście, załóżmy, że sam sklep jest magiczną bazą danych, która nie może w żaden sposób popełnić błędu. Biorąc pod uwagę te założenia, pytanie brzmi:
Czy powinny savePeople()
być testowane jednostkowo, czy też takie testy mogłyby oznaczać testowanie wbudowanej forEach
konstrukcji języka?
Moglibyśmy oczywiście wprowadzić próbną kpinę dataStore
i zapewnić, że dataStore.savePerson()
wezwanie jest raz dla każdej osoby. Z pewnością można argumentować, że taki test zapewnia bezpieczeństwo przed zmianami implementacyjnymi: np. Jeśli zdecydujemy się zastąpić forEach
tradycyjną for
pętlą lub inną metodą iteracji. Test nie jest więc całkowicie trywialny. A jednak wydaje się okropnie blisko ...
Oto kolejny przykład, który może być bardziej owocny. Zastanów się nad funkcją, która jedynie koordynuje inne obiekty lub funkcje. Na przykład:
function bakeCookies(dough, pan, oven) {
panWithRawCookies = pan.add(dough);
oven.addPan(panWithRawCookies);
oven.bakeCookies();
oven.removePan();
}
Jak taką funkcję należy testować jednostkowo, zakładając, że tak powinno być? Trudno mi sobie wyobrazić, wszelkiego rodzaju testów jednostkowych, które nie tylko Mock dough
, pan
i oven
, a następnie stwierdzić, że metody są wywoływane na nich. Ale taki test nie robi nic więcej, jak powielenie dokładnej implementacji funkcji.
Czy ta niezdolność do przetestowania funkcji w sensowny czarny sposób wskazuje na wadę projektową samej funkcji? Jeśli tak, jak można to poprawić?
Aby jeszcze bardziej wyjaśnić pytanie motywujące ten bakeCookies
przykład, dodam bardziej realistyczny scenariusz, z którym się spotkałem przy próbie dodania testów i refaktoryzacji starszego kodu.
Gdy użytkownik utworzy nowe konto, musi się zdarzyć kilka rzeczy: 1) należy utworzyć nowy rekord użytkownika w bazie danych 2) należy wysłać powitalny e-mail 3) adres IP użytkownika musi zostać zarejestrowany w celu oszustwa cele.
Chcemy więc stworzyć metodę, która łączy wszystkie kroki „nowego użytkownika”:
function createNewUser(validatedUserData, emailService, dataStore) {
userId = dataStore.insertUserRecord(validateduserData);
emailService.sendWelcomeEmail(validatedUserData);
dataStore.recordIpAddress(userId, validatedUserData.ip);
}
Zauważ, że jeśli którakolwiek z tych metod zgłosi błąd, chcemy, aby błąd pojawił się w kodzie wywołującym, aby mógł obsłużyć błąd według własnego uznania. Jeśli jest wywoływany przez kod API, może przełożyć błąd na odpowiedni kod odpowiedzi http. Jeśli jest wywoływany przez interfejs sieciowy, może przełożyć błąd na odpowiedni komunikat wyświetlany użytkownikowi i tak dalej. Chodzi o to, że ta funkcja nie wie, jak obsłużyć błędy, które mogą zostać zgłoszone.
Istotą mojego pomieszania jest to, że do testowania jednostkowego takiej funkcji wydaje się konieczne powtórzenie dokładnej implementacji w samym teście (przez określenie, że metody są wywoływane na próbach w określonej kolejności) i to wydaje się nieprawidłowe.
źródło
Odpowiedzi:
Czy
savePeople()
należy przetestować jednostkę? Tak. Nie testujesz, czydataStore.savePerson
działa, czy działa połączenie db, a nawet czyforeach
działa. Testujesz, którysavePeople
spełnia obietnicę, jaką daje dzięki umowie.Wyobraź sobie ten scenariusz: ktoś dokonuje dużego refaktoryzacji bazy kodu i przypadkowo usuwa
forEach
część implementacji, aby zawsze zapisywał tylko pierwszy element. Czy nie chciałbyś, aby test jednostkowy to wykrył?źródło
Zazwyczaj tego rodzaju pytanie pojawia się, gdy ludzie robią programowanie „po testach”. Podejdź do tego problemu z punktu widzenia TDD, gdzie testy poprzedzają implementację, i zadaj sobie to pytanie ponownie jako ćwiczenie.
Przynajmniej w mojej aplikacji TDD, która zwykle jest na zewnątrz, nie implementowałbym funkcji takiej jak
savePeople
po jej wdrożeniusavePerson
. FunkcjesavePeople
isavePerson
byłyby uruchamiane jako jedna i byłyby sterowane testami z tych samych testów jednostkowych; rozdzielenie między nimi nastąpiłoby po kilku testach na etapie refaktoryzacji. Ten tryb pracy postawiłby również pytanie, gdziesavePeople
powinna być funkcja - czy jest to funkcja wolna, czy jej częśćdataStore
.W końcu, testy nie tylko sprawdzić, czy można poprawnie zapisać
Person
wStore
, ale także wiele osób. Doprowadziłoby mnie to również do pytania, czy konieczne są inne kontrole, na przykład: „Czy muszę się upewnić, żesavePeople
funkcja jest atomowa, albo zapisuje wszystko, albo nie ma żadnej?”, „Czy może po prostu zwrócić błędy osobom, które nie mogły” zostać zapisane? Jak wyglądałyby te błędy? ”itd. Wszystko to oznacza znacznie więcej niż tylko sprawdzenie użycia jednejforEach
lub innych form iteracji.Chociaż, jeśli wymóg zapisania więcej niż jednej osoby naraz pojawił się dopiero po tym, jak
savePerson
został już spełniony , zaktualizowałbym istniejące testy,savePerson
aby przejść przez nową funkcjęsavePeople
, upewniając się, że nadal może uratować jedną osobę, po prostu delegując na początku, następnie przetestuj zachowanie więcej niż jednej osoby za pomocą nowych testów, zastanawiając się, czy konieczne byłoby nadanie temu atomowi charakteru, czy nie.źródło
savePeople
? Jak opisałem w ostatnim akapicie OP lub w inny sposób?savePerson
funkcji, jak zasugerowałeś, zamiast tego przetestowałbym ją bardziej ogólniesavePeople
. Testy jednostkowe dlaStore
zostałyby zmienione tak, aby uruchamiałysavePeople
zamiast bezpośrednio wywoływaćsavePerson
, więc do tego nie są stosowane żadne makiety. Ale baza danych oczywiście nie powinna być obecna, ponieważ chcielibyśmy odizolować problemy z kodowaniem od różnych problemów z integracją, które występują w rzeczywistych bazach danych, więc tutaj wciąż mamy kpinę.Tak, powinno. Ale spróbuj napisać warunki testowe w sposób niezależny od implementacji. Na przykład przekształcając przykład użycia w test jednostkowy:
Ten test wykonuje wiele czynności:
savePeople()
savePeople()
savePeople()
Pamiętaj, że nadal możesz wyśmiewać / odgałęzić / sfałszować magazyn danych. W takim przypadku nie sprawdziłbym jawnych wywołań funkcji, ale wynik operacji. W ten sposób mój test jest przygotowany na przyszłe zmiany / refaktory.
Na przykład implementacja magazynu danych może zapewnić
saveBulkPerson()
metodę w przyszłości - teraz zmiana implementacjisavePeople()
użyciasaveBulkPerson()
nie przerwałaby testu jednostkowego, o ilesaveBulkPerson()
działa zgodnie z oczekiwaniami. A jeślisaveBulkPerson()
jakoś nie zadziała zgodnie z oczekiwaniami, twój test jednostkowy to wykryje.Jak powiedziano, spróbuj przetestować oczekiwane wyniki i interfejs funkcji, a nie implementację (chyba że wykonujesz testy integracyjne - wtedy przydałoby się przechwytywanie określonych wywołań funkcji). Jeśli istnieje wiele sposobów implementacji funkcji, wszystkie powinny działać z testem jednostkowym.
Jeśli chodzi o twoją aktualizację pytania:
Testuj zmiany stanu! Np. Część ciasta zostanie wykorzystana. Zgodnie z wdrożeniem, upewnij się, że ilość użytego
dough
pasuje dopan
lub zapewnij, żedough
jest zużyty. Upewnij się, żepan
zawiera pliki cookie po wywołaniu funkcji. Upewnij się, żeoven
jest pusty / w takim samym stanie jak poprzednio.W przypadku dodatkowych testów sprawdź przypadki skrajne: Co się stanie, jeśli
oven
nie będzie puste przed wywołaniem? Co się stanie, jeśli będzie za małodough
? Jeślipan
jest już pełny?Powinieneś być w stanie wydedukować wszystkie wymagane dane do tych testów z samego ciasta, naczynia i piekarnika. Nie trzeba przechwytywać wywołań funkcji. Traktuj funkcję tak, jakby jej implementacja nie była dla Ciebie dostępna!
W rzeczywistości większość użytkowników TDD pisze testy przed napisaniem funkcji, więc nie są zależni od faktycznej implementacji.
Do najnowszego dodatku:
Dla takiej funkcji wyśmiewałbym / stub / fake (cokolwiek wydaje się bardziej ogólne) parametry
dataStore
iemailService
. Ta funkcja nie wykonuje żadnych zmian stanów na żadnym parametrze, deleguje je do metod niektórych z nich. Chciałbym sprawdzić, czy wywołanie funkcji spowodowało 4 rzeczy:Pierwsze 3 kontrole można wykonać za pomocą próbnych, stubów lub podróbek
dataStore
iemailService
(naprawdę nie chcesz wysyłać wiadomości e-mail podczas testowania). Ponieważ musiałem to sprawdzić w przypadku niektórych komentarzy, oto różnice:dataStore
implementacja odpowiedniej wersjiinsertUserRecord()
irecordIpAddress()
.Oczekiwane wyjątki / błędy są poprawnymi przypadkami testowymi: Potwierdzasz, że w przypadku wystąpienia takiego zdarzenia funkcja zachowuje się tak, jak się spodziewasz. Można to osiągnąć, pozwalając, aby odpowiedni obiekt próbny / fałszywy / skrótowy rzucał w razie potrzeby.
Czasami trzeba to zrobić (choć w testach integracyjnych zależy Ci na tym). Częściej istnieją inne sposoby weryfikacji oczekiwanych efektów ubocznych / zmian stanu.
Weryfikacja dokładnych wywołań funkcji powoduje dość kruche testy jednostkowe: tylko niewielkie zmiany oryginalnej funkcji powodują, że zawodzą. Może to być pożądane lub nie, ale wymaga zmiany odpowiednich testów jednostkowych za każdym razem, gdy zmieniasz funkcję (czy to refaktoryzacja, optymalizacja, usuwanie błędów, ...).
Niestety, w takim przypadku test jednostkowy traci część swojej wiarygodności: ponieważ został zmieniony, nie potwierdza funkcji po zmianie zachowuje się tak samo jak poprzednio.
Na przykład rozważmy dodanie przez kogoś wywołania
oven.preheat()
(optymalizacja!) W przykładzie pieczenia ciasteczek:W moich testach jednostkowych staram się być tak ogólny, jak to możliwe: jeśli implementacja ulegnie zmianie, ale widoczne zachowanie (z perspektywy osoby wywołującej) pozostanie takie samo, moje testy powinny zostać zaliczone. Idealnie, jedynym przypadkiem, w którym muszę zmienić istniejący test jednostkowy, powinna być naprawa błędu (testu, a nie testowanej funkcji).
źródło
myDataStore.containsPerson('Joe')
, zakładasz istnienie funkcjonalnej testowej bazy danych. Gdy to zrobisz, piszesz test integracyjny, a nie test jednostkowy.savePeople()
faktyczne dodanie tych osób do dowolnego magazynu danych, który udostępniasz, o ile ten magazyn danych implementuje oczekiwany interfejs. Test integracji polegałby na przykład na sprawdzeniu, czy moje opakowanie bazy danych faktycznie wykonuje odpowiednie wywołania bazy danych dla wywołania metody.myDataStore.containsPerson('Joe')
, musisz użyć funkcjonalnej bazy danych . Po wykonaniu tego kroku nie jest to już test jednostkowy.myPeople
) znajdują się w tablicy. IMHO próbny powinien nadal mieć takie samo obserwowalne zachowanie, jakiego oczekuje się od rzeczywistego obiektu, w przeciwnym razie testujesz zgodność z próbnym interfejsem, a nie rzeczywistym interfejsem.Podstawową wartością, jaką zapewnia taki test, jest fakt, że implementacja jest refaktoryzowalna.
W swojej karierze przeprowadzałem wiele optymalizacji wydajności i często napotykałem problemy z dokładnie pokazanym wzorem: aby zapisać N jednostek w bazie danych, wykonać N wstawień. Zwykle bardziej wydajne jest wykonywanie wstawiania zbiorczego za pomocą pojedynczej instrukcji.
Z drugiej strony nie chcemy też przedwcześnie optymalizować. Jeśli zwykle ratujesz tylko 1-3 osoby na raz, pisanie zoptymalizowanej partii może być przesadą.
Przy odpowiednim teście jednostkowym możesz napisać go tak, jak go wdrożyłeś powyżej, a jeśli okaże się, że musisz go zoptymalizować, możesz to zrobić za pomocą siatki bezpieczeństwa automatycznego testu w celu wykrycia błędów. Oczywiście różni się to w zależności od jakości testów, więc testuj swobodnie i dobrze testuj.
Drugą zaletą testowania tego zachowania przez jednostkę jest służenie jako dokumentacja tego, do czego służy. Ten trywialny przykład może być oczywisty, ale biorąc pod uwagę następny punkt poniżej, może być bardzo ważny.
Trzecią zaletą, na którą zwrócili uwagę inni, jest możliwość testowania ukrytych detali, które są bardzo trudne do przetestowania za pomocą testów integracyjnych lub akceptacyjnych. Na przykład, jeśli istnieje wymóg, aby wszyscy użytkownicy byli zapisywani atomowo, możesz napisać testową skrzynkę, która da ci pewność, że zachowuje się zgodnie z oczekiwaniami, a także służy jako dokumentacja dla wymagania, które może nie być oczywiste dla nowych programistów.
Dodam myśl, którą dostałem od instruktora TDD. Nie testuj metody. Przetestuj zachowanie. Innymi słowy, nie testujesz, czy to
savePeople
działa, testujesz, czy wielu użytkowników można zapisać w jednym połączeniu.Przekonałem się, że moja zdolność do przeprowadzania testów jednostkowych jakości i TDD poprawia się, kiedy przestałem myśleć o testach jednostkowych jako o sprawdzaniu działania programu, ale raczej sprawdzają, czy jednostka kodu działa zgodnie z oczekiwaniami . Te są różne. Nie sprawdzają, czy to działa, ale sprawdzają, czy działa tak, jak sądzę. Kiedy zacząłem tak myśleć, moja perspektywa się zmieniła.
źródło
savePerson
go dla każdej osoby na liście - przerwałaby się jednak przy masowym refaktoryzowaniu wkładki. Co dla mnie wskazuje, że jest to zły test jednostkowy. Nie widzę jednak alternatywy, która przekazałaby zarówno implementacje zbiorcze, jak i implementacje typu jeden zapis na osobę, bez użycia rzeczywistej testowej bazy danych i zapewnienia jej, co wydaje się błędne. Czy możesz podać test, który działa dla obu implementacji?savePerson
metoda została wywołana dla każdego wejścia, a jeśli zastąpisz pętlę wstawieniem zbiorczym, nie będziesz już wywoływać tej metody. Twój test się zepsuje. Jeśli masz na myśli coś innego, jestem otwarty na to, ale jeszcze tego nie widzę. (I nie widziałem, że o to mi chodziło).Powinien
bakeCookies()
zostać przetestowany? Tak.Nie całkiem. Przyjrzyj się dokładnie, CO ma robić funkcja - ma ustawić
oven
obiekt w określonym stanie. Patrząc na kod, wydaje się, że stany obiektówpan
idough
nie mają tak naprawdę większego znaczenia. Powinieneś więc przekazaćoven
obiekt (lub wyśmiewać go) i upewnić się, że jest on w określonym stanie na końcu wywołania funkcji.Innymi słowy, powinieneś stwierdzić, że
bakeCookies()
upiekły ciasteczka .W przypadku bardzo krótkich funkcji testy jednostkowe mogą wydawać się czymś więcej niż tautologią. Ale nie zapominaj, że twój program będzie trwał znacznie dłużej niż czas, w którym go piszesz. Ta funkcja może, ale nie musi ulec zmianie w przyszłości.
Testy jednostkowe spełniają dwie funkcje:
Sprawdza, czy wszystko działa. Jest to najmniej przydatna funkcja testów jednostkowych i wydaje się, że wydaje się, że rozważasz tę funkcjonalność tylko przy zadawaniu pytania.
Sprawdza, czy przyszłe modyfikacje programu nie psują funkcjonalności, która została wcześniej zaimplementowana. Jest to najbardziej przydatna funkcja testów jednostkowych i zapobiega wprowadzaniu błędów do dużych programów. Jest przydatny w normalnym kodowaniu podczas dodawania funkcji do programu, ale jest bardziej użyteczny w refaktoryzacji i optymalizacjach, w których podstawowe algorytmy wdrażające program są dramatycznie zmieniane bez zmiany jakiegokolwiek możliwego do zaobserwowania zachowania programu.
Nie testuj kodu wewnątrz funkcji. Zamiast tego sprawdź, czy funkcja robi to, co mówi. Kiedy spojrzysz na testy jednostkowe w ten sposób (testowanie funkcji, a nie kodu), zdasz sobie sprawę, że nigdy nie testujesz konstrukcji języka, ani nawet logiki aplikacji. Testujesz interfejs API.
źródło
bakeCookies
są testowane w ten sposób, mają tendencję do psucia się podczas refaktorów, co nie wpłynęłoby na obserwowalne zachowanie aplikacji.Tak. Ale możesz to zrobić w sposób, który po prostu ponownie przetestuje konstrukcję.
Należy tutaj zauważyć, jak zachowuje się ta funkcja, gdy
savePerson
awaria jest w połowie? Jak to ma działać?Jest to rodzaj subtelnego zachowania, które zapewnia funkcja, którą należy egzekwować za pomocą testów jednostkowych.
źródło
savePeople
nie powinienem być odpowiedzialny za obsługę błędów. Aby wyjaśnić jeszcze raz, zakładając, żesavePeople
jest on odpowiedzialny tylko za iterację po liście i delegowanie zapisywania każdego elementu na inną metodę, czy należy go nadal testować?foreach
konstrukcji, a nie żadnych warunków, skutków ubocznych lub zachowań poza nim, masz rację; nowy test jednostkowy nie jest wcale taki interesujący.Kluczem tutaj jest twoje spojrzenie na określoną funkcję jako banalną. Większość programowania jest trywialna: przypisz wartość, zrób matematykę, podejmij decyzję: jeśli to, to kontynuuj pętlę, aż ... W izolacji wszystkie trywialne. Właśnie przejrzałeś 5 pierwszych rozdziałów każdej książki uczącej języka programowania.
Fakt, że pisanie testu jest tak łatwe, powinien być znakiem, że Twój projekt nie jest taki zły. Czy wolisz projekt, który nie jest łatwy do przetestowania?
„To się nigdy nie zmieni”. tak zaczyna się większość nieudanych projektów. Test jednostkowy określa tylko, czy jednostka działa zgodnie z oczekiwaniami w określonych okolicznościach. Zdobądź go, a wtedy możesz zapomnieć o szczegółach jego implementacji i po prostu go użyć. Wykorzystaj przestrzeń mózgu do następnego zadania.
Wiedza o tym, że wszystko działa zgodnie z oczekiwaniami, jest bardzo ważna i nie jest trywialna w dużych projektach, a zwłaszcza w dużych zespołach. Jeśli jest coś, co łączy programistów, to fakt, że wszyscy mieliśmy do czynienia z okropnym kodem innej osoby. Możemy przynajmniej przeprowadzić testy. W razie wątpliwości napisz test i przejdź dalej.
źródło
Odpowiedział na to już @BryanOakley, ale mam dodatkowe argumenty (tak sądzę):
Po pierwsze, test jednostkowy służy do testowania wykonania umowy, a nie wdrożenia interfejsu API; test powinien ustalić warunki wstępne, a następnie wywołać, a następnie sprawdzić efekty, skutki uboczne, niezmienniki i warunki po. Kiedy decydujesz, co przetestować, implementacja interfejsu API nie ma (i nie powinna) mieć znaczenia .
Po drugie, twój test będzie tam, aby sprawdzić niezmienniki, gdy funkcja się zmieni . Fakt, że teraz się nie zmienia , nie oznacza, że nie powinieneś mieć testu.
Po trzecie, warto wdrożyć trywialny test, zarówno w podejściu TDD (który tego wymaga), jak i poza nim.
Pisząc C ++, na moich zajęciach piszę trywialny test, który tworzy instancję obiektu i sprawdza niezmienniki (przypisywalne, zwykłe itp.). Zaskakujące było to, ile razy test ten był przerywany podczas programowania (np. Przez pomyłkę dodanie do klasy niemożliwego do przeniesienia członka).
źródło
Myślę, że twoje pytanie sprowadza się do:
Jak mogę przetestować funkcję typu void bez testowania integracji?
Jeśli zmienimy funkcję pieczenia plików cookie, aby na przykład zwracać pliki cookie, natychmiast stanie się oczywiste, jaki powinien być test.
Jeśli musimy wywołać pan.GetCookies po wywołaniu funkcji, możemy jednak zapytać, czy jest to „test integracji”, czy „ale czy nie testujemy tylko obiektu panoramowania?”.
Myślę, że masz rację pod tym względem, że przeprowadzanie testów jednostkowych z wyśmiewanym wszystkim i sprawdzanie funkcji xy i z nazwano brakiem wartości.
Ale! Argumentowałbym, że w takim przypadku powinieneś zrefaktoryzować swoje nieważne funkcje, aby zwrócić testowalny wynik LUB użyć rzeczywistych obiektów i wykonać test integracji
--- Aktualizacja dla przykładu createNewUser
OK, więc tym razem wynik funkcji nie jest łatwo zwracany. Chcemy zmienić stan parametrów.
W tym miejscu jestem nieco kontrowersyjny. Tworzę konkretne symulacje dla parametrów stanowych
proszę, drodzy czytelnicy, spróbujcie opanować swój gniew!
więc...
Oddziela to szczegóły implementacji testowanej metody od pożądanego zachowania. Alternatywne wdrożenie:
Nadal przejdzie test jednostkowy. Ponadto masz tę zaletę, że możesz ponownie użyć próbnych obiektów w testach, a także wstrzyknąć je do aplikacji na potrzeby interfejsu użytkownika lub testów integracyjnych.
źródło
bakeCookies
zwracania upieczonych ciasteczek jest na miejscu, a po opublikowaniu tego pomyślałem. Myślę więc, że to jeszcze nie jest świetny przykład. Dodałem jeszcze jedną aktualizację, która, mam nadzieję, stanowi bardziej realistyczny przykład motywacji mojego pytania. Doceniam twój wkład.Powinieneś także przetestować
bakeCookies
- co przyniosłoby / powinno e..gbakeCookies(egg, pan, oven)
? Jajko sadzone czy wyjątek? Na własną rękę, anipan
też nieoven
będą dbać o rzeczywistych składników, ponieważ żaden z nich nie mają, alebakeCookies
powinny zazwyczaj wydajność ciasteczek. Bardziej ogólnie może to zależeć od tego, jakdough
uzyskuje się i czy jest jakaś szansa, że staje się zwykłymegg
lub npwater
zamiast.źródło