Skocz do treści

Już wkrótce odpalamy zapisy na drugą edycję next13masters.pl. Zapisz się na listę oczekujących!

TDD w React.js z pomocą react-testing-library

Jak tworzyć komponenty w React.js zgodnie z TDD dzięki react-testing-library? Jak zamockować axios? Jak napisać testy odporne na refactoring? To i kilka innych sztuczek w artykule poniżej :)

Ten artykuł jest częścią 25 z 42 w serii React.js.

Zdjęcie Michał Baranowski
JavaScript6 komentarzy

Trzymanie się zasad TDD (Test-Driven Development) pisząc aplikacje po stronie front-endu w React.js może wydawać się trudniejsze niż testowanie kodu po stronie back-endu.

Musimy w jakiś sposób wyrenderować nasz komponent, zasymulować interakcje użytkownika z przeglądarką, reagować na zmiany propsów i stanu naszego komponentu, a na koniec jeszcze przetestować asynchroniczne metody wywołane przez na przykład kliknięcie w przycisk na stronie.

Aby pokryć te wszystkie scenariusze w naszych testach, dochodzi często do sytuacji, w których stają się one nieczytelne, jeden zależy od drugiego, mockujemy na potęgę i w rezultacie mamy testy napisane wg. antypatternów.

Szanuj swój czas

Z moich obserwacji dużo osób tworzy cały działający komponent i dopiero wtedy zabiera się za pisanie do niego testów, a następnie okazuje się, że nie da się przetestować go w obecnej implementacji i trzeba go przepisać. Tracimy na tym czas, cierpliwość i pieniądze pracodawcy.

Dostępne rozwiązania

Na nasze szczęście istnieje wiele bibliotek, które rozwiązują nam problem renderowania komponentu (np. Enzyme), mockowania odpowiedzi z servera (np. MockAxios), ale często mają nie do końca jasne API jak w przypadku tego pierwszego — czym do cholery różni się od siebie Shallow, Mount i Render i którego powinienem użyć?!?

O projekcie

Na potrzeby artykułu stworzymy małą aplikację, która po kliknięciu w przycisk będzie pobierała z zewnętrznego API losowy kawał, w którym główną rolę pełni Chuck Norris. Będziemy stopniowo pisać testy z pomocą react-testing-library, a następnie tworzyć komponent i starać się żeby testy przeszły.

Pisząc testy będziemy mieć w głowie to zdanie:

https://twitter.com/kentcdodds/status/977018512689455106

Zaczynamy

Projekt stworzymy z boilerplate create-react-app, Axios użyjemy do pobierania danych z zewnętrznego API, do uruchamiania testów Jest'a, do mockowania zewnętrznego API MockAxios, a do renderowania komponentów, triggerowania akcji i obsługi asynchronicznych metod react-testing-library — świetnej i ultra lekkiej biblioteki stworzonej przez cytowanego już wcześniej Kent C. Dodds.

Generujemy projekt z create-react-app wg. instrukcji, a następnie instalujemy dodatkowe zależności (do stworzenia projektu możemy użyć także CodeSandbox):

npm install axios
npm install --save-dev axios-mock-adapter react-testing-library

Struktura

Tworzymy podobną sktrukturę plików jak poniżej:

 - src
	 - __tests__
		 - jokeGenerator.test.js
	 - joke.js
	 - jokeGenerator.js
	 - index.js

Piszemy pierwszy test

Zaczniemy od napisania testu do komponentu Joke, którego funkcją będzie wyświetlenie tekstu przekazanego przez propsy (jokeGenerator.test.js):

test('Joke komponent otrzymuje propsy, a następnie renderuje text', () => {
  const { getByTestId } = render(<Joke text="The funniest joke this year." />);

  expect(getByTestId('joke-text')).toHaveTextContent('The funniest joke this year.');
});

Już tłumaczę co tu się dzieje. Idąc od góry widzimy funkcję render zaimportowaną z paczki react-testing-library. Przekazujemy do niej nasz jeszcze nie istniejący komponent.

Funkcja ta zwraca obiekt zawierający kilka przydatnych metod (pełna lista metod) min. getByTestId — zwraca nam element HTML przyjmując data-testid jako argument.

Czym jest data-testid? Jest to unikalny atrybut elementu na podstawie którego możemy napisać odpowiedni selektor HTML. Korzystając z tej metody możemy napisać expecta, który oczekuje, że innerHTML będzie równy "The funniest joke this year".

Dzięki data-testid nasze testy stają się odporne na refactoring ponieważ polegamy na wartościach, które w kodzie już raczej się nie zmienią. Należy jednak korzystać z tego z rozwagą, chcemy przecież aby nasz test odzwierciedlał to, jak użytkownik będzie z aplikacji korzystał. Dlatego najlepiej stosować data-testid, gdy metody getByText/queryByText zawiodą.

npm test

Uruchamiamy testy i widzimy:

Joke is not defined

Tego się spodziewaliśmy! Joke jeszcze nie istnieje, stworzyliśmy do tej pory tylko pusty plik joke.js. Napisaliśmy test, w którym jasno widać czego od komponentu oczekujemy. Teraz naszym zadaniem jest nie dotykając już testu sprawić, aby test przeszedł (joke.js):

export default ({ text }) => <div data-testid="joke-text">{text}</div>;

Po przeładowaniu testów jeśli zrobiłaś wszystko tak jak ja, test powinien przejść :)

Drugi komponent

Zadaniem drugiego komponentu będzie pobranie losowego kawału z API po kliknięciu w przycisk, zapisanie go w state komponentu i wyrenderowanie dzięki znanemu już nam Joke.

Startujemy oczywiście od napisania testu. Jest to większy komponent, zatem test będziemy pisać stopniowo i będziemy starali się żeby jak najczęściej był „zielony”.

test("Komponent 'JokeGenerator' pobiera randomowego suchara i go renderuje", async () => {
  const { getByText } = render(<JokeGenerator />);

  expect(getByText('Brak suchara')).toBeTruthy();
});

Widzimy znaną już nam funkcję render, tylko tym razem wyciągamy z niej getByText. Jak łatwo się domyśleć, zwraca nam element HTML z podanym przez nas tekstem jeśli takowy oczywiście istnieje.

Chuck Norris can overflow your stack just by looking at it.

Odświeżamy testy i mamy:

JokeGenerator is not defined

Wiemy co z tym zrobić:

export default class JokeGenerator extends React.Component {
  render() {
    return <div />;
  }
}

Rezultat:

Unable to find an element with the text: **Brak suchara**. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

Chcemy wyświetlić powyższy tekst gdy nie mamy w state żadnego kawału: Wiemy co z tym zrobić:

export default class JokeGenerator extends React.Component {
  state = {
    joke: null,
  };

  render() {
    const { joke } = this.state;

    return <React.Fragment>{!joke && <div>Brak suchara</div>}</React.Fragment>;
  }
}

Teraz chcę zasymulować kliknięcie w przycisk przez użytkownika i zobaczyć wiadomość, że mój kawał się ładuje, a domyślny tekst Brak Suchara znika. Użyjemy w tym celu metody Simulate.

import { render, Simulate } from 'react-testing-library';
Simulate.click(getByTestId('laduj-suchara'));

expect(queryByText('Brak suchara')).toBeNull();

expect(querybyText('Ładuję...')).not.toBeNull();

queryByText różni się od getByText tym, że ten pierwszy gdy nie znajdzie elementu zwraca null, a ten drugi rzuca błędem.

Po przeładowaniu testów:

Unable to find an element by: [data-testid="laduj-suchara"]

Tworzymy buttona i przy okazji metode która ustawi nam loading state na true.

export default class JokeGenerator extends React.Component {
  state = {
    joke: null,
    loading: false,
  };

  loadJoke = () => {
    this.setState({ loading: true });
  };

  render() {
    const { joke, loading } = this.state;

    return (
      <React.Fragment>
        {!joke && !loading && <div>Brak suchara</div>}
        {loading && <div>Ładuję...</div>}

        <button onClick={this.loadJoke} type="button" data-testid="laduj-suchara">
          Załaduj losowy kawał
        </button>
      </React.Fragment>
    );
  }
}

Testy elegancko przechodzą. Zamockujmy teraz odpowiedź z serwera używając MockAxios.

import MockAxios from 'axios-mock-adapter';

Zaraz nad pierwszym testem dopiszmy ten fragment kodu:

const mock = new MockAxios(axios, { delayResponse: Math.random() * 500 });

afterAll(() => mock.restore());

Na początku drugiego testu, w którym testujemy JokeGenerator dodajmy:

mock.onGet().replyOnce(200, {
  value: {
    joke: 'Really funny joke!',
  },
});

A na końcu tego samego testu:

await wait(() => expect(queryByText('Ładuję...')).toBeNull());

expet(queryByTestId('joke-text')).toBeTruthy();

Metoda wait (importujemy ją tak samo jak Simulate i render) czeka (domyślnie 4500ms) na callbacka dopóki ten nie przestanie zwracać erroru. Interwał w jakim sprawdzane jest wyrażenie w callbacku to domyślnie 50ms.

Dzięki tej metodzie możemy testować min. asynchroniczne działania w naszej aplikacji.

Co ciekawe wait dostępne jest jako oddzielna paczka (react-testing-library z tej paczki korzysta). Stworzył ją Łukasz Gandecki z The Brain Software House.

Po tych modyfikacjach powinniśmy dostać taki błąd:

Expected value to be truthy, instead received:
null

Aby nasz test zaczął ponownie przechodzić musimy zmodyfikować naszą metode loadJoke:

loadJoke = async () => {
  this.setState({ loading: true });

  const {
    data: {
      value: { joke },
    },
  } = await axios.get('https://api.icndb.com/jokes/random');

  this.setState({ loading: false, joke });
};

oraz wyrenderować nasz kawał przy użyciu Joke:

{
  joke && !loading && <Joke text={joke} />;
}

Test powinien ponownie zrobić się zielony, a my mamy pewność, że wszystko działa.

Zauważcie, że jeszcze ani razu nie otworzyliśmy przeglądarki i nie przetestowaliśmy tego ręcznie, ale dzięki temu w jak pisaliśmy testy (w sposób w jaki użytkownik normalnie korzysta z aplikacji) mamy 100% pewność, że nasza mała aplikacja po prostu działa.

Dodajmy na koniec JokeGenerator do index.js i odpalmy przeglądarkę:

const App = () => (
  <div style={styles}>
    <JokeGenerator />
  </div>
);

Bonus

Sposób w jaki napisaliśmy nasze testy umożliwia nam wykorzystanie ich jako testów e2e bez dodawania ani jednej linijki kodu.

Wystarczy, że zakomentujemy fragmenty kodu odpowiedzialne za mockowanie Axios'a i gotowe! Uruchom teraz testy, a będą korzystać z prawdziwego API.

Podsumowanie

W razie problemów kod całego projektu dostępny jest na CodeSandbox.

Zachęcam do zapoznania się z pełną dokumentacją react-testing-library. Mamy do dyspozycji więcej metod do znajdywania elementów w naszym wirtualnym DOM-ie, zwracania wartości tekstu z elementu itd.

Mam nadzieję, że dzięki mnie czegoś się dzisiaj nauczyliście i wykorzystacie parę technik w Waszych projektach.

👉  Znalazłeś/aś błąd?  👈Edytuj ten wpis na GitHubie!

Autor