Uwaga! Trwają prace nad nową wersją serwisu. Mogą występować przejściowe problemy z jego funkcjonowaniem. Przepraszam za niedogodności!

🔥 Zgarnij PŁATNY STAŻ w 3 edycji kursu programowania front end (zapisy do 22.01) 🔥

Testowanie kodu asynchronicznego w JavaScript

Wyjaśnienie podstawowych zagadnień

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.

Spis treści

 Testowanie kodu synchronicznego i asynchronicznego – różnica

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.

 Kod asynchroniczny do testowania

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.

Uwaga: w testowaniu kodu asynchronicznego, by nie łączyć się z bazą danych czy API, wykorzystujemy tzw. mocki, czyli „atrapy” naśladujące zachowanie funkcjonalności. Jest to jednak temat bardziej zaawansowany, wykraczający poza zakres tematyczny tego artykułu, dlatego dziś nie będziemy go omawiać.

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:

  • pobierz repozytorium async-code-testing,
  • zainstaluj zależności – w terminalu wpisz komendę npm i,
  • uruchom testowanie za pomocą komendy npm test.

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
});

 Prosty test kodu asynchronicznego

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ę).

 Zapis z .then

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).

 Zapis z async…await

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.

 Łańcuchowanie obietnic (ang. Promises chaining)

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:

  • wywołać metodę changeDocumentID,
  • sprawdzić, czy otrzymaliśmy odpowiedni komunikat (Document was succesfully added),
  • wywołać metodę getDocumentID,
  • sprawdzić, czy ID jest zgodne z tym nowo przez nas nadanym.

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);
});
Uwaga: jeśli w takim teście będziemy mieć błąd, to nie wszystkie sprawdzenia mogą się wykonać i test przejdzie jako pozornie poprawny. Aby się przed tym zabezpieczyć, korzystamy z weryfikacji liczby sprawdzeń (asercji). O asercjach dla tego przykładu przeczytasz niżej w sekcji Więcej sprawdzeń (asercji) w jednym teście.

 expect.assertions() – wyjaśnienie metody i przykłady użycia

 Ile sprawdzeń (asercji) ma test

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ć.

 Testowanie rzucenia błędem w metodach asynchronicznych

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.

 Brak „return”, czyli brak zwrócenia obietnicy

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ść”.

 Więcej sprawdzeń (asercji) w jednym teście

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ł:

Mentoring to efektywna nauka pod okiem doświadczonej osoby, która:

  • przekazuje Ci swoją wiedzę i nadzoruje Twoje postępy w zdobywaniu umiejętności,
  • uczy Cię dobrych praktyk i wyłapuje złe nawyki,
  • wspiera Twój rozwój i zwiększa zaangażowanie w naukę.

Mam coś dla Ciebie!

W każdy piątek rozsyłam motywujący do nauki programowania newsletter!

Dodatkowo od razu otrzymasz ode mnie e-book o wartości 39 zł. To ponad 40 stron konkretów o nauce programowania i pracy w IT.

PS Zazwyczaj wysyłam 1-2 wiadomości na tydzień. Nikomu nie będę udostępniał Twojego adresu e-mail.