Testowanie komponentów w praktyce

Testowanie komponentów jest dobrym punktem wyjścia do prezentacji praktycznego kodu testowania. Testy komponentów są bardziej szczegółowe niż proste testy jednostkowe, ale mniej złożone niż testy kompleksowe i pozwalają na interakcję z DOM. Z perspektywy filozoficznej korzystanie z Reacta ułatwiło deweloperom stron internetowych postrzeganie witryn i aplikacji internetowych jako zbioru komponentów.

Dlatego testowanie poszczególnych komponentów, niezależnie od ich złożoności, to dobry sposób na rozpoczęcie testowania nowej lub już opublikowanej aplikacji.

Na tej stronie znajdziesz instrukcje testowania małego komponentu o skomplikowanych zależnościach zewnętrznych. Łatwo przetestować komponent, który nie wchodzi w interakcję z innym kodem, np. klikając przycisk i potwierdzając, że liczba wzrasta. W rzeczywistości tak niewiele jest kodu, a testowanie kodu bez interakcji może mieć ograniczoną wartość.

Komponent będący przedmiotem testu

Do testowania komponentu React używamy Vitest i jego środowiska JSDOM. Dzięki temu możemy szybko uruchamiać testy za pomocą Node w wierszu poleceń podczas emulowania przeglądarki.

lista nazw z przyciskiem Wybierz obok każdej z nich.
Mały komponent React, który wyświetla listę użytkowników z sieci.

Ten komponent React o nazwie UserList pobiera z sieci listę użytkowników i pozwala wybrać jednego z nich. Lista użytkowników jest uzyskiwana za pomocą funkcji fetch w ramach useEffect, a obsługa wyboru jest przekazywana przez Context. Oto kod:

import React, { useEffect, useState, useContext } from 'react';
import { UserContext } from './UserContext.tsx';
import { UserRow } from './UserRow.tsx';

export function UserList({ count = 4 }: { count?: number }) {
  const [users, setUsers] = useState<any[]>([]);
  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users?_limit=' + count)
      .then((response) => response.json())
      .then((json) => setUsers(json));
  }, [count]);

  const c = useContext(UserContext);
  return (
    <div>
      <h2>Users</h2>
      <ul>
        {users.map((u) => (
          <li key={u.id}>
            <button onClick={() => c.userChosen(u.id)}>Choose</button>{' '}
            <UserRow u={u} />
          </li>
        ))}
      </ul>
    </div>
  );
}

Ten przykład nie pokazuje sprawdzonych metod dotyczących Reacta (np. używa fetch wewnątrz useEffect), ale Twój kod źródłowy prawdopodobnie zawiera wiele takich przypadków. Co więcej, na pierwszy rzut oka te przypadki mogą wydawać się uporczywe. W kolejnych częściach tego kursu omówimy szczegółowo pisanie kodu testowalnego.

W tym przykładzie testujemy następujące elementy:

  • Sprawdź, czy w odpowiedzi na dane z sieci jest tworzony prawidłowy DOM.
  • Sprawdź, czy kliknięcie użytkownika powoduje połączenie zwrotne.

Każdy komponent jest inny. Co sprawia, że testowanie tego rozwiązania jest interesujące?

  • Wykorzystuje globalny fetch, aby żądać z sieci rzeczywistych danych, które w fazie testów mogą być niestabilne lub wolne.
  • Importuje inną klasę, UserRow, której możemy nie chcieć testować.
  • Używa ona elementu Context, który nie jest częścią testowanego kodu i zwykle jest udostępniany przez komponent nadrzędny.

Na początek napisz krótki test

Możemy szybko przetestować ten komponent pod kątem podstawowych funkcji. Pamiętaj, że ten przykład nie jest zbyt przydatny. Warto jednak skonfigurować szablon w pliku peer o nazwie UserList.test.tsx (pamiętaj, że uruchamiacze testów, takie jak Vitest, domyślnie uruchamiają pliki, których nazwy kończą się na .test.js lub podobnie, w tym .tsx):

import { vi, test, assert, afterAll } from 'vitest';
import { render } from '@testing-library/react';
import { UserList } from './UserList.tsx';
import React, { ContextType } from 'react';

test('render', async () => {
  const c = render(<UserList />);

  const headingNode = await c.findAllByText(/Users);
  assert.isNotNull(headingNode);
});

Ten test sprawdza, czy podczas renderowania komponent zawiera tekst „Użytkownicy”. Działa nawet wtedy, gdy ma efekt uboczny polegający na wysyłaniu do sieci fetch. Na koniec testu projekt fetch nadal trwa i nie ma ustawionego punktu końcowego. Nie możemy potwierdzić, że po zakończeniu testu wyświetlane są jakiekolwiek informacje o użytkownikach, przynajmniej nie bez oczekiwania na przekroczenie limitu czasu.

Mock fetch()

Próba polega na zastąpieniu rzeczywistej funkcji lub klasy czymś, co pod Twoją kontrolą na potrzeby testu. Jest to powszechna praktyka w przypadku prawie wszystkich typów testów, z wyjątkiem najprostszych testów jednostkowych. Więcej informacji znajdziesz w sekcji Oświadczenia i inne prymitywy.

Możesz utworzyć symulację fetch() na potrzeby testu, aby przebiegał on szybko i zwracał oczekiwane dane, a nie „rzeczywiste” lub nieznane dane. fetch ma charakter globalny, co oznacza, że nie trzeba dodawać jej import ani require do kodu.

W vitest możesz zasymulować zmienną globalną, wywołując funkcję vi.stubGlobal z specjalnym obiektem zwracanym przez funkcję vi.fn(). Dzięki temu możesz później zmodyfikować symulację. Te metody omówimy bardziej szczegółowo w późniejszej sekcji tego kursu, ale możesz je zobaczyć w akcji w tym kodzie:

test('render', async () => {
  const fetchMock = vi.fn();
  fetchMock.mockReturnValue(
    Promise.resolve({
      json: () => Promise.resolve([{ name: 'Sam', id: 'sam' }]),
    }),
  );
  vi.stubGlobal('fetch', fetchMock);

  const c = render(<UserList />);

  const headingNode = await c.queryByText(/Users);
  assert.isNotNull(headingNode);

  await waitFor(async () => {
    const samNode = await c.queryByText(/Sam);
    assert.isNotNull(samNode);
  });
});

afterAll(() => {
  vi.unstubAllGlobals();
});

Ten kod dodaje mockup, opisuje fałszywą wersję sieci fetchResponse, a potem czeka na jej pojawienie się. Jeśli tekst się nie pojawi (możesz to sprawdzić, zmieniając nazwę zapytania w queryByText na nową). Test zakończy się niepowodzeniem.

W tym przykładzie użyto wbudowanych narzędzi do emulacji Vitest, ale inne frameworki testowe mają podobne podejście do emulacji. Vitest jest wyjątkowy, ponieważ po wszystkich testach musisz wywołać funkcję vi.unstubAllGlobals() lub ustawić odpowiednią opcję globalną. Bez „odwracania” tego, co zrobiliśmy, mock fetch może wpływać na inne testy, a na każdą prośbę będzie odpowiadać za pomocą naszej dziwnej sterty danych JSON.

Importy testowe

Zauważysz, że komponent UserList sam importuje komponent o nazwie UserRow. Nie dołączyliśmy kodu, ale możesz zobaczyć, że renderuje on imię i nazwisko użytkownika: poprzedni test sprawdza, czy jest to „Sam”, a nie jest on renderowany bezpośrednio w obiekcie UserList, więc musi pochodzić z UserRow.

Schemat blokowy tego, jak nazwy użytkowników przechodzą w komponencie.
UserListTest nie ma widoczności UserRow.

UserRow może jednak być złożonym komponentem, który może pobierać dodatkowe dane użytkownika lub mieć efekty uboczne nieistotne dla naszego testu. Usunięcie tej zmienności sprawia, że testy są bardziej przydatne, zwłaszcza gdy komponenty, które chcesz przetestować, stają się bardziej złożone i powiązane ze swoimi zależnościami.

Na szczęście możesz użyć Vitest do symulowania niektórych importów, nawet jeśli test nie używa ich bezpośrednio. Dzięki temu każdy kod, który ich używa, będzie zawierać prostą lub znaną wersję:

vi.mock('./UserRow.tsx', () => {
  return {
    UserRow(arg) {
      return <>{arg.u.name}</>;
    },
  }
});

test('render', async () => {
  // ...
});

To jak naśmiewanie się z globalnego fetch, jest to zaawansowane narzędzie, ale jego działanie może okazać się nieskuteczne, jeśli Twój kod będzie zawierać wiele zależności. Ponownie, najlepszym rozwiązaniem jest napisanie kodu, który można przetestować.

Kliknij i podaj kontekst

React i inne biblioteki takie jak Lit mają pojęcie Context. Przykładowy kod zawiera funkcję UserContext, która wywołuje metodę, jeśli wybrano użytkownika. Jest to często stosowana alternatywa dla „prop drilling”, w której wywołanie zwrotne jest przekazywane bezpośrednio do UserList.

Nasze narzędzia testowe nie udostępniają UserContext. Dodanie do testu React działania kliknięcia bez tego typu działania może w najgorszym przypadku spowodować jego zablokowanie. W najlepszej sytuacji, jeśli instancja domyślna została udostępniona w innym miejscu, może to spowodować skrajne zachowanie niezgodne z naszą kontrolą (podobne do nieznanego działania UserRow powyżej).

  const c = render(<UserList />);
  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();

Zamiast tego podczas renderowania komponentu możesz podać własny Context. Ten przykład używa instancji vi.fn(), która jest Mock Function w Vitest. Można jej użyć do sprawdzenia, czy wywołanie zostało wykonane i jakich argumentów użyto.

W naszym przypadku funkcja ta współdziała z udaremnioną funkcją fetch w poprzednim przykładzie, a test może potwierdzić, że przekazany identyfikator to sam:

  const userChosenFn = vi.fn();
  const ucForTest: ContextType<typeof UserContext> = { userChosen: userChosenFn as any };
  const c = render(
    <UserContext.Provider value={ucForTest}>
      <UserList />
    </UserContext.Provider>,
  );

  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();
  assert.deepStrictEqual(userChosenFn.mock.calls, [['sam']]);

To prosty, ale skuteczny wzór, który pozwala usunąć nieistotne zależności z głównego komponentu, który próbujesz przetestować.

W skrócie

Ten przykład pokazuje, jak utworzyć test komponentu, aby przetestować i chronić komponent React, który trudno jest przetestować. Ten test sprawdzał, czy komponent prawidłowo współpracuje ze swoimi zależnościami: modułem globalnym fetch, zaimportowanym podkomponentem i elementem Context.

Sprawdź swoją wiedzę

Jakie podejścia zostały użyte do przetestowania komponentu React?

Wstrzykiwanie zależności za pomocą kontekstu
Eksplorowanie złożonych zależności za pomocą prostych zależności testowych
Sprawdzanie, czy liczba jest zwiększana
Wywoływanie zmiennych globalnych