Jak efektywnie testować kod napisany za pomocą React?
Na blogu wielokrotnie poruszałem temat testowania kodu aplikacji. Pisałem o tym jak testować kod JS oraz o tym dlaczego testowanie kodu jest ważne. W dzisiejszym wpisie poruszę temat związany z testowaniem kodu napisanego przy pomocy biblioteki React.
Na początek postawmy sobie pytanie, czym testować kod JavaScript, który napisaliśmy? Jest wiele różnych frameworków testowych: Mocha, Karma, Jasmine, QUnit, itd., itp. Wszystkie spełniają swoje założenia, czyli można mniej lub bardziej wygodnie pisać kod testów jednostkowych aplikacji.
Ale jak wygląda sytuacja w przypadku komponentów napisanych za pomocą React? Okazuje się, że z pomocą przychodzi narzędzie Jest oraz zestaw narzędzi Enzyme. Wspólnie połączone zapewniają kompleksowe rozwiązanie, które znacząco ułatwia testowanie kodu JavaScript, nie tylko bazującego na React.
Jest - narzędzie do uruchamiania testów kodu React
Z pomocą Jest jesteśmy w stanie łatwo przygotować środowisko uruchomieniowe do testów kodu JavaScript. Lista zalet tego narzędzia zawiera następujące punkty:
- Bardzo łatwa konfiguracja środowiska,
- Bardzo szybkie działanie i otrzymywanie informacji nt. testów,
- Testowanie bazujące na zrzutach stanu aplikacji, dzięki czemu jesteśmy w stanie wykryć czy zmiany mają swoje odzwierciedlenie w wyrenderowanym widoku,
- Wbudowane narzędzie analizujące pokrycie kodu testami,
- Jest wbudowany w narzędzie create-react-app, znacząco ułatwiające pisanie kodu React,
- Łatwa integracja z Babel.js,
- Współpracuje z TypeScript,
- Zawiera narzędzia do mockowania funkcji i modułów.
Jak widać lista zalet jest dość pokaźna. Co utwierdziło mnie, aby dać mu szansę w swoich projektach. W przypadku, gdyby istniała potrzeba przeniesienia istniejących testów napisanych przy pomocy frameworków: AVA, Chai, Mocha, Jasmine, Tape, itd., itp., to można skorzystać z opcji przeniesienia ich do Jest. Tego akurat nie przetestowałem w praktyce.
Enzyme - zestaw narzędzi ułatwiających pisanie testów JS
Enzyme z kolei jest zestawem narzędzi dodatkowych, które ułatwiają testowanie aplikacji Reactowych. Z jego pomocą można renderować komponenty na potrzeby testów. Komponenty można wyrenderować na kilka sposobów:
- shallow - renderowanie komponentu bez potrzeby renderowania komponentów-dzieci znajdujących się wewnątrz testowanego komponentu,
- mount - renderowanie komponentu w pełni, czyli z pełnym drzewem DOM, ale wciąż posiadającym referencje do React,
- static/render - rezultat jest zbliżony do tego co można osiągnąć za pomocą mount. Różnica polega na tym, że drzewo DOM nie zawiera żadnych odniesień do React.
Jeśli powyższe zestawienie wydaje Ci się enigmatyczne, to spróbuję to opisać następującymi słowami:
- Zawsze renderuj w teście za pomocą metody
shallow()
, - Jeśli chcesz przetestować metody związane z cyklem życia komponentu, np.:
componentDidMount
lubcomponentDidUpdate
, to użyjmount()
, - Jeśli Ciebie interesuje przetestowanie samego kodu HTML, to użyj
render()
.
Instalacja Jest i Enzyme
Instalacja jest bardzo prosta. Sprowadza się do wpisania kilku komend w terminalu systemowym. Są to:
yarn add jest --dev yarn add enzyme --dev yarn add react-test-renderer enzyme-to-json babel-jest --dev
Pierwsza z nich zainstaluje Jest, druga: Enzyme, a trzecia pozostałe przydatne dodatki, które sprawdzą się w testowaniu kodu JS. Tak naprawdę, można byłoby te 3 komendy złączyć w jedną całość (na wzór trzeciej komendy), ale chciałem zachować separację dla większej czytelności.
Piszemy pierwszy test kodu napisanego w React
Gdy mamy już zainstalowane wszystkie wymagane narzędzia, możemy przystąpić do napisania naszego pierwszego testu. W teście będziemy sprawdzać działanie komponentu, który zawiera w sobie komponent podrzędny. Będzie to popup, który posiada w sobie przyciski z akcjami. Przykładowy kod wygląda następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | import React, { Component } from 'react'; export default class Popup extends Component { constructor(props) { super(props); this.state = {visible: !!props.visible}; } componentWillReceiveProps(props) { this.setState(state => Object.assign({}, state, {visible: props.visible})); } renderExtraAction(action) { const attrs = Object.assign({}, { className: `c-popup__action--${action.id}` }, action.attrs); return <button key={action.id} {...attrs}>{action.label}</button>; } render() { const attrs = {hidden: !this.state.visible}; return ( <div className="c-popup" {...attrs}> <div className="c-popup__title">{this.props.title}</div> <div className="c-popup__content">{this.props.children}</div> <div className="c-popup__actions"> {this.props.extraActions.map(this.renderExtraAction.bind(this))} <button className="c-popup__action--close" onClick={this.props.onHide}>X</button> <button className="c-popup__action--cancel" onClick={this.props.onCancel}>Cancel</button> <button className="c-popup__action--confirm" onClick={this.props.onConfirm}>Confirm</button> </div> </div> ); } } |
Komponent Popup
jest bardzo prosty w użyciu. Przyjmuje 6 propsów zdefiniowanych w komponencie:
visible
- flagę informującą o widoczności popupu,title
- tytuł popupu,onHide
- funkcję wywoływaną w momencie zamykania popupu,onCancel
- funkcję wywoływaną w momencie zaniechania akcji proponowanej w popupie,onConfirm
- funkcję wywoływaną w momencie potwierdzenia akcji proponowanej w popupie,extraActions
- tablicę obiektów, które zawierają definicje dodatkowych akcji dostępnych w popupie.
Elementy podrzędne są renderowane w kontenerze o klasie .c-popup__content
. Elementy zagnieżdżone w tagu
są dostępne w kodzie komponentu za pomocą this.props.children
.
Przykładowy test z wykorzystaniem Enzyme
Gdy już wiemy co chcemy testować, to możemy się zająć pisaniem pierwszego testu. Lecz zanim przejdziemy do tej czynności warto sobie przyswoić informacje dotyczące przyjętych konwencji nazewnictwa plików. Domyślnie, Jest oczekuje plików, które mają w swoich nazwach .spec.js
lub .test.js
. Innym rozwiązaniem jest trzymanie plików z testami w folderze o nazwie __tests__
. Jaką konwencję wybierzesz, to już zależy od Ciebie lub od Twojego zespołu. Z mojej strony dodam jeszcze jedną sugestię. Warto kod rozbijać per component - i testy trzymać w katalogu komponentu, a nie w zbiorczym folderze na testy. Łatwiej jest przenosić wtedy kod do innych projektów oraz ścieżki importu modułów JS ulegną znacznemu skróceniu.
Przejdźmy teraz do naszego pierwszego testu. Aby zbytnio nie komplikować sobie życia, nasz pierwszy test będzie prosty. Sprawdzi czy komponent się poprawnie wyrenderował i czy jest zgodny z zapisanym stanem:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import React from 'react'; import { shallow } from 'enzyme'; import serializer from 'enzyme-to-json/serializer'; import Popup from './popup'; expect.addSnapshotSerializer(serializer); describe('Popup', () => { it('renders without crashing', () => { expect.hasAssertions(); expect.assertions(1); const popup = shallow(<Popup />); expect(popup).toMatchSnapshot(); }); }); |
Na wstępie warto wspomnieć, żeEnzyme korzysta z funkcjonalności Jasmine, więc jeśli miałeś/miałaś styczność z tym frameworkiem testowym, to będziesz czuć się jak w domu. Wracając do testu powyżej, nasz test składa się z kilku części:
- Lista importowanych modułów,
- Zestawu testów dotyczących komponentu opisanego za pomocą
describe()
, - Poszczególnych test case'ów, opisanych za pomocą
it()
.
Dodatkową rzeczą, którą określiliśmy w naszym teście jest wykorzystanie toJson()
pochodzącego z pakietu enzyme-to-json
. W naszym teście, gdy wykorzystamy jedną z metod: shallow()
, mount()
lub render()
, to domyślnie będą zwracały obiekt JSON, który będzie można przetestować ze snapshotem wykonanym przez Jest, a to dzięki temu kawałkowi kodu: expect.addSnapshotSerializer(serializer);
.
5 przykładów jak testować kod React z pomocą Jest i Enzyme
W tej części tekstu przedstawię kilka sposobów na testowanie specyficznych przypadków. Warto je wszystkie znać, aby usprawnić pisanie testów. Będą to snippety kodu wraz z krótkim wyjaśnieniem.
Szukanie konkretnego komponentu-dziecka
1 2 3 4 | const popup = shallow(<Popup><ExtraContent /></Popup>); const extraContent = popup.find(ExtraContent); expect(extraContent).toHaveLength(1); |
Framework Enzyme udostępnia metodę find()
, której możemy użyć do znalezienia konkretnego węzła drzewa DOM. Może to być tag HTML lub tag komponentu.
Szukanie komponentu ze specyficznymi propsami
1 2 3 4 5 6 7 8 9 10 11 | const sampleProp = 100; const attrs = { extraActions: [{ id: 'extra', label: 'Extra', attrs: {sampleProp} }] }; const popup = shallow(<Popup {...attrs} />); const buttons = popup.find('button'); const specificButton = buttons.findWhere(n => n.prop('sampleProp') === sampleProp); |
W tym przypadku wykorzystaliśmy metodę findWhere()
, która odnajduje element spełniający warunki określone w callbacku przekazanym do funkcji. Warto zwrócić uwagę na metodę prop()
, która pozwala na dotarcie do wartości konkretnego propsa.
Testowanie zdarzeń w drzewie DOM
1 2 3 4 5 6 7 8 | const attrs = { confirm: () => {} }; const popup = shallow(<Popup {...attrs} />); popup.find('.c-popup__action--confirm').simulate('click'); expect(attrs.confirm).toHaveBeenCalled(); |
W przypadku powyżej szukamy przycisku Confirm i symulujemy kliknięcie, w następstwie czego oczekujemy, że zostanie uruchomiona akcja zdefiniowana w attrs.confirm
.
Testowanie kliknięcia w zablokowany przycisk
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | const action = { id: 'extra', label: 'Extra', attrs: { disabled: true, onClick: () => {} } }; const attrs = {extraActions: [action]}; const popup = mount(<Popup {...attrs} />); popup.find(`.c-popup__action--${action.id}`).simulate('click'); expect(action.onClick).not.toHaveBeenCalled(); |
W powyższym kodzie jest kilka rzeczy na które trzeba zwrócić uwagę. Po pierwsze, zamiast shallow()
użyto mount()
do wyrenderowania komponentu. Gdybyśmy wykorzystali shallow()
to wtedy nasz test by się nie powiódł, ponieważ przycisk byłby klikalny. Jednakże, dzięki zastosowaniu metody mount()
mogliśmy wyrenderować pełne drzewo DOM. Dzięki temu, przycisk będzie zachowywał się jak zablokowany przycisk.
Drugą rzeczą wartą uwagi jest użycie przedrostka .not
w asercji sprawdzającej uruchomienie funkcji: expect(action.onClick).not.toHaveBeenCalled();
. W ten sposób możemy tworzyć negację w asercjach i sprawdzać czy dana akcja nie miała miejsca.
Jak rozwiązać problem z uruchamianiem testów na MacOS Sierra?
Na koniec, chciałbym podsunąć rozwiązanie na problem związany z uruchamianiem testów Jest na komputerach z zainstalowanym systemem MacOS Sierra (i być może na innych też). Do poprawnego działania narzędzia wymagany jest pakiet watchman z repozytoriów narzędzi dla systemów MacOS. Bez niego, Jest nie jest w stanie uruchomić watchera plików z testami i jest rzucany błąd w konsoli.
Pakiet watchman może być zainstalowany za pomocą następującej komendy:
brew install watchman
Po zainstalowaniu pakietu, problem z uruchamianiem testów powinien zniknąć. Szerzej na ten temat opisano w jednym z ticketów na Githubie.
Podsumowanie
Mam nadzieję, że z pomocą tego tekstu będziesz w stanie bez przeszkód rozpocząć testowanie kodu komponentów napisanych za pomocą React. Mam świadomość, że w tym tekście nie wyczerpałem tematu testowania kodu napisanego za pomocą wcześniej wymienionej biblioteki. Jeśli będzie taka potrzeba z Twojej strony, to postaram się przygotować bardziej zaawansowane przypadki, które wymagają bardziej indywidualnego podejścia do testowania kodu, np. mockowanie API czy obsługa JS Promise API.
Zapraszam do komentowania i udostępniania tekstu wśród znajomych. Wierzę, że wiedza tutaj zawarta się przyda wielu osobom.