Przewodnik Node.js dotyczący faktycznego wykonywania testów integracyjnych

Opublikowany: 2022-03-11

Testy integracyjne nie są czymś, czego należy się bać. Są niezbędnym elementem pełnego przetestowania aplikacji.

Mówiąc o testowaniu, zwykle myślimy o testach jednostkowych, w których testujemy mały fragment kodu w izolacji. Jednak Twoja aplikacja jest większa niż ten mały fragment kodu i prawie żadna część aplikacji nie działa w izolacji. W tym miejscu testy integracyjne udowadniają swoją wagę. Testy integracyjne pojawiają się tam, gdzie testy jednostkowe kończą się niepowodzeniem i wypełniają lukę między testami jednostkowymi a testami końcowymi.

Wiesz, że musisz pisać testy integracyjne, więc dlaczego tego nie robisz?
Ćwierkać

W tym artykule dowiesz się, jak pisać czytelne i komponowalne testy integracyjne z przykładami w aplikacjach opartych na API.

Chociaż we wszystkich przykładach kodu w tym artykule będziemy używać JavaScript/Node.js, większość omawianych pomysłów można łatwo dostosować do testów integracyjnych na dowolnej platformie.

Testy jednostkowe a testy integracyjne: potrzebujesz obu

Testy jednostkowe koncentrują się na jednej konkretnej jednostce kodu. Często jest to specyficzna metoda lub funkcja większego elementu.

Testy te są wykonywane w izolacji, gdzie wszystkie zależności zewnętrzne są zwykle zastępowane lub wyszydzane.

Innymi słowy, zależności są zastępowane wstępnie zaprogramowanym zachowaniem, zapewniając, że wynik testu jest określony tylko przez poprawność testowanej jednostki.

Więcej o testach jednostkowych dowiesz się tutaj.

Testy jednostkowe służą do utrzymania wysokiej jakości kodu z dobrym projektem. Pozwalają nam również z łatwością zakrywać narożniki.

Wadą jest jednak to, że testy jednostkowe nie mogą obejmować interakcji między komponentami. W tym miejscu przydatne stają się testy integracyjne.

Testy integracyjne

Jeśli testy jednostkowe są definiowane przez testowanie najmniejszych jednostek kodu w izolacji, testy integracyjne są wręcz przeciwne.

Testy integracyjne służą do testowania wielu większych jednostek (komponentów) w interakcji, a czasami mogą nawet obejmować wiele systemów.

Celem testów integracyjnych jest znalezienie błędów w połączeniach i zależnościach między różnymi komponentami, takich jak:

  • Przekazywanie nieprawidłowych lub niepoprawnie uporządkowanych argumentów
  • Uszkodzony schemat bazy danych
  • Nieprawidłowa integracja pamięci podręcznej
  • Luki w logice biznesowej lub błędy w przepływie danych (ponieważ testowanie odbywa się teraz z szerszego punktu widzenia).

Jeśli testowane przez nas komponenty nie mają skomplikowanej logiki (np. komponenty o minimalnej złożoności cyklomatycznej), testy integracyjne będą o wiele ważniejsze niż testy jednostkowe.

W takim przypadku testy jednostkowe będą używane przede wszystkim do wymuszenia dobrego projektu kodu.

Podczas gdy testy jednostkowe pomagają upewnić się, że funkcje są poprawnie napisane, testy integracyjne pomagają upewnić się, że system działa poprawnie jako całość. Tak więc zarówno testy jednostkowe, jak i testy integracyjne służą swojemu własnemu uzupełniającemu się celowi i oba są niezbędne do kompleksowego podejścia do testowania.

Testy jednostkowe i testy integracyjne są jak dwie strony tego samego medalu. Moneta nie jest ważna bez obu.

Dlatego testowanie nie jest zakończone, dopóki nie ukończysz testów integracyjnych i jednostkowych.

Skonfiguruj pakiet do testów integracyjnych

Podczas gdy konfigurowanie zestawu testów do testów jednostkowych jest dość proste, skonfigurowanie zestawu testów do testów integracyjnych jest często trudniejsze.

Na przykład komponenty w testach integracyjnych mogą mieć zależności spoza projektu, takie jak bazy danych, systemy plików, dostawcy poczty e-mail, zewnętrzne usługi płatności i tak dalej.

Czasami testy integracyjne muszą korzystać z tych zewnętrznych usług i komponentów, a czasami mogą one zostać skrócone.

Kiedy są potrzebne, może to prowadzić do kilku wyzwań.

  • Delikatne wykonanie testu: usługi zewnętrzne mogą być niedostępne, zwracać nieprawidłową odpowiedź lub znajdować się w nieprawidłowym stanie. W niektórych przypadkach może to skutkować fałszywie pozytywnym wynikiem, innym razem może skutkować fałszywie negatywnym.
  • Powolne wykonanie: przygotowywanie i łączenie się z usługami zewnętrznymi może być powolne. Zazwyczaj testy są uruchamiane na zewnętrznym serwerze w ramach CI.
  • Złożona konfiguracja testu: usługi zewnętrzne muszą być w żądanym stanie do testowania. Na przykład baza danych powinna być wstępnie załadowana wymaganymi danymi testowymi itp.

Wskazówki, których należy przestrzegać podczas pisania testów integracyjnych

Testy integracyjne nie mają ścisłych reguł, takich jak testy jednostkowe. Mimo to istnieje kilka ogólnych wskazówek, którymi należy się kierować podczas pisania testów integracyjnych.

Powtarzalne testy

Kolejność testów lub zależności nie powinny zmieniać wyniku testu. Wielokrotne uruchamianie tego samego testu powinno zawsze zwracać ten sam wynik. Może to być trudne do osiągnięcia, jeśli test wykorzystuje Internet do łączenia się z usługami innych firm. Jednak ten problem można obejść poprzez przycinanie i wyśmiewanie.

W przypadku zależności zewnętrznych, nad którymi masz większą kontrolę, skonfigurowanie kroków przed i po teście integracji pomoże zapewnić, że test będzie zawsze uruchamiany z identycznego stanu.

Testowanie odpowiednich działań

Aby przetestować wszystkie możliwe przypadki, znacznie lepszą opcją są testy jednostkowe.

Testy integracyjne są bardziej zorientowane na połączenie między modułami, dlatego testowanie szczęśliwych scenariuszy jest zwykle dobrym rozwiązaniem, ponieważ obejmie ważne połączenia między modułami.

Zrozumiały test i asercja

Jeden szybki widok testu powinien poinformować czytelnika, co jest testowane, jak skonfigurowane jest środowisko, co jest zastępowane, kiedy test jest wykonywany i co jest potwierdzane. Asercje powinny być proste i wykorzystywać pomocniki w celu lepszego porównywania i rejestrowania.

Łatwa konfiguracja testu

Doprowadzenie testu do stanu początkowego powinno być jak najprostsze i jak najbardziej zrozumiałe.

Unikaj testowania kodu innej firmy

Chociaż usługi innych firm mogą być używane w testach, nie ma potrzeby ich testowania. A jeśli im nie ufasz, prawdopodobnie nie powinieneś ich używać.

Zostaw kod produkcyjny wolny od kodu testowego

Kod produkcyjny powinien być czysty i prosty. Mieszanie kodu testowego z kodem produkcyjnym spowoduje, że dwie domeny, których nie można połączyć, zostaną ze sobą połączone.

Odpowiednie rejestrowanie

Nieudane testy nie są bardzo cenne bez dobrego logowania.

Gdy testy zakończą się pomyślnie, nie jest potrzebne dodatkowe rejestrowanie. Ale kiedy zawiodą, niezbędne jest obszerne rejestrowanie.

Rejestrowanie powinno zawierać wszystkie zapytania do bazy danych, żądania API i odpowiedzi, a także pełne porównanie tego, co jest świadczone. Może to znacznie ułatwić debugowanie.

Dobre testy wyglądają na czyste i zrozumiałe

Prosty test zgodny z niniejszymi wytycznymi może wyglądać tak:

 const co = require('co'); const test = require('blue-tape'); const factory = require('factory'); const superTest = require('../utils/super_test'); const testEnvironment = require('../utils/test_environment_preparer'); const path = '/v1/admin/recipes'; test(`API GET ${path}`, co.wrap(function* (t) { yield testEnvironment.prepare(); const recipe1 = yield factory.create('recipe'); const recipe2 = yield factory.create('recipe'); const serverResponse = yield superTest.get(path); t.deepEqual(serverResponse.body, [recipe1, recipe2]); }));

Powyższy kod testuje API ( GET /v1/admin/recipes ), które oczekuje, że w odpowiedzi zwróci tablicę zapisanych receptur.

Widać, że test, jakkolwiek prosty, opiera się na wielu narzędziach. Jest to typowe dla każdego dobrego zestawu testów integracyjnych.

Komponenty pomocnicze ułatwiają pisanie zrozumiałych testów integracyjnych.

Przyjrzyjmy się, jakie komponenty są potrzebne do testów integracyjnych.

Komponenty pomocnicze

Kompleksowy pakiet testowy składa się z kilku podstawowych składników, w tym: kontroli przepływu, struktury testowej, obsługi bazy danych i sposobu łączenia się z interfejsami API zaplecza.

Kontrola przepływu

Jednym z największych wyzwań w testowaniu JavaScript jest przepływ asynchroniczny.

Callbacki mogą siać spustoszenie w kodzie, a obietnice po prostu nie wystarczą. Tutaj przydają się pomocnicy przepływu.

Podczas oczekiwania na pełne wsparcie async/await można użyć bibliotek o podobnym zachowaniu. Celem jest napisanie czytelnego, ekspresyjnego i solidnego kodu z możliwością przepływu asynchronicznego.

Co umożliwia pisanie kodu w ładny sposób, jednocześnie nie blokując go. Odbywa się to poprzez zdefiniowanie funkcji kogeneratora, a następnie uzyskanie wyników.

Innym rozwiązaniem jest użycie Bluebird. Bluebird to biblioteka obietnic, która ma bardzo przydatne funkcje, takie jak obsługa tablic, błędów, czasu itp.

Współprogramy Co i Bluebird zachowują się podobnie do async/await w ES7 (oczekiwanie na rozwiązanie przed kontynuowaniem), jedyną różnicą jest to, że zawsze zwróci obietnicę, co jest przydatne do obsługi błędów.

Ramy testowe

Wybór struktury testowej sprowadza się do osobistych preferencji. Preferuję framework, który jest łatwy w użyciu, nie ma skutków ubocznych i którego dane wyjściowe są łatwo czytelne i przesyłane strumieniowo.

W JavaScript istnieje wiele różnych frameworków testowych. W naszych przykładach używamy taśmy. Moim zdaniem taśma nie tylko spełnia te wymagania, ale także jest czystsza i prostsza niż inne frameworki testowe, takie jak Mocha czy Jasmin.

Taśma jest oparta na protokole Test Anything Protocol (TAP).

TAP ma wariacje dla większości języków programowania.

Taśma wykonuje testy jako dane wejściowe, uruchamia je, a następnie wyprowadza wyniki jako TAP. Wynik TAP może być następnie przesłany do reportera testowego lub może być wyprowadzony do konsoli w surowym formacie. Taśma jest uruchamiana z wiersza poleceń.

Tape ma kilka fajnych funkcji, takich jak definiowanie modułu do załadowania przed uruchomieniem całego zestawu testów, dostarczanie małej i prostej biblioteki asercji oraz definiowanie liczby asercji, które powinny być wywołane w teście. Korzystanie z modułu do wstępnego ładowania może uprościć przygotowanie środowiska testowego i usunąć niepotrzebny kod.

Biblioteka fabryczna

Biblioteka fabryczna umożliwia zastąpienie plików statycznych osprzętu znacznie bardziej elastycznym sposobem generowania danych do testu. Taka biblioteka pozwala na definiowanie modeli i tworzenie encji dla tych modeli bez pisania niechlujnego, złożonego kodu.

JavaScript ma do tego factory_girl - bibliotekę inspirowaną klejnotem o podobnej nazwie, który został pierwotnie opracowany dla Ruby on Rails.

 const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, { username: 'Bob', number_of_recipes: 50 }); const user = factory.build('user');

Aby rozpocząć, nowy model musi być zdefiniowany w factory_girl.

Jest określony nazwą, modelem z twojego projektu i obiektem, z którego generowana jest nowa instancja.

Alternatywnie, zamiast definiować obiekt, z którego generowana jest nowa instancja, można podać funkcję, która zwróci obiekt lub obietnicę.

Tworząc nową instancję modelu możemy:

  • Zastąp dowolną wartość w nowo wygenerowanej instancji
  • Przekaż dodatkowe wartości do opcji funkcji budowania

Zobaczmy przykład.

 const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, (buildOptions) => { return { name: 'Mike', surname: 'Dow', email: buildOptions.email || '[email protected]' } }); const user1 = factory.build('user'); // {"name": "Mike", "surname": "Dow", "email": "[email protected]"} const user2 = factory.build('user', {name: 'John'}, {email: '[email protected]'}); // {"name": "John", "surname": "Dow", "email": "[email protected]"}

Łączenie z API

Uruchomienie pełnowymiarowego serwera HTTP i wysłanie rzeczywistego żądania HTTP tylko po to, aby kilka sekund później je wyłączyć – zwłaszcza w przypadku przeprowadzania wielu testów – jest całkowicie nieefektywne i może spowodować, że testy integracyjne będą trwać znacznie dłużej niż to konieczne.

SuperTest to biblioteka JavaScript do wywoływania API bez tworzenia nowego aktywnego serwera. Opiera się na SuperAgent, bibliotece do tworzenia żądań TCP. Dzięki tej bibliotece nie ma potrzeby tworzenia nowych połączeń TCP. Interfejsy API są niemal natychmiast wywoływane.

SuperTest, z obsługą obietnic, jest supertestem zgodnie z obietnicą. Gdy takie żądanie zwraca obietnicę, pozwala uniknąć wielu zagnieżdżonych funkcji zwrotnych, co znacznie ułatwia obsługę przepływu.

 const express = require('express') const request = require('supertest-as-promised'); const app = express(); request(app).get("/recipes").then(res => assert(....));

SuperTest został stworzony dla frameworka Express.js, ale po niewielkich zmianach może być używany również z innymi frameworkami.

Inne narzędzia

W niektórych przypadkach istnieje potrzeba zakpiwania pewnej zależności w naszym kodzie, przetestowania logiki wokół funkcji za pomocą szpiegów lub użycia kodów pośredniczących w określonych miejscach. Tutaj przydają się niektóre z tych pakietów narzędzi.

SinonJS to świetna biblioteka, która obsługuje szpiegów, stubów i makiety do testów. Obsługuje również inne przydatne funkcje testowania, takie jak czas gięcia, testowa piaskownica i rozszerzone asercje, a także fałszywe serwery i żądania.

W niektórych przypadkach istnieje potrzeba zakpiwania pewnej zależności w naszym kodzie. Odniesienia do usług, które chcielibyśmy zakpić, są wykorzystywane przez inne części systemu.

Aby rozwiązać ten problem, możemy użyć wstrzykiwania zależności lub, jeśli nie jest to możliwe, możemy skorzystać z usługi mockingu, takiej jak Mockery.

Szyderstwo pomaga naśmiewać się z kodu, który ma zewnętrzne zależności. Aby używać go poprawnie, Mockery należy wywołać przed załadowaniem testów lub kodu.

 const mockery = require('mockery'); mockery.enable({ warnOnReplace: false, warnOnUnregistered: false }); const mockingStripe = require('lib/services/internal/stripe'); mockery.registerMock('lib/services/internal/stripe', mockingStripe);

Dzięki temu nowemu odwołaniu (w tym przykładzie mockingStripe ) łatwiej jest symulować usługi w dalszej części naszych testów.

 const stubStripeTransfer = sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null));

Z pomocą biblioteki Sinon łatwo jest ośmieszyć. Jedynym problemem jest to, że ten kod pośredniczy będzie propagowany do innych testów. Aby to zrobić w piaskownicy, można użyć piaskownicy sinon. Dzięki niemu późniejsze testy mogą przywrócić system do stanu początkowego.

 const sandbox = require('sinon').sandbox.create(); const stubStripeTransfer = sandbox.sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null)); // after the test, or better when starting a new test sandbox.restore();

Potrzebne są inne komponenty dla funkcji takich jak:

  • Opróżnianie bazy danych (można to zrobić za pomocą jednego zapytania przed kompilacją hierarchii)
  • Ustawienie go w stan roboczy (sequelize-urządzenia)
  • Mocowanie żądań TCP do usług innych firm (nock)
  • Korzystanie z bogatszych asercji (chai)
  • Zapisane odpowiedzi od innych firm (łatwa naprawa)

Niezbyt proste testy

Abstrakcja i rozszerzalność to kluczowe elementy budowania efektywnego zestawu testów integracyjnych. Wszystko, co usuwa fokus z rdzenia testu (przygotowanie danych, działanie i asercja) powinno być pogrupowane i wyodrębnione w funkcje użytkowe.

Chociaż nie ma tu dobrej ani złej ścieżki, ponieważ wszystko zależy od projektu i jego potrzeb, niektóre kluczowe cechy są nadal wspólne dla każdego dobrego zestawu testów integracyjnych.

Poniższy kod pokazuje, jak przetestować interfejs API, który tworzy przepis i wysyła wiadomość e-mail jako efekt uboczny.

Ogranicza zewnętrznego dostawcę poczty e-mail, dzięki czemu można sprawdzić, czy wiadomość e-mail zostałaby wysłana bez faktycznego jej wysyłania. Test sprawdza również, czy API odpowiedziało odpowiednim kodem statusu.

 const co = require('co'); const factory = require('factory'); const superTest = require('../utils/super_test'); const basicEnv = require('../utils/basic_test_enivornment'); const path = '/v1/admin/recipes'; basicEnv.test(`API POST ${path}`, co.wrap(function* (t, assert, sandbox) { const chef = yield factory.create('chef'); const body = { chef_id: chef.id, recipe_name: 'cake', Ingredients: ['carrot', 'chocolate', 'biscuit'] }; const stub = sandbox.stub(mockery.emailProvider, 'sendNewEmail').returnsPromise(null); const serverResponse = yield superTest.get(path, body); assert.spies(stub).called(1); assert.statusCode(serverResponse, 201); }));

Powyższy test jest powtarzalny, ponieważ za każdym razem zaczyna się od czystego środowiska.

Ma prosty proces konfiguracji, w którym wszystko związane z konfiguracją jest konsolidowane w funkcji basicEnv.test .

Testuje tylko jedną akcję - pojedyncze API. I jasno określa oczekiwania testu za pomocą prostych stwierdzeń asercyjnych. Ponadto test nie obejmuje kodu stron trzecich poprzez skrócenie/naśladowanie.

Zacznij pisać testy integracyjne

Wprowadzając nowy kod do produkcji, programiści (i wszyscy inni uczestnicy projektu) chcą mieć pewność, że nowe funkcje będą działać, a stare nie zepsują się.

Jest to bardzo trudne do osiągnięcia bez testowania, a jeśli zostanie wykonane źle, może prowadzić do frustracji, zmęczenia projektem i ostatecznie do niepowodzenia projektu.

Testy integracyjne połączone z testami jednostkowymi to pierwsza linia obrony.

Użycie tylko jednego z dwóch jest niewystarczające i pozostawia dużo miejsca na wykryte błędy. Zawsze korzystanie z obu tych elementów sprawi, że nowe zobowiązania będą solidne, zapewnią pewność siebie i wzbudzą zaufanie u wszystkich uczestników projektu.