Uwaga! Trwają prace nad nową wersją serwisu. Mogą występować przejściowe problemy z jego funkcjonowaniem. Przepraszam za niedogodności!
⛔ Potrzebujesz wsparcia? Oceny CV? A może Code Review? ✅ Dołącz do Discorda!
Poznaj podstawowe zagadnienia dotyczące testowania kodu asynchronicznego JavaScript. W tym artykule znajdziesz wyjaśnienie asercji, łańcuchowania obietnic (ang. Promises chaining) oraz zapisu funkcji asynchronicznych z .then i async...await. Wszystko to omówione na przykładzie frameworka Jest.
W przypadku kodu synchronicznego wynik np. działania danej metody otrzymujemy od razu. Nie ma więc kwestii oczekiwania na wykonanie operacji, możliwej różnej kolejności ich ukończenia czy obsługi błędów wynikających z komunikacji z serwerem. W testowaniu kodu asynchronego uwzględniamy wszystkie te rzeczy. Pomagają nam w tym narzędzia udostępniane przez frameworki do testowania, np. Jest.
Abyś nie musiał uruchamiać JSON Servera ani łączyć się z API (w fazie testów jest to zresztą niezalecane, bo nie tylko wyczerpuje się limit odpytań, ale przypadkowo można zmienić coś w bazie danych), to w naszym kodzie zasymulujemy asynchroniczność za pomocą metody setTimeout.
Stworzymy klasę Document, która umożliwia ustawianie i sprawdzanie ID „dokumentu”. Jest ona bardzo prosta po to, abyś nie musiał poświęcać wiele czasu na zrozumienie jej działania. Wystarczy ona do przedstawienia kilku koncepcji testowania kodu asynchronicznego.
export default class Document { // obiekt tworzony za pomocą tej klasy ma właściwość id constructor(id) { this.id = id; } getDocumentID(id) { return new Promise((resolve, reject) => { if (id === this.id) { this.async(() => { // gdy przekazane przez argument ID zgadza się z ID obiektu, zwracamy to ID resolve(this.id); }); } else { this.async(() => { // jeśli to ID się nie zgadza, rzucamy błędem reject('Provided ID does not exist.'); }); } }); } changeDocumentID(id) { return new Promise((resolve, reject) => { // jeśli ID zostało podane... if (id) { this.async(() => { // ustawiamy obiektowi nowe ID i wyświetlamy komunikat o sukcesie this.id = id; resolve('Document was successfully added.'); }); } else { this.async(() => { // w przeciwnym razie rzucamy błędem reject('Operation failed.'); }); } }); } // symulacja kodu asynchronicznego async(callback, ...params) { setTimeout(() => { callback(...params); }, Math.random() * 100); } }
W pliku z testami pamiętajmy o zaimportowaniu naszej klasy:
import Document from './Document';
Aby całość uruchomić u siebie:
Korzystamy z frameworka do testowania Jest, więc jeśli chcesz dowiedzieć się więcej, zajrzyj do dokumentacji o testowaniu kodu asynchronicznego.
Wskazówka: jeżeli w jednym momencie chcesz uruchamiać tylko jeden test, przed pozostałymi dodaj „x”, np. tak:
xtest('Does the document have an ID provided', async () => { // test code });
Dla rozgrzewki przetestujmy działanie funkcji getDocumentID. Jak ona działa? Jeśli obiekt posiada ID, o które pytamy, to zostanie ono zwrócone. Jeśli go nie posiada lub do metody przekażemy nieprawidłowe ID, to otrzymamy błąd: Document ID does not exist.
Przetestujmy na razie prawidłowe działanie tej funkcji (czyli bez testowania rzucania błędem – o tym za chwilę).
test('Does the document have an ID provided', () => { // ustalamy ID, które otrzyma dokument const id = 2; // tworzymy obiekt o tym ID na podstawie naszej klasy const doc = new Document(id); // wywołujemy testowaną metodę z prawidłowym ID const promise = doc.getDocumentID(id); // spodziewamy się, że ID obiektu jest takie, jakie ustawiliśmy podczas jego tworzenia return promise.then((documentID) => expect(documentID).toBe(id)); });
O czym warto pamiętać w tym zapisie? Na pewno o zwróceniu obietnicy (użycie „return”) – jest to dla frameworka testowego sygnałem, że obietnica została wykonana i że można już sprawdzić wynik jej działania. Bez tego wynik testu będzie fałszywie pozytywny, nawet jeśli w sprawdzeniu podamy błędne dane. Zaraz powiemy o tym więcej przy okazji asercji.
Trzeba też pamiętać, że to, co zwraca obietnica, jest dostępne jako argument callbacku w metodzie .then (tutaj nazwaliśmy sobie ten argument documentID).
test('Does the document have an ID provided', async () => { const id = 2; const doc = new Document(id); const documentID = await doc.getDocumentID(id); expect(documentID).toBe(id); });
Test ten działa identycznie. Różnica? Przed callbackiem w funkcji test umieściliśmy słówko „async”. Nie używamy też „return” – jego działanie zstąpiło nam słówko „await”. Jest to dla frameworka informacja, że w tym miejscu ma się spodziewać wartości zwróconej z asynchronicznej metody.
Test nie musi sprawdzać tylko jednej rzeczy (np. działania tylko jednej metody). Załóżmy, że chcemy zmienić ID naszego obiektu, a następnie upewnić się, że zostało ono prawidłowo zaktualizowane.
Należy więc:
Jeżeli chcemy, aby nasz kod został przetestowany dokładnie w powyższej kolejności i używamy zapisu z .then, test mógłby wyglądać tak:
test('Does document ID change', () => { expect.assertions(2); const newID = 5; const doc = new Document(2); const promise = doc.changeDocumentID(newID); return promise .then(message => expect(message).toBe('Document was successfully added.')) .then(() => doc.getDocumentID(newID)) .then(documentID => expect(documentID).toBe(newID)); });
Zwróć uwagę, że jeżeli funkcja coś zwraca i chcemy to wykorzystać, uwzględniamy to jako argument kolejnej metody .then. Pierwsza wartość otrzymana z metody changeDocumentID zwraca komunikat (nazwaliśmy go message). Wpisujemy więc message jako argument pierwszego użycia metody .then. Metoda ta tym razem nic nie zwraca (funkcja expect nic nie zwraca), więc argumentu do kolejnego .then nie mamy. Zachowujemy jednak kolejność wykonania kodu i teraz wywołujemy metodę getDocumentID. Tu znów mamy zwróconą wartość (documentID), więc wykorzystamy ją w kolejnej metodzie .then do sprawdzenia, czy nowe ID zostało prawidłowo ustawione.
Zapis nieco się uprości, jeśli użyjemy async…await. Każde await oznacza dla interpretera „poczekaj w tym miejscu na zakończenie operacji asynchronicznej”, więc nie musimy obawiać się, że nasze metody zakończą działanie w odwrotnej kolejności.
test('Does document ID change', async () => { expect.assertions(2); const newID = 5; const doc = new Document(2); const message = await doc.changeDocumentID(newID); expect(message).toBe('Document was successfully added.'); const documentID = await doc.getDocumentID(newID); expect(documentID).toBe(newID); });
Asercja to inaczej sprawdzenie. Przykładowo w teście powyżej sprawdzamy, czy wykonała się metoda ustawiająca nowe ID (1 asercja). W tym samym teście od razu wywołujemy metodę pobierającą ID i sprawdzamy, czy zgadza się ono z nowo ustawionym (2 asercja).
Ma to pomóc w dopilnowaniu, by wszystkie interesujące nas sprawdzenia (asercje) zostały wykonane. Gdyby nie zostały wykonane (np. przez nasze błędy), to test dostarczyłby fałszywych informacji, czyli przeszedł jako pozornie poprawny.
Poniżej zobaczysz kilka przykładów zastosowania metody expect.assertions() i przekonasz się, dlaczego warto ją stosować.
Kopiowanie wprowadza błędy. Tak też się może zdarzyć, gdy przekopiujemy test sprawdzający poprawne wprowadzenie danych i będziemy chcieli zrobić z niego test sprawdzający rzucenie błędem.
test('Does getDocumentID throw error with non-existent ID', async () => { const id = 2; const doc = new Document(id); try { await doc.getDocumentID(id); // błąd w teście. Zwróć uwagę, że testujemy rzucanie błędem, więc powinniśmy wprowadzić tu niepoprawne ID, np. 5 } catch (error) { expect(error).toMatch('Provided ID does not exist.'); } });
W powyższym przykładzie przypadkowo wywołaliśmy metodę getDocumentID z prawidłowym ID. W związku z tym wykonanie tej metody skończy się obietnicą rozwiązaną (spełniony zostanie warunek z metody: id === this.id) i błąd nigdy nie zostanie rzucony. W teście zatem nie będzie nic, co blok catch mógłby przechwycić. Test przejdzie, choć jest błędnie skonstruowany.
Zaznaczenie w teście, ilu asercji się spodziewamy, ochroni nas przed tym błędem:
test('Does getDocumentID throw error with non-existent ID', async () => { expect.assertions(1); const id = 2; const doc = new Document(id); try { await doc.getDocumentID(id); // błąd w teście. Zwróć uwagę, że testujemy rzucanie błędem, więc powinniśmy wprowadzić tu niepoprawne ID, np. 5 } catch (error) { expect(error).toMatch('Provided ID does not exist.'); } });
Spodziewamy się jednej asercji, ponieważ mamy tylko jedno sprawdzenie:
expect(error).toMatch('Provided ID does not exist.');
Błąd w konsoli poinformuje nas, że do tego sprawdzenia nie doszło:
Expected one assertion to be called but received zero assertion calls.
Dzięki temu wiemy, że coś z naszym testem poszło nie tak.
Jeżeli nie korzystamy z zapisu async…await, lecz z .then (co znajdziesz zwłaszcza w starszych projektach), należy pamiętać o zwróceniu obietnicy, o czym mówiliśmy już przy okazji pierwszego testu 'Does the document have an ID provided'.
Bez tego test przejdzie jako prawidłowy, ponieważ nigdy nie dokona się sprawdzenie: expect(id).toBe(id) – nawet wtedy, gdy się pomylimy! Zwrócenie obietnicy (użycie „return”) dla frameworka testowego jest sygnałem, że obietnica została wykonana i że można już sprawdzić wynik jej działania (wykonać asercję).
test('Does the document have an ID provided', () => { const id = 2; const doc = new Document(id); const promise = doc.getDocumentID(id); promise.then(id => expect(id).toBe(5)); // zapomnieliśmy o "return" i choć mamy błąd w teście (5 zamiast zmiennej id), to przechodzi on jako poprawny });
Uruchom test i się o tym przekonaj. Następnie dodaj expect.assertions(1) na początku testu. Otrzymasz pomocny komunikat:
Expected one assertion to be called but received zero assertion calls.
Aby „poinformować” interpreter, że obietnica została wykonana, możemy też skorzystać z innego sposobu. Zastosujemy funkcję done dostępną w Jest jako parametr callbacku funkcji testującej:
test('Does the document have an ID provided', done => { expect.assertions(1); const id = 2; const doc = new Document(id); const promise = doc.getDocumentID(id); promise.then(id => { expect(id).toBe(id); done(); // Wywołujemy done() dopiero po zakończeniu asynchronicznego kodu }); });
Jeśli funkcja done() nigdy nie zostanie wywołana, test zakończy się niepowodzeniem (z błędem przekroczenia limitu czasu), co poinformuje nas o nieprawidłowo stworzonym teście:
Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Timeout
Kiedy taka sytuacja może wystąpić? Na przykład wtedy, gdy z funkcji asynchronicznej zapomnimy zwrócić wartość, a zamiast tego zwrócimy obietnicę. Wówczas na jej rozwiązanie będziemy czekać „w nieskończoność”.
Przy okazji omawiania Promises chaining i testu 'Does document ID change' pojawiło się więcej sprawdzeń niż jedno. Im więcej sprawdzeń, tym łatwiej gdzieś się pomylić i uzyskać zafałszowany wynik testu.
Załóżmy, że pomyliliśmy w teście sposób sprawdzania zwracanej wartości – zamiast metody expect użyliśmy operatora porównania ścisłego. Pamiętaliśmy jednak zaznaczyć, że test ma mieć dwie asercje.
test('Does document ID change (with assertions)', () => { expect.assertions(2); const newID = 5; const doc = new Document(2); const promise = doc.changeDocumentID(newID); return promise .then(message => expect(message).toBe('Document was successfully added.')) .then(() => doc.getDocumentID(newID)) .then(documentID => documentID === newID); // pomyliliśmy sposób sprawdzania wartości });
Dzięki temu otrzymaliśmy błąd:
Expected two assertions to be called but received one assertion call.
Omówione tematy to zaledwie fragment zagadnienia testowania kodu asynchronicznego. Dzięki temu jednak lepiej zrozumiesz, w jaki sposób interpreter JS odczytuje kod testów. Możesz stosować zarówno zapis z .then, jak i z async…await – ten drugi jednak zwykle jest czytelniejszy. Twój zespół może też mieć w tym zakresie własne ustalenia. Powodzenia w testowaniu!
Udostępnij ten artykuł:
Potrzebujesz cotygodniowej dawki motywacji?
Zapisz się i zgarnij za darmo e-book o wartości 39 zł!
PS. Zazwyczaj rozsyłam 1-2 wiadomości na tydzień. Nikomu nie będę udostępniał Twojego adresu email.
Chcesz zostać (lepszym) programistą i lepiej zarabiać?
🚀 Porozmawiajmy o nauce programowania, poszukiwaniu pracy, o rozwoju kariery lub przyszłości branży IT!
Umów się na ✅ bezpłatną i niezobowiązującą rozmowę ze mną.
Chętnie porozmawiam o Twojej przyszłości i pomogę Ci osiągnąć Twoje cele! 🎯