Robin Wieruch氏によるHow to use React Testing Library Tutorialを著者の許可を得て意訳しました。
誤りやより良い表現などがあればご指摘頂けると助かります。
原文: https://www.robinwieruch.de/react-testing-library
Kent C. Dodds氏によるReact Testing Library (RTL)がAirbnbのEnzymeに取って代わるものとしてリリースされました。EnzymeはReact開発者にReactコンポーネント内部をテストするためのユーティリティを提供しますが、React Testing Libraryは一歩さがって、「Reactコンポーネントを完全に信頼するためにはどうテストすべきか」を問いかけます。コンポーネントの実装の詳細をテストするのではなく、React Testing Libraryは開発者をReactアプリケーションの利用者として扱います。
このReact Testing Libraryチュートリアルでは、Reactコンポーネントを抜かりなくユニットテストおよび結合テストするために必要なすべての手順を解説していきます。
Jest 対 React Testing Library
React初心者は、Reactのテストツールを混同しがちです。**React Testing LibraryはJestの代わりにはなりません。**相互に依存し、それぞれが明確な担当領域を持つためです。
モダンなReact開発において、Jestによるテストを避けることはできません。JavaScriptアプリケーションのための最も人気があるテストフレームワークだからです。テストランナー(テスト用スクリプトをpackage.jsonに設定したらnpm testで実行可能)であることのほか、Jestはテストに役立つ下記のような機能を提供します。
describe('my function or component', () => {
test('does the following', () => {
});
});
describeブロックはテストスイートであるのに対し、testブロック(testの代わりにitを使うことも可能)はテストケースになります。テストスイートは複数のテストケースを持ちますが、テストケースは必ずテストスイートに含まれるわけではありません。テストケース内に記述するものはアサーション(例. Jestのexpect)と呼ばれ、成功(緑)か失敗(赤)のどちらかになります。ここに2つのアサーションがあり、どちらも成功します。
describe('true is truthy and false is falsy', () => {
test('true is truthy', () => {
expect(true).toBe(true);
});
test('false is falsy', () => {
expect(false).toBe(false);
});
});
テストスイートと、アサーションを含むテストケースをtest.jsファイルに含めると、npm test実行時にJestが自動で識別してくれます。testコマンドを実行すると、Jestのテストランナーは標準でtest.jsのサフィックスが付いた全てのファイルにマッチします。このマッチングパターンと他の設定はカスタムのJest設定ファイルで行います。
create-react-appを使用している場合、Jest(とReact Testing Library)は標準でインストールされます。Reactを自前でセットアップした場合は、Jest(とReact Testing Library)のインストールとセットアップも行う必要があります。
npm test(もしくはpackage.json内で使用しているスクリプトなどでも可能)でJestのテストランナーを使ってテストを実行すると、先ほどの2つのテストに対して下記のように出力されます。
PASS src/App.test.js
true is truthy and false is falsy
✓ true is truthy (3ms)
✓ false is falsy
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 2.999s
Ran all test suites related to changed files.
Watch Usage
› Press a to run all tests.
› Press f to run only failed tests.
› Press q to quit watch mode.
› Press p to filter by a filename regex pattern.
› Press t to filter by a test name regex pattern.
› Press Enter to trigger a test run.
全て緑になるテストを実行すると、Jestはインタラクティブなインターフェースを提供し、そこからさらに追加の指示を与えることができます。しかし、多くの場合で期待されるのはただ1つ、全てのテストに対して緑になることでしょう。ファイルを更新すると、それがソースコードであろうとテストであろうと、再度全てのテストが実行されます。
function sum(x, y) {
return x + y;
}
describe('sum', () => {
test('sums up two values', () => {
expect(sum(2, 4)).toBe(6);
});
});
実際のJavaScriptプロジェクトでは、テスト対象となる関数は別ファイルにありますが、テストそのものはテストファイル内にあり、関数をインポートしてテストします。
import sum from './math.js';
describe('sum', () => {
test('sums up two values', () => {
expect(sum(2, 4)).toBe(6);
});
});
これがJestの本質です。Reactコンポーネントについてはまだ何もしていません。Jestはテストランナーであり、コマンドラインからJestでテストを実行する能力を提供します。さらに、Jestはテストスイート、テストケースやアサーションのための関数を持っています。もちろん、フレームワークはこれ以上のもの(例. スパイ、モック、スタブなど)も備えていますが、そもそもJestが必要な理由を理解するために必要なのはこれだけです。
React Testing Libraryは、Jestとは対象的にReactコンポーネントをテストするためのテストライブラリの1つです。この分野で人気のもう1つのライブラリが先ほど言及したEnzymeです。次のセクションでは、ReactコンポーネントのテストのためのReact Testing Libraryの使い方を見ていきましょう。
React Testing Library: コンポーネントのレンダリング
create-react-appを使用している場合、React Testing Libraryは標準で含まれています。自前でReactセットアップ(例. WebpackでReact)もしくは他のReactフレームワークを使用している場合は、React Testing Libraryも自分でインストールする必要があります。このセクションでは、React Testing LibraryでReactコンポーネントをレンダリングする方法を学びます。下記のAppという名の関数コンポーネントをsrc/App.jsファイルから引用して利用します。
import React from 'react';
const title = 'Hello React';
function App() {
return <div>{title}</div>;
}
export default App;
src/App.test.jsファイルでテストしていきましょう。
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
});
});
RTLのrender関数は、任意のJSXを受け取ってレンダリングします。その後、テスト内でReactコンポーネントにアクセスできるようになるでしょう。その確認のため、RTLのdebug関数を利用できます。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
screen.debug();
});
});
コマンドラインでテストを実行すると、AppコンポーネントのHTMLを確認できます。React Testing Libraryでコンポーネントをテストする際は、まず最初にコンポーネントをレンダリングしてから、テスト内のRTLのレンダラーで見えるものをデバッグしていきます。こうすることで、自信を持ってテストを書くことができます。
<body>
<div>
<div>
Hello React
</div>
</div>
</body>
特筆すべき点は、React Testing Libraryが実際のコンポーネントにはあまり関心がないということです。異なるReactの機能(useState、イベントハンドラ、props)と概念(制御されたコンポーネント)を使った下記のReactコンポーネントを見てみましょう。
import React from 'react';
function App() {
const [search, setSearch] = React.useState('');
function handleChange(event) {
setSearch(event.target.value);
}
return (
<div>
<Search value={search} onChange={handleChange}>
Search:
</Search>
<p>Searches for {search ? search : '...'}</p>
</div>
);
}
function Search({ value, onChange, children }) {
return (
<div>
<label htmlFor="search">{children}</label>
<input
id="search"
type="text"
value={value}
onChange={onChange}
/>
</div>
);
}
export default App;
Appコンポーネントのテストを再度開始すると、debug関数で下記のような出力が得られます。
<body>
<div>
<div>
<div>
<label
for="search"
>
Search:
</label>
<input
id="search"
type="text"
value=""
/>
</div>
<p>
Searches for
...
</p>
</div>
</div>
</body>
React Testing Libraryは、Reactコンポーネントと人間のように対話するために使用されます。人間が見るものがReactコンポーネントからHTMLとしてレンダリングされるため、2つの異なるReactコンポーネントではなく、このHTML構造が出力されます。
React Testing Library: 要素の選択
Reactコンポーネントをレンダリングした後、React Testing Libraryの様々な検索関数で要素を取得することができます。これらの要素はアサーションやユーザーインタラクションで使用されますが、その前に要素を取得する方法から学んでいきましょう。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
screen.getByText('Search:');
});
});
RTLのrender関数の出力が不明な時は、RTLのdebug関数を使いましょう。HTML構造さえわかれば、RTLのscreenオブジェクト関数で要素の選択を始めることができます。選択された要素はユーザーインタラクションやアサーションで使用されます。アサーションでDOMに要素が含まれているかどうか確認しましょう。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
expect(screen.getByText('Search:')).toBeInTheDocument();
});
});
便利なことに、getByTextは要素が取得できなかった時はエラーをスローします。テストを書く間、そもそも選択された要素がないことを示すヒントになります。expectで明示的なアサーションを書く代わりに暗黙的なアサーションとして利用する人もいます。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
// implicit assertion
// because getByText would throw error
// if element wouldn't be there
screen.getByText('Search:');
// explicit assertion
// recommended
expect(screen.getByText('Search:')).toBeInTheDocument();
});
});
getByText関数はこのように文字列を入力として受け取りますが、正規表現も利用できます。文字列の引数が完全一致であるのに対し、正規表現は部分一致となるため、より役に立つでしょう。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
// fails
expect(screen.getByText('Search')).toBeInTheDocument();
// succeeds
expect(screen.getByText('Search:')).toBeInTheDocument();
// succeeds
expect(screen.getByText(/Search/)).toBeInTheDocument();
});
});
getByText関数は、React Testing Libraryの数多ある検索関数の1つに過ぎません。他にどんなものがあるか見ていきましょう。
React Testing Library: 検索のタイプ
getByTextについて学び、Textが検索タイプのうちの1つであることがわかりました。React Testing Libraryで要素を選択する上で、Textは一般的でよく利用されますが、getByRoleによるRoleもまた役立つことが多いです。
getByRole関数は通常、aria-label属性で要素を取得するのに使用されます。ところが、HTML要素には暗黙的なrole(button要素のbuttonなど)もあります。このようにして、React Testing Libraryを使うことで、目視できるテキストだけでなくアクセシビリティのroleでも要素を選択することができます。getByRoleの特徴は、利用できないroleを選択すると代案をサジェストしてくれるという点にあります。getByTextとgetByRoleのどちらもRTLではよく使われる検索関数です。
getByRoleの特徴:コンポーネントのHTMLがレンダリングされた際に利用できないroleを指定すると、利用可能な全てのroleを表示する。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
screen.getByRole('');
});
});
つまり、上記のテストは実行後に下記のような出力を得ます。
Unable to find an accessible element with the role ""
Here are the accessible roles:
document:
Name "":
<body />
--------------------------------------------------
textbox:
Name "Search:":
<input
id="search"
type="text"
value=""
/>
--------------------------------------------------
HTML要素の暗黙的なroleによって、少なくともテキストボックス(ここでは<input />)要素は取得できます。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
});
つまり、テストのためにariaロールをHTML要素で明示しなくても良いことが多いのは、DOMが既にHTML要素にアタッチされた暗黙的なroleを持っているためです。getByRoleがgetByTextに並ぶ理由も同様です。
他にも、より要素を特定した検索タイプがあります。
- LabelText: getByLabelText: <label for="search" />
- PlaceholderText: getByPlaceholderText: <input placeholder="Search" />
- AltText: getByAltText: <img alt="profile" />
- DisplayValue: getByDisplayValue: <input value="JavaScript" />
さらに最終手段として、ソースコードのHTMLにdata-testid属性を指定する必要があるgetByTestIdを使った検索タイプTestIdもあります。結局のところ、getByTextとgetByRoleは、React Testing LibraryでReactコンポーネントをレンダリングした結果から要素を選択できる検索タイプであるべきです。
- getByText
- getByRole
- getByLabelText
- getByPlaceholderText
- getByAltText
- getByDisplayValue
繰り返しになりますが、それぞれの検索タイプは全てRTLで利用できます。
React Testing Library: 検索バリエーション
検索タイプのほか、検索バリエーションもあります。React Testing Libraryの検索バリエーションの1つは、getByTextやgetByRoleで使用されるgetByです。これは検索バリエーションでもあり、Reactコンポーネントのテストにおいて標準で使用されます。
他に2つの検索バリエーション、queryByとfindByがあります。どちらもgetByで利用可能な検索タイプで拡張できます。たとえば、queryByは全ての検索タイプを利用可能です。
- queryByText
- queryByRole
- queryByLabelText
- queryByPlaceholderText
- queryByAltText
- queryByDisplayValue
さらに全ての検索タイプを持つfindByがあります。
- findByText
- findByRole
- findByLabelText
- findByPlaceholderText
- findByAltText
- findByDisplayValue
getByとqueryByの違いは?
ここで大きな疑問が生じます。getByと他の2つの検索バリエーション、queryByとfindByをいつ使うべきなのでしょうか?getByが要素かエラーを返すことは既に学びました。エラーを返すことは便利な副作用であり、開発者としてテストに問題があることを早期に発見することができます。その一方で、要素が存在しないことを確認するのは困難になります。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
screen.debug();
// fails
expect(screen.getByText(/Searches for JavaScript/)).toBeNull();
});
});
これは動作しません。debug出力が「Searches for JavaScript」というテキストの要素を持たないことを示すものの、getByはエラーをスローしてアサーションを妨げます。該当するテキストの要素が見つからないためです。存在しない要素のアサーションを行うためには、getByをqueryByに置換すると良いでしょう。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();
});
});
つまり、要素が存在しないことをアサーションする場合にはqueryByを使い、それ以外の場合は標準でgetByを使います。ではfindByについてはどうでしょうか?
findByをいつ使うか?
findBy検索バリエーションは最終的に存在する非同期要素に使われます。適切なシナリオとして、Reactコンポーネントを下記のような機能(検索inputフィールドから独立した)で拡張します。初回レンダリングの後、AppコンポーネントはシミュレートされたAPIからユーザーを取得します。APIはJavaScriptのプロミスを返し、ただちにユーザーオブジェクトで解決され、コンポーネントはプロミスから取得したユーザーをstateに格納します。コンポーネントは更新とともに再レンダリングされ、続いてコンディショナルレンダリングによってコンポーネントの更新後に「Signed in as」がレンダリングされます。
function getUser() {
return Promise.resolve({ id: '1', name: 'Robin' });
}
function App() {
const [search, setSearch] = React.useState('');
const [user, setUser] = React.useState(null);
React.useEffect(() => {
const loadUser = async () => {
const user = await getUser();
setUser(user);
};
loadUser();
}, []);
function handleChange(event) {
setSearch(event.target.value);
}
return (
<div>
{user ? <p>Signed in as {user.name}</p> : null}
<Search value={search} onChange={handleChange}>
Search:
</Search>
<p>Searches for {search ? search : '...'}</p>
</div>
);
}
解決されたプロミスによる最初のレンダリングから2回目のレンダリングまでの間にコンポーネントをテストしたい時は、非同期テストを書いて、プロミスが非同期に解決されるのを待つ必要があります。つまり、ユーザーを取得してコンポーネントを1回更新した後、ユーザーがレンダリングされるのを待ちます。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', async () => {
render(<App />);
expect(screen.queryByText(/Signed in as/)).toBeNull();
expect(await screen.findByText(/Signed in as/)).toBeInTheDocument();
});
});
初回レンダリングの後、「Signed in as」というテキストが存在しないことをgetByではなくqueryByでアサーションします。続いて、新しい要素をawaitして最終的にプロミスが解決されてコンポーネントが再レンダリングされることがわかります。
実際に動作することが信じられない場合、2つのdebug関数を含めてコマンドラインの出力を確認します。
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', async () => {
render(<App />);
expect(screen.queryByText(/Signed in as/)).toBeNull();
screen.debug();
expect(await screen.findByText(/Signed in as/)).toBeInTheDocument();
screen.debug();
});
});
まだ存在しないものの最終的に存在する要素については、getByやqueryByではなくfindByを使用してください。存在しない要素をアサーションしたい場合にはqueryByを使います。その他のケースでは通常はgetByを使うと良いでしょう。
複数の要素については?
getBy、queryByとfindByという3つの検索バリエーションを学びましたが、これらは全て検索タイプ(例. Text、Role、Placeholder、DisplayValue)に関連付けられます。全ての検索関数は1つの要素だけを返しますが、複数の要素(例. Reactコンポーネントのリスト)をアサーションするにはどうすれば良いのでしょうか?全ての検索バリエーションはAllで拡張できます。
- getAllBy
- queryAllBy
- findAllBy
一方、これらは全て要素の配列を返しますが、再び検索タイプと関連付けることも可能です
アサーション関数
アサーション関数はアサーションの右側で発生します。先ほどのテストでは、2つのアサーション関数、toBeNullとtoBeInTheDocumentを使いました。どちらもReact Testing Libraryで要素が存在するかどうかをチェックする際には主に使用されるものです。
通常、全てのアサーション関数はJest由来のものです。しかし、React Testing LibraryはこのAPIをtoBeInTheDocumentのような独自のアサーション関数で拡張します。アサーション関数は全て別パッケージに含まれますが、create-react-appを使っていれば最初からセットアップされています。
- toBeDisabled
- toBeEnabled
- toBeEmpty
- toBeEmptyDOMElement
- toBeInTheDocument
- toBeInvalid
- toBeRequired
- toBeValid
- toBeVisible
- toContainElement
- toContainHTML
- toHaveAttribute
- toHaveClass
- toHaveFocus
- toHaveFormValues
- toHaveStyle
- toHaveTextContent
- toHaveValue
- toHaveDisplayValue
- toBeChecked
- toBePartiallyChecked
- toHaveDescription
React Testing Library: イベントの発火
これまでは、Reactコンポーネント内に要素が存在する(もしくは存在しない)ことをgetBy(あるいはqueryBy)で、再レンダリングされたReactコンポーネントが狙った要素を持っているかどうかをfindByでテストしてきました。実際のユーザーインタラクションはどうでしょうか?ユーザーがinputフィールドに文字を入力すると、コンポーネントは再レンダリング(例のように)され、新しい値が表示される(もしくはどこかで使用される)でしょう。
RTLのfireEvent関数を使ってエンドユーザーのインタラクションをシミュレートすることができます。inputフィールドでどのように動作するのか見ていきましょう。
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
screen.debug();
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'JavaScript' },
});
screen.debug();
});
});
fireEvent関数は要素(ここではtextbox roleを持つinputフィールド)とイベント(ここでは「JavaScript」という値を持つイベント)を引数に取ります。debug関数はイベント前後のHTML構造を出力して、適切にレンダリングされたinputフィールドの新しい値を確認できます。
加えて、コンポーネントが非同期タスクを含む場合、たとえば、Appコンポーネントがユーザーを取得するような場合は、下記のような警告が表示されるかもしれません。「警告:テスト内のAppの更新はact(...)でラップされていません」。この場合、非同期タスクが発生して、コンポーネントに処理させる必要があることを意味します。多くの場合、RTLのact関数で対応できますが、今回はユーザーが解決されるのを待つだけで構いません。
describe('App', () => {
test('renders App component', async () => {
render(<App />);
// wait for the user to resolve
// needs only be used in our special case
await screen.findByText(/Signed in as/);
screen.debug();
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'JavaScript' },
});
screen.debug();
});
});
その後、イベント前後のアサーションを追加できます。
describe('App', () => {
test('renders App component', async () => {
render(<App />);
// wait for the user to resolve
// needs only be used in our special case
await screen.findByText(/Signed in as/);
expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'JavaScript' },
});
expect(screen.getByText(/Searches for JavaScript/)).toBeInTheDocument();
});
});
queryBy検索バリエーションを使って要素がイベント前に存在しないことをチェックします。後続のアサーションでもqueryByを使うケースがあるかもしれませんが、要素が存在する時はgetByと同じように使えるため問題ありません。
以上です。テスト内で記述するべき非同期の振る舞いはさておき、RTLのfireEvent関数は直感的で、その後にアサーションも作成できます。
React Testing Library: ユーザーイベント
React Testing Libraryには、fireEvent APIを拡張するユーザーイベントライブラリが付属します。先ほどfireEventでユーザーインタラクションを発火しましたが、今回はuserEventを代わりに使います。userEvent APIはfireEvent APIよりも実際のブラウザに近い振る舞いをするためです。たとえば、fireEvent.change()がchangeイベントだけを発火させるのに対し、userEvent.typeはchangeイベントだけでなく、keyDown、keyPressやkeyUpイベントも発火させます。・
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
describe('App', () => {
test('renders App component', async () => {
render(<App />);
// wait for the user to resolve
await screen.findByText(/Signed in as/);
expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();
await userEvent.type(screen.getByRole('textbox'), 'JavaScript');
expect(
screen.getByText(/Searches for JavaScript/)
).toBeInTheDocument();
});
});
React Testing Libraryを使う時は、可能な限りfireEventよりもuserEventを使うように心がけてください。現時点では、userEventはfireEventの全ての機能を含んではいませんが、将来的に変更される可能性があります。
React Testing Library: コールバックハンドラ
Reactコンポーネントを分離してユニットテストする時もあります。これらのコンポーネントは多くの場合、副作用やstateを持たず、入力(props)と出力(JSXとコールバックハンドラ)だけを持ちます。コンポーネントとpropsを指定してレンダリングされたJSXをテストする方法は既に学びました。次はSearchコンポーネントのコールバックハンドラをテストしていきましょう。
function Search({ value, onChange, children }) {
return (
<div>
<label htmlFor="search">{children}</label>
<input
id="search"
type="text"
value={value}
onChange={onChange}
/>
</div>
);
}
全てのレンダリングとアサーションはこれまで通り実行されます。しかし、今回はJestのユーティリティを使って、コンポーネントに渡されるonChange関数をモックします。すると、inputフィールドにおけるユーザーインタラクション発火後にonChangeコールバック関数が呼ばれたことをアサーションできます。
describe('Search', () => {
test('calls the onChange callback handler', () => {
const onChange = jest.fn();
render(
<Search value="" onChange={onChange}>
Search:
</Search>
);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'JavaScript' },
});
expect(onChange).toHaveBeenCalledTimes(1);
});
});
ここでも、userEventがユーザーの振る舞いをブラウザ上でfireEventよりも正確に再現することを確認できます。fireEventがchangeイベントを実行するのはコールバック関数を一度だけ呼び出す時ですが、userEventはキー入力するごとに発火します。
describe('Search', () => {
test('calls the onChange callback handler', async () => {
const onChange = jest.fn();
render(
<Search value="" onChange={onChange}>
Search:
</Search>
);
await userEvent.type(screen.getByRole('textbox'), 'JavaScript');
expect(onChange).toHaveBeenCalledTimes(10);
});
});
いずれにせよ、React Testing Libraryは、Reactコンポーネントを分離してテストするよりも、他のコンポーネントと統合(統合テスト)することを推奨します。この方法によってのみ、stateの更新がDOMに反映されたかどうかや、副作用が発生したかどうかを実際にテストすることができます。
React Testing Library: 非同期 / async
React Testing Libraryでのテスト時に、findBy検索バリエーションを使って特定の要素が出現するのを待つためにasync awaitを使う方法は既に学びました。今度は小さな例でReactでのデータ取得を見ていきましょう。下記のReactコンポーネントでは、axiosを使ってリモートのAPIからデータを取得します。
import React from 'react';
import axios from 'axios';
const URL = 'http://hn.algolia.com/api/v1/search';
function App() {
const [stories, setStories] = React.useState([]);
const [error, setError] = React.useState(null);
async function handleFetch(event) {
let result;
try {
result = await axios.get(`${URL}?query=React`);
setStories(result.data.hits);
} catch (error) {
setError(error);
}
}
return (
<div>
<button type="button" onClick={handleFetch}>
Fetch Stories
</button>
{error && <span>Something went wrong ...</span>}
<ul>
{stories.map((story) => (
<li key={story.objectID}>
<a href={story.url}>{story.title}</a>
</li>
))}
</ul>
</div>
);
}
export default App;
ボタンをクリックすると、Hacker News APIからストーリー一覧を取得します。成功した場合は、ストーリー一覧がReactに一覧としてレンダリングされます。失敗した場合は、エラーが表示されます。Appコンポーネントのテストは下記のようになります。
import React from 'react';
import axios from 'axios';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
jest.mock('axios');
describe('App', () => {
test('fetches stories from an API and displays them', async () => {
const stories = [
{ objectID: '1', title: 'Hello' },
{ objectID: '2', title: 'React' },
];
axios.get.mockImplementationOnce(() =>
Promise.resolve({ data: { hits: stories } })
);
render(<App />);
await userEvent.click(screen.getByRole('button'));
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(2);
});
});
Appコンポーネントをレンダリングする前に、APIがモックされていることを確認します。この場合、axiosのgetメソッドの返り値がモックされます。しかし、他のライブラリやブラウザネイティブのfetch APIでデータ取得を行う場合はこれらをモックする必要があります。
APIをモックしてコンポーネントをレンダリングした後、userEvent APIを使ってボタンクリック時にAPIリクエストを発行します。リクエストは非同期なので、更新を待つ必要があります。前回同様、RTLのfindBy検索バリエーションを使うことで要素が最終的に表示されるのを待ちます。
import React from 'react';
import axios from 'axios';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
jest.mock('axios');
describe('App', () => {
test('fetches stories from an API and displays them', async () => {
...
});
test('fetches stories from an API and fails', async () => {
axios.get.mockImplementationOnce(() =>
Promise.reject(new Error())
);
render(<App />);
await userEvent.click(screen.getByRole('button'));
const message = await screen.findByText(/Something went wrong/);
expect(message).toBeInTheDocument();
});
});
この最後のテストでは、ReactコンポーネントからのAPIリクエストが失敗することをテストします。APIを正常に解決するpromiseでモックする代わりに、エラーのpromiseで拒否します。コンポーネントのレンダリング後にボタンをクリックして、エラーメッセージが表示されるのを待ちます。
import React from 'react';
import axios from 'axios';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
jest.mock('axios');
describe('App', () => {
test('fetches stories from an API and displays them', async () => {
const stories = [
{ objectID: '1', title: 'Hello' },
{ objectID: '2', title: 'React' },
];
const promise = Promise.resolve({ data: { hits: stories } });
axios.get.mockImplementationOnce(() => promise);
render(<App />);
await userEvent.click(screen.getByRole('button'));
await act(() => promise);
expect(screen.getAllByRole('listitem')).toHaveLength(2);
});
test('fetches stories from an API and fails', async () => {
...
});
});
万全を期して、この最後のテストは、HTMLが表示されるのを待ちたくない時でも動作するようなより明示的な方法でpromiseを待つ方法になります。
結局のところ、Reactでの非同期の振る舞いをReact Testing Libraryでテストするのはそれほど難しくありません。Jestを使って外部モジュールをモック(ここではリモートAPI)し、テスト内でデータもしくはReactコンポーネントの再レンダリングを待つだけです。
React Testing Libraryは、Reactコンポーネントのための頼れるテストライブラリです。これまではずっとAirbnb製のEnzymeを使ってきましたが、React Testing Libraryは実装の詳細ではなく、ユーザーの振る舞いをテストするように導いてくれる点が優れています。実際のユーザーシナリオに似せてテストを書くことで、ユーザーがアプリケーションを使えるかどうかをテストできるのです。