ReactでTesting Library/Jestを使ってテストを学ぼう
Reactを学び始めてコードを書けるようになったので記述したコードのテストを実行してみたいという人を対象にReactでのテスト方法について説明を行っています。
Testing Libraryとは?Jestとは?
Reactでのテストを行う前にテストに利用するライブラリについて説明を行っておきます。
ReactアプリケーションのテストはTesting Library、Jestを利用して行うことができます。Testing LibararyもJestもReactに限定されたライブラリではありません。VueやSvelteを含めた他のフレームワークでも利用することができます。Testing LibararyではReact用のReact Testing Libraryが提供されておりcreate-react-appを利用してReactプロジェクトを作成すると自動でインストールされます。Tesing Libraryはテストを実行したいコンポーネントの描写やクリックイベントの実行、描写した内容からの要素の取得等に利用されます。JestはJavaScriptのTesting Framework/Test Runnerです。テストのファイルを自動で探し出し、テストを実行、テストを実行した結果期待通りの正しい値を持っているか関数(matchers)を利用してチェックを行い、テストが成功か失敗かの判断を行うことができます。上記の説明だけでは違いがわかりづらいかもしれませんが2つのライブラリは役割が異なるのでReactでテストを行う際にはどちらか一方のライブラリを利用するだけではテストを行うことができません。実際にライブラリを使ったテストを作成して、2つの違いとReactでのテスト方法の理解を深めていきましょう。
Reactプロジェクトの作成
Reactプロジェクトを作成してテストを行う環境を構築します。create-react-appでReactのプロジェクトを作成するとテスト環境が設定されているため作成直後からテストを実行することができます。
npx create-react-appコマンドを実行してReactプロジェクトを作成します。npx create-react-appコマンドの後に続くreact-test-tutorialはプロジェクト名なので任意の名前を指定してください。コマンドを実行すると指定した名前のreact-test-tutorialフォルダが作成されます。
% npx create-react-app react-test-tutorial
プロジェクト作成後、プロジェクトフォルダに移動してインストールされているJavaScriptのライブラリを確認するためにpackage.jsonファイルを確認します。dependenciesの中にtestのついた3つのライブラリを確認することができます。これがテストを行う際に利用するライブラリです。scriptsの中にtestがあることも確認できます。テストを実行する際にscriptsにあるtestを利用します。
{
//略
"dependencies": {
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.1",
"@testing-library/user-event": "^13.5.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
//略
さらにsrcフォルダの中を確認するとsetupTests.jsとApp.test.jsという名前のファイルを確認することができます。App.test.jsファイルの中にはテストが記述されています。
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
作成したプロジェクトのpackage.jsonファイルとファイルの構成を見ることでnpx create-react-appコマンドを実行してプロジェクトを作成することでテストを行うための環境が事前にインストールされていることがわかります。
初めてのテスト
テスト環境が事前に備わっているので早速テストを実行してみましょう。
先ほど説明した通りpackage.jsonファイルのscriptの中にtestが含まれているのでnpm runコマンドを利用して実行できるかテストが実行できるか確認してみましょう。
% npm run test
実行すると対話式のメッセージが表示されます。メッセージを見るとキーボードのキーによって処理される内容が異なることがわかります。ここでは”Press a to run all tests.”と表示されている”a”のキーを押します。
No tests found related to files changed since last commit.
Press `a` to run all tests, or run Jest with `--watchAll`.
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.
aのキーを押すとテストが実行され”PASS”が表示されます。”PASS”は実行したテストに成功したことを意味します。下記のメッセージからsrc/App.test.jsが実行されテストに”PASS”したことがわかります。テストに”PASS”したこと以外にもテストの件数やテストにかかった時間なども表示されます。
PASS src/App.test.js
✓ renders learn react link (35 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.817 s, estimated 2 s
Ran all test suites.
Watch Usage: Press w to show more.
テストが実行されたApp.test.jsの中身を確認してみましょう。
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
まずtest関数に注目します。test関数はJestの関数でテストを実行する際に必ず利用する関数です。test関数の第一引数にはテストがどのような内容なのか説明を記述することができます。後に読み直してもテストの内容がわかるような説明を記述します。第2引数の関数の中にテストを記述していきます。
第2引数の関数の中身を確認すると1行目でimportしたAppコンポーネントをrender関数で描写しています。render関数を利用することでAPPコンポーネントはテストの中で<body><div>タグの中に追加されて描写されます。2行目にあるscreenはさまざまなメソッドを持ちそのうちの一つgetByTextメソッドを利用してrenderで描写したAppコンポーネントの中で”learn react”という文字列を探しています。要素を見つけた場合はその要素を取得します。”/learn react/i”にある”i”はRegular Expressionで”i”をつけることで大文字、小文字を区別しません。3行目のexpect関数の引数に指定した要素がdocument.bodyに存在するかtoBeInTheDocument関数を使ってチェックしています。toBeInTheDocument関数はmatchers関数と呼ばれる関数です。
App.test.jsファイルの中で利用されているtest関数、expect関数はJestの関数でtoBeInTheDocumentはjest-domライブラリに含まれているCustom matchersです。jest-domはsrcフォルダのsetupTests.jsファイル内でimportされています。setupTests.jsファイルからimportを削除するとtoBeInTheDocument関数は利用することができません。
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
App.jsファイルの中身を見ると単語の先頭が大文字になっていますが”Learn React”を見つけることができます。 screenのgetByTextメソッドで<a>タグのAnchor Elementが取得されることになります。
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;
もしApp.jsのLearnの”L”を削除すると”FAIL”となりlearn reactを持つ要素が見つけられないとテストに失敗します。
FAIL src/App.test.js
✕ renders learn react link (36 ms)
● renders learn react link
TestingLibraryElementError: Unable to find an element with the text: /learn react/i. 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.
//略
renderで指定したAppコンポーネントのHTMLのDOM構造を確認したい場合はscreen.debug()を利用することができます。
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
screen.debug();
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
render関数では<body><div>タグの中にコンポーネントが追加されると説明した通りになっていることが確認できます。またJSXのclassNameがclassになっていることも確認できます。
console.log
<body>
<div>
<div
class="App"
>
<header
class="App-header"
//略
プロジェクト作成後から存在するテストを理解することでテストとはどのようなものかある程度理解ができたかと思います。
最もシンプルなテスト
先ほどのテストはReactを利用していたのでテストを始めて実行した人にとっては難しいかもしれません。そこでテストについてもう少し理解を深めるためにJestのみを利用してテストを実行しテストの基本的なルールや記述方法を確認していきます。
srcフォルダにExample.test.jsファイルを作成します。テストを実行するためにはファイル名にtestをつけます。テストファイルは<ファイル名>.test.jsの形をとります(<ファイル名>.spec.jsでもOK)。このようにテスト名を命名することでJestがテストのファイルとして判断してくれます。
Jestのみを利用するのでReactのコンポーネントを利用せず足し算の簡単なテストを実行します。テストはtest関数の中に記述します。test関数の第一引数にはテストの内容を記述し、第二引数のの関数にテストを記述します。expect関数の引数の2+2の足し算が4になることをテストしたいのでtoBe関数に4を指定しています。expect関数には値を入れて、matchers関数を利用してチェックを行います。toBe関数はmatchers関数の一つでexpect関数に指定した値がtoBe関数に指定した値と一致するかチェックを行います。
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
npm run testを実行して”a”を選択している場合はファイルを作成して保存した瞬間にテストが実行されます。Example.test.jsとApp.test.jsが実行されます。どちらのテストもPASSしていることがわかります。
PASS src/Example.test.js
PASS src/App.test.js
Test Suites: 2 passed, 2 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.603 s
Ran all test suites.
Watch Usage: Press w to show more.
toBe関数の引数を5に変更してテストが失敗することを確認します。
test('two plus two is four', () => {
expect(2 + 2).toBe(5);
});
ファイルを保存するとテストが再実行され”FAIL”が確認できます。メッセージを確認することでどのファイルで”FAIL”したのかまたどこにテストの失敗の原因があるか確認することができます。
PASS src/App.test.js
FAIL src/Example.test.js
● two plus two is four
expect(received).toBe(expected) // Object.is equality
Expected: 5
Received: 4
1 | test('two plus two is four', () => {
> 2 | expect(2 + 2).toBe(5);
| ^
3 | });
4 |
at Object.<anonymous> (src/Example.test.js:2:17)
Test Suites: 1 failed, 1 passed, 2 total
Tests: 1 failed, 1 passed, 2 total
Snapshots: 0 total
Time: 2.544 s
Ran all test suites.
Watch Usage: Press w to show more.
テストを記述する際にtest関数を利用していましたがit関数を利用することもできます。
it('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
it関数やtest関数を1つのファイルに複数記述することができますがit関数やtest関数を複数含めることができるdescribe関数があります。describe関数のブロックはTest Suitesと呼ばれtest/it関数のブロックはTest(Test Case)と呼ばれます。
describe関数を利用すると下記のように記述することができます。
describe('simple tests', () => {
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
test('two minus one is one', () => {
expect(2 - 1).toBe(1);
});
});
Jestの基本的なルールと記述方法を理解することができました。
Reactを利用したテスト
Jestの基本的な理解が進んだのでReactのテストに戻ります。ここからReactのコンポーネントを利用してテストを実行してテストの理解を深めていきます。
debug関数
コンポーネントのテストで失敗した場合に取得した要素やコンポーネントが期待通りに描写されているのか知りたい場面が多々あります。その場合にdebug関数を利用することができます。
App.jsファイルを下記のように更新します。
function App() {
return <h1>Hello</h1>
}
export default App;
テキストにHelloを含む要素が存在するかテストを行うため下記のコードを記述します。
import { render, screen } from '@testing-library/react';
import App from './App';
it('renders Hello',() => {
render(<App />);
const element = screen.getByText('Hello');
expect(element).toBeInTheDocument();
});
Helloを持つ要素が存在するためテストは”PASS”します。
テストに”PASS”したのでelementには要素が含まれていることがわかっていますが実際の中身を確認したい場合にはscrenn.debugの引数に要素を入れることで確認することができます。
const element = screen.getByText('Hello');
screen.debug(element);
expect(element).toBeInTheDocument();
npm run testを実行しているコンソールに要素の情報を確認することができます。
console.log
<h1>
Hello
</h1>
getBy, findBy, queryByの違い
テストを行う際に要素を取得することが重要になってきます。そのため要素を取得するためQueryにいくつか種類(getBy, findBy, queryBy)があります。複数の種類があるということは必ず違いがあるはずなので違いを確認していきます。
これまでに利用したgetByTextではテキストから要素を見つけるのに利用することができました。queryByTextでも同様にテキストから要素を見つけることができます。App.test.jsのgetByTextからqueryByTextに変更して実行します。
import { render, screen } from '@testing-library/react';
import App from './App';
it('renders Hello', () => {
render(<App />);
const element = screen.queryByText('Hello');
screen.debug(element);
expect(element).toBeInTheDocument();
});
実行するとテストにも成功し、screen.debugで表示される内容も同じです。
次にqueryByTextからfindByTextに変更します。
const element = screen.findByText('Hello');
screen.debug(element);
先ほどとは異なりエラーが発生します。エラーの場所はscreen.debug(element)の箇所でメッセージの内容からelement(要素)の情報ではなくPromiseが変数elementに入っているといっています。
TypeError: Expected an element or document but got Promise
Testing Libraryを見るとそれらのQueryの違いを一覧表でまとめています。findBy…の右側を見るとRetry(Async/Await)が”Yes”になっていることがわかります。
findBy…ではPromiseで戻されることが予想できるのでasync, await関数を利用します。
import { render, screen } from '@testing-library/react';
import App from './App';
it('renders Hello', async() => {
render(<App />);
const element = await screen.findByText('Hello');
screen.debug(element);
expect(element).toBeInTheDocument();
});
async, await関数を利用することで要素には<h1>Hello</h1>が入りテストに成功します。getBy/queryByの2つとfindByには大きな違いがあることがわかりました。
次にgetByとqueryByの違いを確認しますが、動作確認の結果、要素が取得できない場合に違いがあることがわかります。実際にコードで確認します。
getByTextの引数を”Hello”から”ello”に変更します。
const element = screen.getByText('ello');
エラーのメッセージを見ると要素が見つけられない時点でエラーになっていることがわかります。
TestingLibraryElementError: Unable to find an element with the text: ello. ....
queryByTextに変更してテストを実行します。
const element = screen.getByText('ello');
getByTextの場合とはエラーの内容が異なり、要素を見つけれらなかったことでエラーになっているのではなくexpect関数に入るelementがHTLMElementかSVGElementのはずなのにnullになっていることでテストに失敗しています。
expect(received).toBeInTheDocument()
received value must be an HTMLElement or an SVGElement.
Received has value: null
先ほどの表を見るとqueryByの場合は要素を見つけることができない場合はnullを戻すと記載されているので記載されている内容と動作が一致するのがわかります。queryByでは要素が存在しなくてもエラーにならないのでエラーが存在しないことを確認するなどのテストに利用することができます。toBeInTheDocumentは要素が存在することを確認していますがその前にnotをつけることで要素が存在しないことを確認しています。getByでは要素が見つからにとエラーになってしまうためこのテストを実行することはできません。
it('not renders Hello', () => {
render(<App />);
const element = screen.queryByText('ello');
screen.debug(element);
expect(element).not.toBeInTheDocument();
});
elloを持つ要素がない場合はテストに”PASS”します。
getBy…, findBy…, queryBy…の3つの違いが理解できたかと思います。要素を見つける場合はテストの状況に合わせてQueryを使い分ける必要があります。
getAllBytextでのテスト
App.jsファイルを更新してHelloの文字列が2つ表示されるようにp要素を追加します。
function App() {
return (
<>
<h1>Hello</h1>
<p>Hello</p>
</>
);
}
export default App;
まずgetByTextでテストを行います。
const elements = screen.getByText('Hello');
複数の要素が見つかったのでエラーが発生します。
TestingLibraryElementError: Found multiple elements with the text: Hello
複数の要素に対応できるgetAllByTextに変更します。
const elements = screen.getAllByText('Hello');
これで”PASS”するかと思われるかもしれませんがエラーが発生します。toBeInTheDocumentでは要素が入ることが期待されていますが複数の要素が見つかったことにより要素ではなく要素を含む配列になっていることでエラーになっています。
expect(received).toBeInTheDocument()
received value must be an HTMLElement or an SVGElement.
Received has type: array
Received has value: [<h1>Hello</h1>, <p>Hello</p>]
getAllByTextの場合にはmatchers関数のtoBeInTheDocumentが利用できないことがわかります。配列で戻されるので配列に入っている要素の数でチェックを行うことができます。その場合はtoHaveLength関数を利用して引数には配列の要素の数を指定します。
it('renders multiple Hello', () => {
render(<App />);
const elements = screen.getAllByText('Hello');
screen.debug(elements);
expect(elements).toHaveLength(2);
});
これでテストには”PASS”します。このようにテストを行う内容によってmatchers関数も適切なものに変更する必要があります。
ByRoleの使い方
要素を見つける方法には要素の文字列を使うByText以外にも複数あります。ここではByRoleを利用して要素を見つける方法を確認します。ByRoleは各要素に設定されているrole属性の値を利用します。
button要素などには設定を行わなくてもrole属性にbuttonが設定されています。a要素であればroleにはlinkが設定されています。各要素に設定されているroleについてはこちらのサイトから確認することができます。
h1タグにはheadingというroleが設定さているのでgetByRoleを利用する場合はgetByroleの引数に”heading”を設定することでh要素を取得することができます。h1要素だけではなくh2, h3..などもroleはheadingです。
import { render, screen } from '@testing-library/react';
import App from './App';
it('renders heading', () => {
render(<App />);
const headElement = screen.getByRole('heading');
screen.debug(headElement);
expect(headElement).toBeInTheDocument();
});
h1タグを持つ要素がApp.jsに含まれているのでテストは”PASS”します。
App.jsにh1タグとh2タグを設定するとheadingを持つ要素が2つになるためエラーとなります。
function App() {
return (
<>
<h1>Hello</h1>
<h2>Hello World</h2>
</>
);
}
export default App;
getByRoleにはオプションがあり、オプションを設定することで1つのheadingのみ見つけることができます。オプションはオブジェクトで設定することができnameプロパティにh1タグのテキストを設定します。
const headElement = screen.getByRole('heading', { name: 'Hello' });
オプションを設定したことでgetByRoleでは”Hello”のテキストを持つh1タグのみを見つけることができるのでテストに”PASS”します。
リスト表示する場面も多数あるのでli要素の取得方法を確認します。hタグはheadingでしたがul要素のroleはlist、li要素のroleはlistitemです。App.jsにulタグを使ってリストを追加します。
function App() {
return (
<>
<h1>Hello</h1>
<ul>
<li>React</li>
<li>Vue</li>
<li>Svelte</li>
</ul>
</>
);
}
liタグは複数の要素から構成されているのでgetAllByRoleを利用します。複数の要素の場合は配列が戻されるのでtoHaveLength関数を利用します。
import { render, screen } from '@testing-library/react';
import App from './App';
it('renders lists', () => {
render(<App />);
const listElements = screen.getAllByRole('listitem');
screen.debug(listElements);
expect(listElements).toHaveLength(3);
});
テストを実行すると”PASS”します。配列の大きさでであればtoEqual関数を利用することもできます。
expect(listElements.length).toEqual(3);
matchers関数のtoHaveLengthもtoEqualもJESTのドキュメントのExpectのページで確認することができます。
toBeInTheDocument関数はjest-domのGithubのページで確認することができます。
QueryのPriority
要素を取得するためにここまで確認したByText, ByRole以外にもByLabel, ByTitle, ByTestIdなど利用することができます。複数の方法で要素を見つける場合にどれを利用したらよいのでしょう。Testing LibraryのドキュメントのPriority(優先度)を参考にすることができます。
getByRole, getByTextなどどの方法でも要素を見つける方法がない場合はgetByTestIdを利用することができます。
テストで利用したい要素にdata-testid属性を設定して任意の値を設定します。設定した値はgetByTestIdで利用します。
<p data-testid="test">Test</p>
data-testidを設定した要素を見つけたい場合にはgetByTestIdの引数にtestを設定して行います。
const element = screen.getByTestId('test');
expect(element).toBeInTheDocument();
イベントを使ったテスト
Powerコンポーネントの作成
新たにsrcフォルダにcomponentsフォルダを作成しPower.jsxファイルを作成します。これまでテストに利用していたApp.test.jsファイルを削除します。
Power.jsはシンプルなコンポーネントで電源をON, OFFに切り替えることができるボタンを備え、現在のボタンの状態(ON or OFF)を表示するだけコンポーネントです。h1タグを使って表示する名前(name)はpropsを使って渡されます。
import { useState } from 'react';
function Power({ name }) {
const [power, setPower] = useState(false);
return (
<div style={{ margin: '2em' }}>
<h1>
{name} {power ? 'ON' : 'OFF'}{' '}
</h1>
<button
onClick={() => setPower(true)}
disabled={power ? true : false}
>
ON
</button>
<button onClick={() => setPower(false)} disabled={!power ? true : false}>
OFF
</button>
</div>
);
}
export default Power;
App.jsファイルからPower.jsxファイルをimportして表示します。
import Power from './componets/Power';
function App() {
return <Power name="電源" />;
}
export default App;
npm startコマンドを実行して開発サーバを起動してブラウザからアクセスすると下記の画面が表示されます。
デフォルトでは電源がOFFでONボタンのみクリックできる状態です。
ONボタンをクリックすると電源がONになり、OFFボタンがクリックできる状態になります。
テストの実行
テストを実行するためにcompoentsフォルダの下にPower.test.jsファイルを作成します。コンポーネントにpropsを渡す場合は通常の方法と同じ方法で渡すことができます。
import { render, screen } from '@testing-library/react';
import Power from './Power';
it('renders Power Component', () => {
render(<Power name="電源" />);
const nameElement = screen.getByText(/電源 off/i);
expect(nameElement).toBeInTheDocument();
});
propsで渡された”電源”が正しく表示されているのか確認するためにgetByTextを利用しています。テストを実行するとテストは”PASS”します。
OFFボタンがdisabledになっているかもテストで確認することができます。matchers関数のtoBeDisabled関数を利用します。getByRoleを利用してbuttonを指定しています。ボタンが2つあるのでオプションのnameを利用することでOFFボタンを取得しています。
//略
it('off button disabled', () => {
render(<Power name="電源" />);
const offButtonElement = screen.getByRole('button', { name: 'OFF' });
expect(offButtonElement).toBeDisabled();
});
ONボタンがdisabledでないこともチェックしておきます。toBedisabled関数の前にnotをつけることで実現できます。(toBeEnabledを利用することも可能)
//略
it('on button enable', () => {
render(<Power name="電源" />);
const onButtonElement = screen.getByRole('button', { name: 'ON' });
expect(onButtonElement).not.toBeDisabled();
});
ここまでのテストであればこれまで説明した内容で理解はできると思います。次にここまで説明していないClickボタンのクリックをテストではどのように行うのかを確認します。
fireEventの利用
ボタンを”ON”から”OFF”に切り替えるためにはONボタンをクリックする必要があります。テストコードの中でボタンをクリックしたい場合はfireEventを利用することができます。testing-library/reactからfireEventをimportします。クリックを行う際はfireEventのclickメソッドを実行する際にクリックしたい要素を引数に指定するだけです。
import { fireEvent, render, screen } from '@testing-library/react';
//略
it('chagen from off to on', () => {
render(<Power name="電源" />);
const onButtonElement = screen.getByRole('button', { name: 'ON' });
fireEvent.click(onButtonElement);
expect(onButtonElement).toBeDisabled();
});
テスト内でONボタンへのクリックが行われ、ONボタンはdisabledとなりテストは”PASS”します。
fireEventはtesting-library/reactからimportしていますがpackage.jsonに含まれていたtesting-library/user-eventライブラリのuserEventを利用することもできます。
userEventの利用
userEventを利用する場合はfireEventとは異なりtesting-library/user-eventライブラリからimportする必要があります。先ほどのコードのfireEventをuserEventに書き換えるだけで設定は完了です。テストは”PASS”します。
import { fireEvent, render, screen } from '@testing-library/react';
//略
it('chagen from off to on', () => {
render(<Power name="電源" />);
const onButtonElement = screen.getByRole('button', { name: 'ON' });
userEvent.click(onButtonElement);
expect(onButtonElement).toBeDisabled();
});
fireEvent vs userEvent
上記の動作確認ではfireEventとuserEventどちらでもテストは”PASS”しました。では違いはどこにあるのでしょうか?
fireEventではclickイベントのみしか実行されることはありませんが実際にユーザがボタンをクリックするとclickイベントだけではなくpointDownイベント、hoverイベントなどさまざまなイベントが発生します。userEventを利用するとそれらのイベントも実行されユーザがブラウザ上で行う処理と同じ処理を再現することができます。
言葉だけでイメージしくにと思うのでuserEventではclickイベントだけではなく他のイベントも実行されることを確認するためにPower.jsxファイルの2つのボタンにpointDownイベントを設定します。
import { useState } from 'react';
function Power({ name }) {
const [power, setPower] = useState(false);
return (
<div style={{ margin: '2em' }}>
<h1>
{name} {power ? 'ON' : 'OFF'}
</h1>
<button
onClick={() => setPower(true)}
disabled={power ? true : false}
onPointerDown={() => console.log('PointerDown Event')}
>
ON
</button>
<button
onClick={() => setPower(false)}
disabled={!power ? true : false}
onPointerDown={() => console.log('PointerDown Event')}
>
OFF
</button>
</div>
);
}
export default Power;
設定後、テストを行う前にブラウザを利用してクリックするとPointerDownイベントが実行されるのか確認します。ON, OFFどちらのボタンをクリックしてもブラウザのデベロッパーツールのコンソールに”PointerDown Event”が表示されます。ブラウザ上でボタンをクリックするとPinterDownイベントが発生することがわかったのでテストを行います。
まず最初にuserEvent.clickでテストを実行します。
import { fireEvent, render, screen } from '@testing-library/react';
//略
it('chagen from off to on', () => {
render(<Power name="電源" />);
const onButtonElement = screen.getByRole('button', { name: 'ON' });
userEvent.click(onButtonElement);
expect(onButtonElement).toBeDisabled();
});
テストには影響ないのでテストは”PASS”をしますがnpm run testを実行したコンソールに”PinterDown Event”メッセージが表示されます。PinterDownイベントがuserEventのclickによって実行されたことがわかります。
console.log
PointerDown Event
at onPointerDown (src/componets/Power.jsx:14:38)
次にfireEventのclickイベントを設定します。
fireEvent.click(onButtonElement);
テストは”PASS”しますがuserEventの時のようにコンソールにメッセージが表示されることはありません。fireEventではclickだけではなくpointerDownも行えるようにfireEventを新たに追加しpointerDownをを設定します。
fireEvent.pointerDown(onButtonElement);
fireEvent.click(onButtonElement);
pointerDownを設定するとuserEventと同じようにコンソールにメッセージが表示されます。
console.log
PointerDown Event
at onPointerDown (src/componets/Power.jsx:14:38)
動作確認を通してfireEventよりuserEventの方のユーザがブラウザ上で行う動作と同じ動作を再現できることが理解することができました。
Todoリストアプリを利用したテスト
これまでよりも少しだけ実際のテストに近づけるために本ブログで公開済みの記事で作成したTodoリストアプリケーションを利用してテストを実施していきます。
テストに利用するコード
利用するコードの作成についての詳細は下記の記事を参考にしてください。
Todoリストアプリケーションは3つのファイルTodo.js, TodoList, AddTodo.jsから構成されています。Componentsフォルダの下に3つのファイルを作成して下記のコードを記述してください。
Todo.jsファイルにはTodoのリストtodosの初期値とTodoList.jsとAddTodo.jsファイルをimportしてpropsでtodosとsetTodosを渡します。
import { useState } from 'react';
import AddTodo from './AddTodo';
import TodoList from './TodoList';
const Todo = () => {
const initialState = [
{
task: 'Learn vue.js',
isCompleted: false,
},
{
task: 'Learn React Hook',
isCompleted: false,
},
{
task: 'Learn Gatsby.js',
isCompleted: false,
},
];
const [todos, setTodos] = useState(initialState);
return (
<div>
<h1>ToDo List</h1>
<AddTodo setTodos={setTodos} />
<TodoList todos={todos} setTodos={setTodos} />
</div>
);
};
export default Todo;
AddTodo.jsファイルではタスクの追加の処理のみ記述します。
import { useState } from 'react';
const AddTodo = ({ setTodos }) => {
const [task, setTask] = useState('');
const handleNewTask = (event) => {
setTask(event.target.value);
};
const handleSubmit = (event) => {
event.preventDefault();
if (task === '') return;
setTodos((todos) => [...todos, { task, isCompleted: false }]);
setTask('');
};
return (
<form onSubmit={handleSubmit}>
Add Task :
<input value={task} placeholder="Add New Task" onChange={handleNewTask} />
<button> type="submit">追加</button>
</form>
);
};
export default AddTodo;
TodoList.jsはpropsのtodosを受け取りtodosを展開して表示し、更新、削除の関数を設定しています。
import React from 'react';
const TodoList = ({ todos, setTodos }) => {
const handleRemoveTask = (index) => {
const newTodos = [...todos];
newTodos.splice(index, 1);
setTodos(newTodos);
};
const handleUpdateTask = (index) => {
const newTodos = todos.map((todo, todoIndex) => {
if (todoIndex === index) {
todo.isCompleted = !todo.isCompleted;
}
return todo;
});
setTodos(newTodos);
};
return (
<ul>
{todos.map((todo, index) => (
<li
key={index}
style={{
textDecoration: todo.isCompleted ? 'line-through' : 'none',
}}
>
<input
type="checkbox"
checked={todo.isCompleted}
onChange={() => handleUpdateTask(index)}
/>
{todo.task}
<span
onClick={() => handleRemoveTask(index)}
style={{ cursor: 'pointer' }}
>
X
</span>
</li>
))}
</ul>
);
};
export default TodoList;
AppコンポーネントからTodoコンポーネントをimportします。
import Todo from './componets/Todo';
function App() {
return (
<div style={{ margin: '2em' }}>
<Todo />
</div>
);
}
export default App;
npm startコマンドで開発サーバを起動してブラウザでアクセスすると以下の画面が表示されます。
AddTodoコンポーネントのテスト
AddTodoコンポーネントのテストを行うためにcomponentsフォルダの下にAddTodo.test.jsファイルを作成します。
AddTodoコンポーネントはTodoリストに追加するためのTaskを入力フォームを持っています。AddTodoコンポーネントのlabel要素のテキストが表示されているのかを確認するためにgetByLabelTextを利用してテストを記述します。
import { render, screen } from '@testing-library/react';
import AddTodo from './AddTodo';
describe('AddTodo', () => {
it('renders label element', () => {
render(<AddTodo />);
const labelElement = screen.getByLabelText('Task:');
expect(labelElement).toBeInTheDocument();
});
});
テストを実行すると”PASS”します。
次にinput要素が表示されているか確認するためにgetByPlaceholderTextを利用してテストを記述します。input要素のplaceholderに設定した値は”Add New Task”です。
it('renders input element', () => {
render(<AddTodo />);
const inputElement = screen.getByPlaceholderText(/Add New Task/i);
expect(inputElement).toBeInTheDocument();
});
テストを実行すると”PASS”します。
input要素に入力した値が表示されているか確認するためのテストを実行します。input要素にテキストを入力するためにはイベントを利用する必要があります。イベントにはfireEvent.changeを利用することができます。
it('input element should change', () => {
render(<AddTodo />);
const inputElement = screen.getByPlaceholderText(/Add New Task/i);
fireEvent.change(inputElement, {
target: { value: 'Learn Testing Library' },
});
expect(inputElement.value).toBe('Learn Testing Library');
});
target.valueを利用してinput要素にテキストを入力してmatchers関数のtoBeを利用して入力した値とinput要素に保存されている値が一致するかチェックを行っています。
テストを実行すると”PASS”します。
fireEventの行の後にscreen.debug(inputElement)を利用してinput要素の中身を確認するとvalueに”Learn Testing Library”が入っていることが確認できます。
console.log
<input
id="task"
placeholder="Add New Task"
value="Learn Testing Library"
/>
input要素にテキストを入力後に”追加”ボタンをクリックするとinput要素のvalueが空になるかのテストを実行します。ボタン要素はgetByRoleを利用して見つけた後fireEventでclickイベントを実行します。
it('input text should remove where add button click', () => {
render(<AddTodo />);
const inputElement = screen.getByPlaceholderText(/Add New Task/i);
fireEvent.change(inputElement, {
target: { value: 'Learn Testing Library' },
});
const buttonElement = screen.getByRole('button', { name: '追加' });
fireEvent.click(buttonElement);
expect(inputElement.value).toBe('');
});
テストを実行するとsetTodos関数に関するエラーが表示されます。ここまでPropsで渡されるsetTodosについて何も設定していませんでしたが”追加”ボタンをクリックするとhandleSubmit関数が実行されその中でsetTodos関数が実行されます。
console.error
Error: Uncaught [TypeError: setTodos is not a function]
setTodos関数が必要となるのでpropsでAddTodoコンポーネントに渡す必要があります。AddTodoコンポーネントのテストではsetTodos関数の処理については考慮する必要がないためモック関数を利用します。jest.fn()でモック関数を設定することができます。setTodos関数が実行されるとjets.fn()によりundefinedが戻されます。
render(<AddTodo setTodos={jest.fn()} />);
再度テストを実行すると”PASS”します。
fireEventからuseEventに変更することも可能です。userEventを利用する場合は@testing-library/user-eventからimportする必要があります。useEventではtypeメソッドを利用するとことでinput要素やtextarea要素にテキストを入力することができます。第一引数に要素と第二引数には入力したいテキストを設定します。fireEvent.changeとは記述方法が変わります。userEvent.typeの方が記述もわかりやすいです。
import userEvent from '@testing-library/user-event';
//略
it('input text should remove where add button click', () => {
render(<AddTodo setTodos={jest.fn()} />);
const inputElement = screen.getByPlaceholderText(/Add New Task/i);
// fireEvent.type(inputElement, {
// target: { value: 'Learn Testing Library' },
// });
userEvent.type(inputElement, 'Learn Testing Library');
const buttonElement = screen.getByRole('button', { name: '追加' });
userEvent.click(buttonElement);
expect(inputElement.value).toBe('');
});
fireEventからuserEventに変更してもテストは”PASS”します。
Todoコンポーネントのテスト
ここではAddTodoコンポーネントとTodoListコンポーネントを含むTodoコンポーネントのテストを行っていきます。AddTodoコンポーネントのテストでは単体のコンポーネントのテストでしたが今回は複数のコンポーネントが含まれた状態でテストを実施します。
AddTodoコンポーネントをテストする際には同じcomponentsフォルダにAddTodo.test.jsファイルを作成しましたが__test__フォルダを作成してその中にテスト用のファイルTodo.test.jsファイルを作成してテストコードを記述していきます。慣例でテストのファイルは__test__フォルダを作成してその中に保存します。
Todo.jsファイルでh1タグで囲まれたToDo ListをgetByRoleを利用して確認します。
import { render, screen } from '@testing-library/react';
import Todo from '../Todo';
describe('Todo', () => {
it('should render header tag title', () => {
render(<Todo />);
const headingElement = screen.getByRole('heading', { name: /Todo List/i });
expect(headingElement).toBeInTheDocument();
});
});
テストを実行すると2つのファイルでテストが”PASS”していることがわかります。
PASS src/componets/AddTodo.test.js
PASS src/componets/__test__/Todo.test.js
Test Suites: 2 passed, 2 total
Tests: 5 passed, 5 total
Snapshots: 0 total
Time: 1.827 s, estimated 2 s
Ran all test suites related to changed files.
Watch Usage: Press w to show more.
デフォルト値としてTodosには3つのTodoが含まれているので3つのリストが表示されるか確認します。liタグのアイテムはgetByRoleで’listitem’を指定することで取得することができます。複数あるのでgetAllByRoleを利用します。
it('should render default three todos', () => {
render(<Todo />);
const listElements = screen.getAllByRole('listitem');
expect(listElements).toHaveLength(3);
});
テストを実行すると”PASS”します。
it('should render 4 todos where i add new task', () => {
render(<Todo />);
const inputElement = screen.getByPlaceholderText(/add new task/i);
const buttonElement = screen.getByRole('button', { name: '追加' });
userEvent.type(inputElement, 'Learn Testing Libary');
userEvent.click(buttonElement);
const listElements = screen.getAllByRole('listitem');
expect(listElements).toHaveLength(4);
});
Taskを追加した直後ではisCompletedがfalseなのでstyle属性の”text-decoration”が”none”になっていることを確認します。入力フォームにテキストを入力しボタンをクリックするまでの設定はAddTodoコンポーネントのテストで実施済みです。style属性のチェックにはtoHaveStyle関数を利用することができます。
it('should not have style when i add new task', () => {
render(<Todo />);
const inputElement = screen.getByPlaceholderText(/add new task/i);
const buttonElement = screen.getByRole('button', { name: '追加' });
userEvent.type(inputElement, 'Learn Testing Libary');
userEvent.click(buttonElement);
const listElement = screen.getByText(/Learn Testing Libary/i);
expect(listElement).toHaveStyle('text-decoration:none');
});
userEventには”Special characters”が準備されておりtypeでテキストを入力した後にEnterキーを押したい場合は{enter}のように記述することができます。{enter}を設定することでボタン要素のクリックの行を削除することができます。
it('should not have style when i add new task', () => {
render(<Todo />);
const inputElement = screen.getByPlaceholderText(/add new task/i);
const buttonElement = screen.getByRole('button', { name: '追加' });
userEvent.type(inputElement, 'Learn Testing Libary{enter]');
const listElement = screen.getByText(/Learn Testing Libary/i);
expect(listElement).toHaveStyle('text-decoration:none');
});
その他のSpecial Charatersはドキュメントに記載されています。
次は、inputのcheckboxをクリックするとisCompltedがtrueになり”text-decoration”の値が”line-through”になることを確認します。本文書のコードではliタグの中にcheckboxを持つinput要素があるのでquerySelectorを利用してinput要素を見つけてuserEvent.clickでcheckboxにチェックを設定します。
it('should have style after i add new task and check checkbox', () => {
render(<Todo />);
const inputElement = screen.getByPlaceholderText(/add new task/i);
const buttonElement = screen.getByRole('button', { name: '追加' });
userEvent.type(inputElement, 'Learn Testing Libary');
userEvent.click(buttonElement);
const listElement = screen.getByText(/Learn Testing Libary/i);
userEvent.click(listElement.querySelector('input'));
expect(listElement).toHaveStyle('text-decoration:line-through');
});
テストを実行すると”PASS”します。
追加したTaskの削除ボタンをクリックすると追加したTaskがTodoのリストから削除されることを確認します。追加した直後ではリスト数は4になり、削除の”X”をuserEvent.clickでクリックした後はリスト数が3になっていることを確認しています。
it('should delete task when i click delete X', () => {
render(<Todo />);
const inputElement = screen.getByPlaceholderText(/add new task/i);
const buttonElement = screen.getByRole('button', { name: '追加' });
userEvent.type(inputElement, 'Learn Testing Libary');
userEvent.click(buttonElement);
expect(screen.getAllByRole('listitem')).toHaveLength(4);
const listElement = screen.getByText(/Learn Testing Libary/i);
userEvent.click(listElement.querySelector('span'));
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
実際に利用するコードはこんなにシンプルなものではありませんが複数の機能を持つTodoアプリケーションでテストを実行することでTesting Libraryを利用する上で必要となる基本を理解することができました。
非同期のテスト
通常、Reactでアプリケーションを構築する場合は外部のAPIからデータを取得します。componentsフォルダにUserList.jsxファイルを作成し外部のAPIからデータを取得する際に行うテストについて確認していきます。
外部のAPIには無料で利用することができるJSONPlaceHolderを利用します。https://jsonplaceholder.typicode.com/usersにアクセスすると10名分のユーザデータがJSONで戻されます。どのようなデータが取得できるか確認したい場合はブラウザからURLに対して直接アクセスすると取得できるので確認しておいてください。
UserList.jsファイルでJSONPlaceHolderから取得したデータmap関数で展開して表示しています。
import axios from 'axios';
import { useEffect, useState } from 'react';
const UserList = () => {
const [users, setUsers] = useState([]);
const fetchUsers = async () => {
const { data } = await axios.get(
'https://jsonplaceholder.typicode.com/users'
);
setUsers(data);
};
useEffect(() => {
fetchUsers();
}, []);
return (
<>
<h1>ユーザ一覧</h1>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</>
);
};
export default UserList;
App.jsからUserList.jsファイルをimportします。
import UserList from './componets/UserList';
function App() {
return (
<div style={{ margin: '2em' }}>
<UserList />
</div>
);
}
export default App;
ブラウザから確認するとユーザの一覧が表示されます。
UserListコンポーネントの”ユーザ一覧”が表示されているか確認するためにテストを実行します。h1タグを利用して”ユーザ一覧”が表示されているのでgetByRoleを利用しています。
import { render, screen } from '@testing-library/react';
import UserList from '../UserList';
describe('UserList', () => {
it('render UserList', () => {
render(<UserList />);
const headingElement = screen.getByRole('heading', {
name: /ユーザ一覧/i,
});
expect(headingElement).toBeInTheDocument();
});
});
テストを実行すると”PASS”します。
次にJSONPlaceHolderから取得したユーザデータが表示されているか確認するためにliタグを持つ要素を取得するためにgetAllByRoleを利用します。複数要素があるのでgetByRoleではなくgetAllByRoleを利用します。
it('should render users list', () => {
render(<UserList />);
const listElements = screen.getAllByRole('listitem');
expect(listElements[0]).toBeInTheDocument();
});
テストを実行するとテストに”FAIL”します。表示するメッセージを確認すると”listitem”を見つけることができていません。axiosを利用してJSONPlaceHolderからデータを取得する際に非同期通信を行なっているので非同期の対応を行う必要があります。
● UserList › should render users list
TestingLibraryElementError: Unable to find an accessible element with the role "listitem"
本文書の前半で要素を見つけるためのQueryにはget, query, findがあることを説明しました。その中でfindはasync, await関数と利用できることを思い出してください。
getAllByRoleからfindAllByRoleに変更してasync, awaitを追加します。
it('should render users list', async () => {
render(<UserList />);
const listElements = await screen.findAllByRole('listitem');
expect(listElements[0]).toBeInTheDocument();
});
テストを再実行すると今度はテストに”PASS"します。
axiosのモック化
先ほどはテストの中で実際にJSONPlaceHolderにアクセスを行なっていましたが外部にアクセスを行うことなくMockを利用することでテストを行うことができます。Mockを利用するとaxion.getリクエスト によって実際のAPIにリクエストしてデータを取得するのではなく代わりに事前に設定したダミーデータを取得することでテストを行うことができます。
UserList.jsファイル内の下記のaxiosのgetリエクストが実行されると下記でこれから設定を行うダミーデータが戻されることになります。
axios.get('https://jsonplaceholder.typicode.com/users');
UserList.test.jsファイルにjest.mock()を追加します。axiosを利用するのでaxiosもimportしておきます。
import { render, screen } from '@testing-library/react';
import UserList from '../UserList';
import axios from 'axios';
jest.mock('axios');
最初はユーザの一覧を取得するテストのみ実行します。
import { render, screen } from '@testing-library/react';
import UserList from '../UserList';
import axios from 'axios';
jest.mock('axios');
describe('UserList', () => {
it('should render users list', async () => {
axios.get.mockResolvedValue({ data: [{ name: 'John Doe', id: 1 }] });
render(<UserList />);
const listElements = await screen.findAllByRole('listitem');
screen.debug();
expect(listElements[0]).toBeInTheDocument();
});
});
UserListコンポーネントの中でaxiosのgetリクエストが実行されるとaxios.get.mockResolvedValueの引数に設定されているダミーデータがPromiseで戻されることになります。ダミーデータが本当に戻されているかはscreen.debugで確認します。
テストを実行すると”PASS”します。screen.debugで表示される内容を確認するとダミーデータがliタグの中に入っていることのでテストに”PASS”できることが理解できます。
console.log
<body>
<div>
<h1>
ユーザ一覧
</h1>
<ul>
<li>
John Doe
</li>
</ul>
</div>
</body>
mockResolvedValueではPromiseでデータが戻されましたがPromiseを設定する場合にはmockImplementationを利用します。テストの結果は同じでmockImplementationのシンタックシュガー(記述を簡単にしたもの)がmockResolvedValueです。
axios.get.mockImplementation(() =>
Promise.resolve({
data: [{ name: 'John Doe', id: 1 }],
})
);
Promiseではなくただの値を戻す場合はmockReturnValueがあります。
axios.get.mockReturnValue({ data: [{ name: 'John Doe', id: 1 }] });
UserList.jsxでasync,await関数を利用している場合はmockReturnValueでもテストは”PASS”します。async, awaitではなくthenを利用した記述の場合はテストに”FAIL”します。
thenの場合は必ずPromiseが戻される必要がありますがasync, awaitの場合はPromiseでない場合にそのまま値が戻されるからです。thenは利用した記述は下記の通りです。
axios
.get('https://jsonplaceholder.typicode.com/users')
.then((response) => setUsers(response.data));
__mock__フォルダ
Mockを利用する際にsrcフォルダに__mock__フォルダを作成し利用するモジュールと同じ名前のファイルを作成します。ここではaxios.jsとなります。
先ほどuser.test.jsで設定したaxionsのimportとmockの設定を一度削除します。
import { render, screen } from '@testing-library/react';
import UserList from '../UserList';
describe('UserList', () => {
it('should render users list', async () => {
render(<UserList />);
const listElements = await screen.findAllByRole('listitem');
screen.debug();
expect(listElements[0]).toBeInTheDocument();
});
});
axios.jsファイルに以下のコードを記述します。
export default {
get: () =>
Promise.resolve({
data: [{ name: 'Jane Doe', id: 1 }],
}),
};
npm run testを実行している場合はaxios.jsファイルを保存した瞬間にテストが実行され__mock__フォルダに保存されたMockの設定が実行されます。
その他のMockの設定
__mock__フォルダの動作確認が終わったので削除します。
下記のようにjest.mockを記述してもダミーデータが利用されテストは”PASS”します。__esModule:trueの行がないとエラーになります。
import { render, screen } from '@testing-library/react';
import UserList from '../UserList';
jest.mock('axios', () => ({
__esModule: true,
default: {
get: () => ({
data: [{ name: 'Kevin Doe', id: 1 }],
}),
},
}));
describe('UserList', () => {
it('should render users list', async () => {
render(<UserList />);
const listElements = await screen.findAllByRole('listitem');
screen.debug();
expect(listElements[0]).toBeInTheDocument();
});
});
actのメッセージ
ユーザ一覧が表示されるかどうかのテスト以外にh1タグのユーザ一覧がgetRoleByで見つけられるかのテストがありました。そのテストも一緒に実行します。
import { render, screen } from '@testing-library/react';
import UserList from '../UserList';
import axios from 'axios';
jest.mock('axios', () => ({
__esModule: true,
default: {
get: () => ({
data: [{ name: 'Kevin Doe', id: 1 }],
}),
},
}));
describe('UserList', () => {
it('render UserList', () => {
render(<UserList />);
const headingElement = screen.getByRole('heading', {
name: /ユーザ一覧/i,
});
screen.debug();
expect(headingElement).toBeInTheDocument();
});
it('should render users list', async () => {
//略
});
2つのテストは”PASS”するのですが、下記のようにactに関するエラーメッセージが表示されます。
console.error
Warning: An update to UserList inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
上記のメッセージが表示された場合はwaitFor関数を利用することでエラーが解消されます。
import { render, screen, waitFor } from '@testing-library/react';
//略
it('render UserList', async () => {
render(<UserList />);
const headingElement = screen.getByRole('heading', {
name: /ユーザ一覧/i,
});
await waitFor(() => {
expect(headingElement).toBeInTheDocument();
});
});
まとめ
テストを行う上で必要な知識はまだまだ必要となるのでこの内容ですべてのテストに対応できるわけではありませんが実際のテストで少しでも役立ててもらえればいいなと思います。