目次
- はじめに
- React Testing Libraryのコンセプト
- いろんなテストケース
- コンポーネントのテスト
- 画面を操作するコンポーネントのテスト with React Hooks
- 画面を操作するコンポーネントのテスト with Redux
- 画面を操作するコンポーネントのテスト with Redux + 外部データ取得
- 終わりに
はじめに
前回書いた記事では、Reactプロジェクトにおいて、どうテストしていけば良いか、の方針を立てることができました。ただ、この記事を書いた後、
「React Testing Libraryって実際にどうテスト書いていくんだろう。。ちょっとは書いたことあるけど、自信ないな。。」
と思いました。そこで今回は、
- React Testing Libraryってそもそもどういうものか
- React Testing Libraryを使ってどうテストを書いていくか
を調べたので、本記事にまとめます。本記事を書くきっかけとなった記事は以下になりますので、もしよろしければこちらもご覧になってみてください。
React Testing Libraryのコンセプト
React Testing Libraryの原理原則のページのトップに
The more your tests resemble the way your software is used, the more confidence they can give you.
とあります。日本語訳すると、 「テストがソフトウェアの使用方法に似ていればいるほど、より信頼性を高めることができます。」 です。
つまり、React Testing Libraryは実際のwebページの使い方に近いテストを書くことを推奨します。また、そのテストが書けるような部品を提供しています。
以上から、React Testing Libraryをざっくり説明すると
- 前提として、ユーザーの操作となるべく近いことをテストしましょうねー
- その上で、Reactに関連する部分をテストしやすくしますよー
- ユーザーの操作に関わるのはReactコンポーネントの部分
- あとはそのReactコンポーネントに関わるhooksたち
- などなど
- なので、テストを実行する役割、ではないですよー
- それは例えばJestが担い、テストランナーと呼ばれたりする
になります!
いろんなテストケース
0. 事前準備
テストを書く対象のアプリを作成します。
$ npx create-react-app react-testing-library-hands-on --template redux-typescript
$ cd react-testing-library-hands-on
1. コンポーネントのテスト
ではまずは簡単なコンポーネントを作っていきましょう。
$ touch src/features/counter/DemoCounter.tsx
$ touch src/features/counter/DemoCounter.test.tsx
import React from "react";
export const DemoCounter = () => {
return (
<div>
<div>DemoCounter</div>
<input type="text" />
<button>Click1</button>
<button>Click2</button>
</div>
);
};
ただ要素を表示するだけのコンポーネントです。ではこのコンポーネントをテストしていきましょう。
ここでは、ちゃんと全ての要素が表示されているかをテストします。テストを書く前に実際に、何がレンダリングされるか確認しましょう。
/* eslint-disable testing-library/no-debugging-utils */
import { render, screen } from "@testing-library/react";
import { DemoCounter } from "./DemoCounter";
describe("render DemoCounter", () => {
it("should render all elements", () => {
render(<DemoCounter />);
screen.debug();
});
});
一つずつ説明します。
-
render
: テストする際にコンポーネントをレンダリングするAPIです。テストするコンポーネントを引数にすると、あたかもそのコンポーネントがレンダリングされたかのように振る舞います。 -
screen
: レンダリングされている全てのDOM要素を取得できます。 -
screen.debug()
: レンダリングされているDOM要素を確認できます。
screen.debug()
を実行すると、以下が出力されるかと思います。
<body>
<div>
<div>
<div>
DemoCounter
</div>
<input
type="text"
/>
<button>
Click1
</button>
<button>
Click2
</button>
</div>
</div>
</body>
しっかりと、表示したい要素が表示されていることを確認したので、テストを書いていきます。
ここで何が表示されているか確認すると
- DemoCounter、という文字
-
type="text"
なinput - buttonが二つ
ですね。なので、これらが表示されることをテストしていきたいですが、まずはテストがちゃんと動作するかを確認したいので失敗するテストを書いていきます。
import { render, screen } from "@testing-library/react";
import { DemoCounter } from "./DemoCounter";
describe("render DemoCounter", () => {
it("should render all elements", () => {
render(<DemoCounter />);
expect(screen.getByText("DemoCounterr")).toBeInTheDocument();
expect(screen.getByRole("textbox")).not.toBeInTheDocument();
expect(screen.getAllByRole("button")).toHaveLength(3);
});
});
すると
TestingLibraryElementError: Unable to find an element with the text: DemoCounterr. 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.
というエラーが確認できました。「DemoCounterr を持つ要素を見つけることができません。」と怒られましたね。テキストをDemoCounterr→DemoCounterにして再度テストを走らせると、今度は
expect(element).not.toBeInTheDocument()
expected document not to contain element, found <input type="text" /> instead
とエラーが出ました。inputタグが表示されているということですね。 .not.toBeInTheDocument()→.toBeInTheDocument() にして再度テストを走らせると
expect(received).toHaveLength(expected)
Expected length: 3
Received length: 2
Received array: [<button>Click1</button>, <button>Click2</button>]
が表示されました。なのでテストを修正します。最終的には以下の形になります。
import { render, screen } from "@testing-library/react";
import { DemoCounter } from "./DemoCounter";
describe("render DemoCounter", () => {
it("should render all elements", () => {
render(<DemoCounter />);
expect(screen.getByText("DemoCounter")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getAllByRole("button")).toHaveLength(2);
});
});
これでようやくテストが通りました。ではここで新たに使った関数をご紹介します。
-
getByText
: 一致するテキストを持つ要素を一つ取得します。getAllByText
で一致するテキストを持つ全ての要素を取得します。 -
getByRole
: 一致するロールを持つ要素を一つ取得します。どのロールで要素が取得できるかに関してはこちらをご確認ください。role=XXXX
となっていたら取得可能で、No corresponding role
となっていたら取得不可です。 -
getAllByRole
: もう予想ついていると思いますが、一致するロールを持つ要素を全て取得します。
2. 画面を操作するコンポーネントのテスト with React Hooks
次は、画面を操作するコンポーネントのテストをしていきます。今回は、ボタンを押すと数字を足したり引いたりできる、皆さんに馴染みのある画面を対象とします。DemoCounterを以下のように編集してください。
import React, { useState } from "react";
import styles from "./Counter.module.css";
export const DemoCounter = () => {
const [count, setCount] = useState(0);
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
aria-label="Decrement value"
onClick={() => setCount((current) => current - 1)}
>
-
</button>
<span className={styles.value} data-testid="count-element">
{count}
</span>
<button
className={styles.button}
aria-label="Increment value"
onClick={() => setCount((current) => current + 1)}
>
+
</button>
</div>
</div>
);
};
ここでは、
- 最初は0が表示されていること
- 二回「+」ボタンをクリックすると、2が表示されていること
- その後に二回「-」ボタンをクリックすると、1が表示されていること
ことをテストしていきます。これ以降、一度テスト失敗させる、という動作は省略しますが、実際にテスト書く時は必ず失敗させてから、テストを書いてくださいね。
import { render, screen, fireEvent } from "@testing-library/react";
import { Counter } from "./Counter";
describe("render Counter", () => {
it("should count by button press", () => {
render(<Counter />);
expect(screen.getByTestId("count-element")).toHaveTextContent('0');
fireEvent.click(screen.getByRole("button", { name: "Increment value" }));
fireEvent.click(screen.getByRole("button", { name: "Increment value" }));
expect(screen.getByTestId("count-element")).toHaveTextContent('2');
fireEvent.click(screen.getByRole("button", { name: "Decrement value" }));
expect(screen.getByTestId("count-element")).toHaveTextContent('1');
});
});
ここで新しく出てきた関数をご紹介します。
-
getByTestId
: 一致するtest idを持つ要素を一つ取得します。ただし、これは取得したいDOM要素が他のクエリーで取得できなかった時のみ使うようにすべき、と公式ドキュメントで述べられています。 -
fireEvent
: DOMイベントを発生させるための便利なメソッドです。ユーザーが実際の画面で行う動作をテスト上で行ってくれます。このコードにあるイベントを実行できます。 -
fireEvent.click()
: 引数に渡されたDOM要素をクリックします。今回の例では「+」ボタンを二回、「-」ボタンを一回クリックしました。
これで、ユーザーが画面を操作するテストもできるようになりましたね。
3. 画面を操作するコンポーネントのテスト with Redux
Reactコンポーネントをテストしていくと、Reduxに格納してあるデータをテストすることもありますよね。次はそういった時のテストをしていきます。まずは、コンポーネントと、storeを用意していきます。
(create react appでテンプレートをredux-typescriptを指定していたら、すでに用意されているかもしれません。その場合はこの手順はスキップして構いません。)
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
export interface CounterState {
value: number;
status: "idle" | "loading" | "failed";
}
const initialState: CounterState = {
value: 0,
status: "idle",
};
export const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
},
});
export const { increment, decrement } = counterSlice.actions;
export const selectCount = (state: RootState) => state.counter.value;
export default counterSlice.reducer;
import React from "react";
import { useAppSelector, useAppDispatch } from "../../app/hooks";
import { decrement, increment, selectCount } from "./counterSlice";
import styles from "./Counter.module.css";
export const DemoCounter = () => {
const count = useAppSelector(selectCount);
const dispatch = useAppDispatch();
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
aria-label="Decrement value"
onClick={() => dispatch(decrement())}
>
-
</button>
<span className={styles.value} data-testid="count-element">
{count}
</span>
<button
className={styles.button}
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
+
</button>
</div>
</div>
);
};
さて、これで表示ができます。先ほどと同様、このコンポーネントが行うのはボタンを押した時の数字の足し引きなので、テストを変えずに動かしてみます。するとどうでしょう。以下のようなエラーが確認できました。
● render Counter › should count by button press
could not find react-redux context value; please ensure the component is wrapped in a <Provider>
DemoCounterコンポーネントをレンダリングしたは良いものの、Reduxのstoreのデータを取得しようとしています。しかし、テスト実行時にはstoreが存在しない、と言われています。そのため、エラーが起きました。
なので、storeを用意してあげましょう。
公式ドキュメントを参考にしながら、修正したテストがこちらです。
import { AnyAction, configureStore, Store } from "@reduxjs/toolkit";
import { render, screen, fireEvent } from "@testing-library/react";
import { Provider } from "react-redux";
import counterReducer from "./counterSlice";
import { DemoCounter } from "./DemoCounter";
describe("render DemoCounter", () => {
let store: Store<unknown, AnyAction>;
beforeEach(() => {
store = configureStore({
reducer: {
counter: counterReducer,
},
});
});
it("should count by button press", () => {
render(
<Provider store={store}>
<DemoCounter />
</Provider>
);
expect(screen.getByTestId("count-element")).toHaveTextContent("0");
fireEvent.click(screen.getByRole("button", { name: "Increment value" }));
fireEvent.click(screen.getByRole("button", { name: "Increment value" }));
expect(screen.getByTestId("count-element")).toHaveTextContent("2");
fireEvent.click(screen.getByRole("button", { name: "Decrement value" }));
expect(screen.getByTestId("count-element")).toHaveTextContent("1");
});
});
エラー内容の通り、 Provider
でコンポーネントを囲んだだけですね。これでReduxの値を使った、画面操作のテストができました。
4. 画面を操作するコンポーネントのテスト with Redux + 外部データ取得
次は、ユーザーが画面を操作することによって、外部データの取得をしてReduxの値を書き換える処理のテストを書いていきます。(例題のためにカウンターコンポーネントに全く関係ない外部データの取得と値を書き換える処理をしてしまいます、ご容赦ください )
まずは外部データを取得するため、axiosをインストールします。
$ npm install axios
次にテスト対象のコードを作成していきます。先程のせた src/features/counter/counterSlice.ts
と src/features/counter/DemoCounter.tsx
を以下のように変更してください。
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import axios from "axios";
import { RootState } from "../../app/store";
export interface CounterState {
value: number;
status: "idle" | "loading" | "failed";
}
const initialState: CounterState = {
value: 0,
status: "idle",
};
const mockUser = {
id: 1,
name: "Leanne Graham",
username: "Bret",
email: "Sincere@april.biz",
address: {
street: "Kulas Light",
suite: "Apt. 556",
city: "Gwenborough",
zipcode: "92998-3874",
geo: {
lat: "-37.3159",
lng: "81.1496",
},
},
phone: "1-770-736-8031 x56442",
website: "hildegard.org",
company: {
name: "Romaguera-Crona",
catchPhrase: "Multi-layered client-server neural-net",
bs: "harness real-time e-markets",
},
};
type User = typeof mockUser;
export const fetchUsers = createAsyncThunk("user/fetchUsers", async () => {
const res = await axios.get<User[]>(
"https://jsonplaceholder.typicode.com/users"
);
return res.data;
});
export const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.status = "loading";
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = "idle";
state.value += action.payload.length;
})
.addCase(fetchUsers.rejected, (state) => {
state.status = "failed";
});
},
});
export const { increment, decrement } = counterSlice.actions;
export const selectCount = (state: RootState) => state.counter.value;
export default counterSlice.reducer;
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import styles from "./Counter.module.css";
import { decrement, fetchUsers, increment, selectCount } from "./counterSlice";
export const DemoCounter = () => {
const count = useAppSelector(selectCount);
const dispatch = useAppDispatch();
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
aria-label="Decrement value"
onClick={() => dispatch(decrement())}
>
-
</button>
<span className={styles.value} data-testid="count-element">
{count}
</span>
<button
className={styles.button}
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
+
</button>
</div>
<div className={styles.row}>
<button
className={styles.asyncButton}
onClick={() => dispatch(fetchUsers())}
>
Add Async
</button>
</div>
</div>
);
};
関数fetchUsers
を新規で追加しました。
https://jsonplaceholder.typicode.com/users にgetリクエストを飛ばしている箇所があるかと思います。レスポンスの値を使用していませんが、この処理を挟むことで、
ユーザーが画面を操作することによって、外部データの取得をしてReduxの値を書き換える
を実現しています。
2022/01/12現在、 "axios": "^1.1.2"
で確認すると、Jestの実行時にES Moduleとしてビルドしてしまう影響で、テストが実行できないかと思います(筆者は Jest encountered an unexpected token
というエラーが確認できました )。そのため、package.jsonに以下の設定を加えてください。
"jest": {
"moduleNameMapper": {
"axios": "axios/dist/node/axios.cjs"
}
},
ref: https://github.com/axios/axios/issues/5101#issuecomment-1296024311
ではまずテストを追加していきましょう。「Add Async」ボタンをクリックすると、画面の値は、取得できるusersの数だけ追加されることが期待する挙動ですね。では早速そのテストを書いていきます。
import { configureStore } from "@reduxjs/toolkit";
import { render, screen, fireEvent } from "@testing-library/react";
import { Provider } from "react-redux";
import { Counter } from "./Counter";
import counterReducer from "./counterSlice";
describe("render Counter", () => {
let store;
beforeEach(() => {
store = configureStore({
reducer: {
counter: counterReducer,
},
});
});
it("should counter by button press", () => {
render(
<Provider store={store}>
<Counter />
</Provider>
);
expect(screen.getByTestId("count-element")).toHaveTextContent("0");
fireEvent.click(screen.getByRole("button", { name: "Increment value" }));
fireEvent.click(screen.getByRole("button", { name: "Increment value" }));
expect(screen.getByTestId("count-element")).toHaveTextContent("2");
fireEvent.click(screen.getByRole("button", { name: "Decrement value" }));
expect(screen.getByTestId("count-element")).toHaveTextContent("1");
// 新規追加したテスト↓
fireEvent.click(screen.getByRole("button", { name: "Add Async" }));
expect(screen.getByTestId("count-element")).toHaveTextContent("2");
});
});
さて、結果はどうでしょう。僕は以下のエラーが確認できました。
● render Counter › should counter by button press
expect(element).toHaveTextContent()
Expected element to have text content:
2
Received:
1
期待値を2としてましたが、変更なく1のままですね。
では、新規追加したテストを以下のように書き換えてみましょう。
fireEvent.click(screen.getByRole("button", { name: "Add Async" }));
await waitFor(() => {
expect(screen.getByTestId("count-element")).toHaveTextContent("2");
});
非同期関数の宣言をしていないので、awaitでエラーが出ますね。以下のように変更しましょう。
it("should count by button press", async () => {
waitForもimportしてあげましょう
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
新しく出てきた関数を説明します。
-
waitFor
: タイムアウト(設定可能)に達するまで何度でもコールバックを実行することができます。外部とのやり取りを待つ必要があるテストに有効です。
ここでは、ボタンをクリックした時に、外部とのやり取りが発生しているため、すぐには画面に反映されません。追加したアサーション( expect(screen.getByTestId("count-element")).toHaveTextContent("2");
)が成功するまで少しではありますが時間がかかるため、実行し続ける必要があります。
そこでこの waitFor
を使用すると、Reduxの値がデータの取得が終わり値の更新が完了するまでテストを実行し続けてくれるのです。結果を見てみましょう。
Expected element to have text content:
2
Received:
11
どうやら、https://jsonplaceholder.typicode.com/users
にgetリクエストを飛ばすと、10件取得できるみたいですね。元々格納されているcountは1なので、期待値を11に変更しましょう。
よって、テストが成功します。
では、このテストはこれで大丈夫でしょうか?
テストは、誰がいつ行っても同じ結果を返す、というのが原則です。
今のテストは、誰がいつ行っても同じ結果を返すでしょうか?
試しにPCのwifi を切ってテストを実行してみましょう。
以下のようなエラーが起きませんでしょうか?
● Console
console.error
Error: Error: getaddrinfo ENOTFOUND jsonplaceholder.typicode.com
at Object.dispatchError (/hogehoge/my-app/node_modules/jsdom/lib/jsdom/living/xhr/xhr-utils.js:63:19)
at Request.<anonymous> (/hogehoge/my-app/node_modules/jsdom/lib/jsdom/living/xhr/XMLHttpRequest-impl.js:655:18)
at Request.emit (events.js:388:22)
at ClientRequest.<anonymous> (/hogehoge/my-app/node_modules/jsdom/lib/jsdom/living/helpers/http-request.js:121:14)
at ClientRequest.emit (events.js:376:20)
at TLSSocket.socketErrorListener (_http_client.js:475:9)
at TLSSocket.emit (events.js:376:20)
at emitErrorNT (internal/streams/destroy.js:106:8)
at emitErrorCloseNT (internal/streams/destroy.js:74:3)
at processTicksAndRejections (internal/process/task_queues.js:82:21) undefined
at VirtualConsole.<anonymous> (node_modules/jsdom/lib/jsdom/virtual-console.js:29:45)
at Object.dispatchError (node_modules/jsdom/lib/jsdom/living/xhr/xhr-utils.js:66:53)
at Request.<anonymous> (node_modules/jsdom/lib/jsdom/living/xhr/XMLHttpRequest-impl.js:655:18)
at ClientRequest.<anonymous> (node_modules/jsdom/lib/jsdom/living/helpers/http-request.js:121:14)
● render Counter › should counter by button press
expect(element).toHaveTextContent()
Expected element to have text content:
11
Received:
1
ボタンを押した際に、外部データの取得を行う過程で、ネットに繋がっていないがために、その処理が失敗し、結果としてReduxの値が更新できませんでした。
まだ、誰がいつ行っても同じ結果を返すテストにはなっていませんね。少し改良していきましょう。
まず、以下を実行してください。
npm i --save-dev msw
or
yarn add --dev msw
そして、テストを以下のように書き換えてみてください。
import { AnyAction, configureStore, Store } from "@reduxjs/toolkit";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { Provider } from "react-redux";
import counterReducer from "./counterSlice";
import { DemoCounter } from "./DemoCounter";
import { setupServer } from "msw/node";
import { rest } from "msw";
const mockUser = {
id: 1,
name: "Leanne Graham",
username: "Bret",
email: "Sincere@april.biz",
address: {
street: "Kulas Light",
suite: "Apt. 556",
city: "Gwenborough",
zipcode: "92998-3874",
geo: {
lat: "-37.3159",
lng: "81.1496",
},
},
phone: "1-770-736-8031 x56442",
website: "hildegard.org",
company: {
name: "Romaguera-Crona",
catchPhrase: "Multi-layered client-server neural-net",
bs: "harness real-time e-markets",
},
};
const server = setupServer(
rest.get("https://jsonplaceholder.typicode.com/users", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(new Array(10).fill(mockUser)));
})
);
describe("render DemoCounter", () => {
let store: Store<unknown, AnyAction>;
beforeAll(() => server.listen());
beforeEach(() => {
store = configureStore({
reducer: {
counter: counterReducer,
},
});
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => server.close());
it("should count by button press", async () => {
render(
<Provider store={store}>
<DemoCounter />
</Provider>
);
expect(screen.getByTestId("count-element")).toHaveTextContent("0");
fireEvent.click(screen.getByRole("button", { name: "Increment value" }));
fireEvent.click(screen.getByRole("button", { name: "Increment value" }));
expect(screen.getByTestId("count-element")).toHaveTextContent("2");
fireEvent.click(screen.getByRole("button", { name: "Decrement value" }));
expect(screen.getByTestId("count-element")).toHaveTextContent("1");
// 新規追加したテスト↓
fireEvent.click(screen.getByRole("button", { name: "Add Async" }));
await waitFor(() => {
expect(screen.getByTestId("count-element")).toHaveTextContent("11");
});
});
});
beforeAllで、 server.listen()
をしています。
こうすることで、 https://jsonplaceholder.typicode.com/users にgetリクエストを飛ばしたとき、テスト環境のモックサーバーにリクエストを飛ばすよう設定できました。
再び、wifiをoffにするなどしてインターネットを切断してから、テスト実行してみてください。
今度は、無事テストが成功するかと思います。
msw
というライブラリは、 setupServer
で定義したリクエストを検知すると、そのリクエストを傍受し、代わりに自身が設定したレスポンスを返してくれます。
今回であれば、https://jsonplaceholder.typicode.com/users へのリクエストを検知した時、statusを200、データはmockUserが一つある配列を返却するようにしました。
この設定をしたおかげで、インターネットを切断してもテストが成功することになりました。これでテストは誰がいつ行っても同じ結果を返しますね!
msw
に関しては一部しか紹介できていないので、詳しく解説してくれている記事やドキュメントのリンクを貼っておきます。興味のある方は、是非見てみてください。
終わりに
本記事でご紹介するテストのパターンは以上になります。
カスタムフックやreducerの単体テストなどもご紹介しようかと思いましたが、 冒頭にも書いた通り、実際のwebページの使い方に近いテストを書くことを推奨しています。
そのため、本記事では割愛させていただきました。
とはいえ、単体テストもあると、とても安心できるアプリケーションになるので、需要があると判断できましたら、書きたいと思います。
長くなってしまいましたが、最後までご覧いただきありがとうございました!