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:
The more your tests resemble the way your software is used, the more confidence they can give you.
— Kent C. Dodds (@kentcdodds) March 23, 2018
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.
