【React,Redux】JEST+testing-library+MSWで始めるテスト入門
こんにちは、@nerusanです。
本日は、テストに関することを記事にします。
皆さんはテストコードは記述していますでしょうか?
自身が所属しているプロジェクトでも最近導入したので、その時学んだことを書きます。
今回は、React, ReduxToolKitを前提としたテストを想定しております。
利用したツールは以下です。
- JEST
- テストフレームワーク
- テストの枠組みを提供(describe(), test()など)
- @testing-library/react
- Reactのコンポーネントをレンダリングし、HTMLを表示したりできる
- @testing-library/jest-dom
- 便利なカスタムマッチャが使える
- @testing-library/user-event
- キーボード操作やテキスト入力、マウス操作などユーザイベント処理を扱う
- msw
- Mock Service Workder
- サーバーをモックし、APIのダミーデータをレスポンスする
導入
以下のコマンドで導入
$ yarn add -D @testing-library/jest-dom @testing-library/react @testing-library/user-event msw
簡単なテストをしてみよう
以下簡単なコンポーネントに対して、テストコードを書いてみました。
import React from "react";
import { useQuery } from "react-query";
const Render = () => {
const [value, setValue] = React.useState("");
window.setTimeout(() => {
setValue("async data");
}, 1000);
return (
<div>
<h1>サンプル</h1>
<input type="text" />
<button>送信</button>
<button>キャンセル</button>
<p> ありがとうございました。 </p>
<p>{value}</p>
</div>
);
};
export default Render;
import React from "react";
import { render, screen } from "@testing-library/react";
import Render from "./Render";
describe("Rendering", () => {
test("Should render all the elements correctly", async () => {
// レンダリングする
render(<Render />);
expect(screen.getByRole("heading")).toBeTruthy(); // h1タグが存在する
expect(screen.getByRole("textbox")).toBeTruthy(); // inputタグが存在する
expect(screen.getAllByRole("button")[0]).toBeTruthy(); // 「送信」のbuttonタグが存在する
expect(screen.getAllByRole("button")[1]).toBeTruthy(); // 「キャンセル」のbuttonタグが存在する
// テキストから検索
// ない場合をテストするときはqueryByText
expect(screen.queryByText("test")).toBeNull();
// 非同期で取得したデータを検索
expect(await screen.findByText("async data")).toBeTruthy();
});
});
タグの取得
タグの取得にはscreen.getByText()
で取得できます。
取得の種類は以下の3種類あります。
-
getBy...
- 特定クエリに一致する要素を取得する
- 要素がない時に利用するとエラースローされてテスト失敗するので、ない場合を調べたい時は
queryBy...
を利用する
-
queryBy...
- 特定クエリが一致する要素がないことを取得する
-
findBy...
- 特定クエリに一致する要素を非同期で取得する
- 要素を取得するまで探す
詳しくは以下公式ページ参照
取得の種類は、getByText
、getByRole
、getByLabelText
、getByPlaceholderText
などがあります。
詳しくは公式のチートページ参照ください。
roleについて
各HTMLタグには、roleがあり、そちらで取得することができます。
例えば、input
タグ, textarea
タグは、textbox
というroleがあります。
つまりscreen.getByRole("textbox")
で取得することができます。
各タグのrole一覧表は以下のリンク参照。
マッチャー関数(アサーション関数)
toBeTruthy()
は要素が存在するかを判断するマッチャー関数であります。他にも、プリミティブな値を比較したりするtoBe()
、オブジェクトを比較するtoBeEqaul
などがあります。
一覧は以下参照。
また、jest-domではさらに便利なマッチャーも用意されています。
例えば、toHaveAttribute()
は、タグの属性を調べることができます。
詳細は、以下の公式ページ参照。
ユーザ操作のテスト
ユーザ操作が絡むテストは、@testing-library/user-event
を利用します。
userEvent.setup()
でインスタンス生成をして、それに対し操作のためのメソッドを呼び出します。
ユーザ操作は、非同期のため、async/await処理を施してあげる必要があります。
以下テストの例を載せまず。
- コンポーネント概要
- inputタグに入力した内容はinputとしてstateに保持
- 「アウトプット」ボタン押下
- stateのinputの状態が空文字なら何も実行しない
- stateのinputの状態が空文字でなければ、その内容がoutput関数に渡され、アウトプットされる
import React from "react";
const Render = ({ outputConsole }) => {
const [input, setInput] = React.useState("");
const outputValue = () => {
if (input) {
outputConsole(input);
}
};
const updateValue = (e) => {
setInput(e.target.value);
};
return (
<div>
<input
type="text"
placeholder="Enter"
value={input}
onChange={updateValue}
/>
<button onClick={outputValue}>アウトプット</button>
</div>
);
};
export default RenderInput;
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Render from "./Render";
// テスト後に行ってくれる処理
// テストを行うと前のテスト結果が残ってしまうことがあるので、clenup()でアンマウントさせる
afterEach(() => cleanup());
describe("入力要素のテスト", () => {
test("ユーザが入力すると、input要素のvalueがそれに応じて、更新される", async () => {
render(<Render outputConsole={jest.fn()}/>);
const inputValue = screen.getByPlaceholderText("Enter");
const user = userEvent.setup();
// ユーザータイピングイベント「test」を入力
await user.type(inputValue, "test");
expect(inputValue.value).toBe("test");
});
});
describe("ボタンを押した時のテスト", () => {
test("テキストに何も入力せず、ボタンを押してもoutput関数は呼ばれない", async () => {
const output = jest.fn();
render(<Render outputConsole={output} />);
const user = userEvent.setup();
await user.click(screen.getByRole("button"));
// 未入力のとき呼び出されない
expect(output).not.toHaveBeenCalled();
});
test("テキスト入力状態でボタンを押すと関数が1回よばれる", async () => {
const output = jest.fn();
render(<Render outputConsole={output} />);
const inputValue = screen.getByPlaceholderText("Enter");
const user = userEvent.setup();
await user.type(inputValue, "test");
await user.click(screen.getByRole("button"));
expect(output).toHaveBeenCalledTimes(1);
});
});
userEventの種類
ユーザイベントは他にも、キーボード操作やマウス操作などができます。ホバー時やファイルアップロード、ダブルクリック時などかなり細かくユーザ操作を指定することができます。
詳しくは以下の公式ページを参照ください。
関数のモック
jest.fn()
を使うことで関数をモック化する事ができます。
モック関数には、.mockプロパティがあり、モック関数呼び出し時のデータと、関数の返り値が記録されています。
const mockFn = jest.fn();
mockFn.mock.calls; // 関数がどのように呼び出されたか(配列)
mockFn.mock.results; // 返り値が何であったか(配列)
mockFn.mock.instances; // どのようにインスタンス化されたか(配列)
// output関数の返り値に'Hoge'を設定
const output = jest.fn().mockImplementation(() => "Hoge");
// 省略することも可能
const output = jest.fn(() => "Hoge");
詳細は以下参照
APIのテスト
APIのテストでは、実際のサーバーに対して行うのはよくないです。理由は、レスポンスの結果によってテスト結果が変わるためです。つまり、テストする時期・場所(通信状況が悪い場所)によってテストの結果が変わるのはよくないです。
そこで、実際にアクセスするのはなく、モックデータを返すようにしてあげます。
モックサーバーに対してリクエストをして、モックデータを返します。
このモックサーバーを実現するには、MSWというライブラリを使います。
次のコンポーネントのテストを考えます。
- コンポーネント概要
- 「APIを実行する」ボタンを押下するとユーザーデータを取得
- 取得に成功するとボタンの下にユーザー名を表示(ボタンは非活性になる)
- 取得に失敗するとボタンの下にエラーメッセージが表示
import React from "react";
import axios from "axios";
const MockServer = () => {
const [clicked, setClicked] = React.useState(false);
const [username, setUserName] = React.useState("");
const [error, setError] = React.useState("");
const fetchUser = async () => {
try {
const res = await axios.get("https://jsonplaceholder.typicode.com/users/1");
const { username } = res.data;
setUserName(username);
setClicked(prevState => !prevState);
} catch {
setError("失敗しました。");
}
};
const buttonText = clicked ? "取得しました" : "取得する";
return (
<div>
<button onClick={fetchUser} disabled={clicked}>
{buttonText}
</button>
{username && <h3>{username}</h3>}
{error && <p data-testid="error">{error}</p>}
</div>
);
};
export default MockServer;
import React from "react";
import { cleanup, render, screen } from "@testing-library/react";
import MockServer from "./MockServer";
import { rest } from "msw";
import { setupServer } from "msw/node";
import userEvent from "@testing-library/user-event";
// モックサーバーの設定
const server = setupServer(
rest.get("https://jsonplaceholder.typicode.com/users/1", (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ username: "Bred dummy" }));
})
);
// 最初に一回だけ実行される モックサーバーの起動
beforeAll(() => server.listen());
afterEach(() => {
// サーバーリセット
server.resetHandlers();
cleanup();
});
// 最後に一回だけ実行される モックサーバー終了
afterAll(() => server.close());
describe("Mock APIのテスト", () => {
test("成功時のテスト", async () => {
render(<MockServer />);
const user = userEvent.setup();
await user.click(screen.getByRole("button"));
expect(await screen.findByText("Bred dummy")).toBeInTheDocument();
expect(screen.getByRole("button")).toHaveAttribute("disabled");
});
test("リクエスト失敗時は、エラーが表示される", async () => {
// サーバーのレスポンスを書き換える(useを使うことで、ここの中だけで有効)
server.use(
rest.get(
"https://jsonplaceholder.typicode.com/users/1",
(req, res, ctx) => {
return res(ctx.status(404));
}
)
);
render(<MockServer />);
const user = userEvent.setup();
await user.click(screen.getByRole("button"));
expect(await screen.findByTestId("error")).toHaveTextContent(
"失敗しました。"
);
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.getByRole("button")).not.toHaveAttribute("disabled");
});
});
MSW
MSWでは、ダミーデータを作り、それをレスポンスとして受け取ることができます。
setServer
にその設定を書きます。リクエストするURLとそれに対するレスポンスを記述します。
そうするとなんやかのHTTPクライアント(axios, fetchなど)でリクエストを送った際に、設定したダミーデータがレスポンスとして返ってきます。
また、テストだけではなく、APIができていない時のフロントエンドでの先行開発や、Strorybookを利用する際のモックにも利用できるため、かなり便利です。
Reduxのストアを含めたテスト
Reduxをストアを含めたテストの例を示す。
- 上記Reduxのアクションが実行されるボタンが3つ
- 「+」ボタン 押下するとincrementアクションが実行され、modeに応じでステート値を増やす
- 「ー」ボタン 押下するとdecrementアクションが実行され、modeに応じてステート値を減らす
- 「値を追加」ボタンと入力欄 ボタン押下すると入力欄の入力値を引数としたincrementByAmountアクションが実行され、modeに応じたかつ入力した値がステートに追加される
- 計算結果value を表示
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
export const customCounterSlice = createSlice({
name: "customCounter",
initialState: {
// 数によって計算処理の方法を変える
mode: 0,
// 計算値
value: 0,
},
reducers: {
increment: (state) => {
switch (state.mode) {
case 0:
state.value += 1;
break;
case 1:
state.value += 100;
break;
case 2:
state.value += 10000;
break;
default:
break;
}
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
switch (state.mode) {
case 0:
state.value += action.payload;
break;
case 1:
state.value += 100 * action.payload;
break;
case 2:
state.value += 10000 * action.payload;
default:
break;
}
},
},
});
export const { increment, decrement, incrementByAmount } =
customCounterSlice.actions;
export const selectCount = (state) => state.customCounter.value;
export default customCounterSlice.reducer;
import React from "react";
import { useSelector, useDispatch } from "react-redux";
import {
selectCount,
increment,
decrement,
incrementByAmount,
} from "./features/customCounter/customCounterSlice";
const Redux = () => {
const [number, setNumber] = React.useState(0);
const count = useSelector(selectCount);
const dispatch = useDispatch();
return (
<div>
<div>
{/* 1プラス */}
<button onClick={() => dispatch(increment())}>+</button>
<span data-testid="count-value">{count}</span>
{/* 1マイナス */}
<button onClick={() => dispatch(decrement())}>-</button>
{/* 入力値分プラス (numberが無効な値のとき0で実行)*/}
<button onClick={() => dispatch(incrementByAmount(number | 0))}>
値を追加
</button>
<input
type="text"
placeholder="Enter"
value={number}
onChange={(e) => setNumber(e.target.value)}
></input>
</div>
</div>
);
};
export default Redux;
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import Redux from "./Redux";
import { configureStore } from "@reduxjs/toolkit";
import customCounterReducer from "../src/features/customCounter/customCounterSlice";
// 実際にReduxのステートを利用
import { store } from './store';
afterEach(() => {
cleanup();
});
describe("ストアのテスト", () => {
test("「+」を3回押すとカウントは3になる", async () => {
render(
<Provider store={store}>
<Redux />
</Provider>
);
const user = userEvent.setup();
await user.click(screen.getByText("+"));
await user.click(screen.getByText("+"));
await user.click(screen.getByText("+"));
expect(screen.getByTestId("count-value")).toHaveTextContent(3);
});
test("入力要素に値50を直接代入し、ボタンを押したらカウント50になる", () => {
render(
<Provider store={store}>
<Redux />
</Provider>
);
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("Enter"), "50");
await user.click(screen.getByText("値を追加"));
expect(screen.getByTestId("count-value")).toHaveTextContent(50);
});
});
ストアの利用
ストアを利用する際は、コンポーネントをProviderコンポーネントでラッピングする必要があります。そうすることで、ストアのテストも有効にすることができます。
また、その時に利用するstoreは実際に利用しているstoreを利用します。そうすることで、本番同様のストアを利用してテストができます。
テストを作る時に気をつけていること
- 1テスト1アサートにする
- 例外がでた時点で、以降のテストが実行されない
- どのアサートが失敗したのかがすぐわからない
- ただ複数成功しないといけないとテストは例外
// NG
test('test',() => {
expect().toBe();
expect().toBe();
})
// OK
test('test1',() => {
expect().toBe();
})
test('test2',() => {
expect().toBe();
})
- テストコードにおける重複も無くす
- beforeEach,afterEach,beforeAllなどを適切に利用する
// NG
test("1", () => {
render(<Conponent />);
expect(screen.getByText("hoge")).toBeTruthy();
});
test("2", () => {
render(<Conponent />);
expect(screen.getByText("foo")).toBeTruthy();
});
// OK
beforeEach(() => {
render(<Conponent />);
});
test("1", () => {
expect(screen.getByText("hoge")).toBeTruthy();
});
test("2", () => {
expect(screen.getByText("foo")).toBeTruthy();
});
-
テスト書きやすいコードはなになのかを考えながらコードを書く
- 1メソッド1つのことやっているのか?
- 1つのメソッドが小さいか?
- 設計を学ぶ
-
仕様がわかるように書き、その後具体例のテストを書く
- ネストで区切ってあげると、みやすい
- ドキュメントにもなるし、初めてみた人でのすぐキャッチアップできる
// どう言ったクラス、コンポーネント、関数なのか?
describe('これは〜するコンポーネント(関数、クラス)', () => {
// どう言ったことをする機能を持っているのか?
describe('〜(引数)を〜(返り血)にする', () => {
// 具体例な実際の値に対してどうするのか?
test('〜が「true」の場合、〜になる', () => {
});
});
});
- 適当に書くのではなく、厳選して書く
- 必要最低限する
- 同じようなテストは書かない
- メンテナンスコスト、開発コストがかかりかえって技術負債になる可能性がある
- 例)境界値の部分はバグになりやすいため、網羅するなど
まとめ
以上、簡単なテストと、テストを書く際の観点をまとめてみました。
参考
Discussion