はじめに
自分は2021年に新卒でweb系の開発会社にフロントエンジニアとして入社し2022年で2年目になります。
実務ではReact×TypeScriptを利用したフロント周りの開発をメインで行なっていなす。
今回は実務でNext.jsプロジェクトにテストを導入することになり「React-Testing-Library」と「Jest」について改めて学び直したのでその内容を紹介します。
はじめに「React-Testing-Library」と「Jest」の概要を説明しその上で具体的なテストコードを何パターンか書いていきます。
この記事の対象者
- フロントエンドのテストの概要を知りたい人
- React-Testing-LibraryとJestについて知りたい人
- 具体的なテストの書き方を学びたい人
なお本記事では、React-Testing-Libraryの具体的な書き方についてをメインにしているので「そもそもテストとは何か」「なぜテストを書くのか」といったテスト自体の詳しい解説は簡単に留めています。
なぜテストを書くのか
テストを書く主な目的は、
- 開発効率と開発体験を上げる
- 開発製品の品質の担保
- 画面に不具合が発生し、ユーザーの期待通りの挙動にならないことを防ぐ
具体的にはテストを導入することで、
- 開発中に早い段階でエラーを発見しやすくし開発コストを減らすことができる
- 機能が増えた時に、既存の機能を壊さない可能性を上げる
- ユーザーの操作が期待通りかをチェックできる
といった恩恵を得ることができます。
フロントエンドテストの種類
フロントエンドのテスト手法は様々ありますが、今回はReact-testing-libraryの著者であるKent C. Dodds氏のTesting Trophyを参考に紹介していきます。
なおTesting Trophyについての詳しい解説はKent C. Dodds氏の下記の記事を読んでみてください。
Testing TrophyではTrophyの上のレイヤーに行くほど
- テストの速度は遅くなる
- 実装コストは高くなる
- テストの信頼性が高くなる
といったことが挙げられています。
ここでは簡単にTesting Trophy紹介されている4種類のテストの内容を説明します。
- Static Test (静的テスト)
- Unit Test (単体テスト)
- Integration Test (結合テスト)
- End to End Test (E2Eテスト)
Static Test (静的テスト)
静的テストとはコードのタイプミス(タイポ)や型エラーをチェックするテストです。FlowやTypeScriptなどの静的型解析を導入することでチェックすることができます。
Unit Test (単体テスト)
単体テストは個々の独立した関数やコンポーネントが動作するかをチェックするテストです。
単体テストのテストツールとしてはJestが一番人気のフレームワークになっています。
またReactコンポーネント指向においては「コンポーネントが担う責務を明確にする」という単一原則というものがあります。
この考えに基づいたコンポーネント設計(良いコンポーネント設計)をすることでユニットテストを書くことができます。
Reactのコンポーネント指向の考え方については、下記のの記事でわかりやすく解説されています。
Integration Test (結合テスト)
結合テストは各コンポーネントや関数を組み合わせた時に機能が問題なく動作するかをチェックするテストです。
結合テストのテストツールとしては、JestやReact Testing Libraryがあります。
End to End Test (E2Eテスト)
E2EテストはサーバーのAPIやブラウザ等の環境でアプリケーションを動かし、システム全体が正しく動くかをチェックするテストです。
E2Eのテストツールとしては、Cypress、Puppeteerが挙げられます。
本記事で紹介するテストについて
本記事紹介するテストツールはReactの公式サイトでも推奨されているJestとReact-testing-Libraryを使っていきます。
また今回紹介するテストは、
- 単体コンポーネントのテスト
- ブラウザでユーザーがアプリを操作する挙動のテスト
- コンポーネント間のデータの受け渡しテスト
- Reactフックのテスト
- mockAPIを使ったテスト
をコードを書きながら解説をしていきます。
Next.jsでの環境構築
今回はNext.js×TypeScriptのプロジェクトにテストを導入します。
Next.js×TypeScirptのプロジェクトを作成
任意にプロジェクトディレクトリを作成し、以下のコマンドを実行します。
このコマンドによってNext.js×TypeScriptの環境を構築します。
npx create-next-app . --typescript --use-npm
React-testing- libraryのインストールと設定
作成したプロジェクト内で以下のコマンドを実行します
npm i -D jest @testing-library/react @types/jest @testing-library/jest-dom @testing-library/dom babel-jest @testing-library/user-event jest-css-modules
babel設定ファイルにNext.jsプロジェクトにJestでテストをすることを明示
プロジェクトのルートで.babelrcを作成します。
touch .babelrc
作成したファイルに下記を追加します。
{
"presets": ["next/babel"]
}
package.jsonの設定
次にTestの対象として除外するファイルとCSSモジュールの読み込みを防ぐ記述をpackage.json
書いてきます。
"jest": {
"testPathIgnorePatterns": [
"<rootDir>/.next/",
"<rootDir>/node_modules/"
],
"moduleNameMapper": {
"\\.(css)$": "<rootDir>/node_modules/jest-css-modules"
}
}
また同様にコマンドでテストが実行できるようにpackage.json
のscript
に以下を追加
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "jest --env=jsdom --verbose", // これを追加した
"lint": "next lint"
},
--env=jsdom --verbose
のオプションを付与することでテストファイル1個1個に対してテストが通ったかを確認することができる。
これで環境構築は完了したので次の章からテストコードを書いてテストを実行していきます。
レンダリングテスト
コンポーネントのレンダリングテスト
最初はテスト対象のコンポーネントがレンダリングされているかをテストします。
まずはpages
配下にあるindex.tsx
の記述を以下のように書き換えます。
import type { NextPage } from "next";
const Home: NextPage = () => {
return <div>Hello World</div>;
};
export default Home;
この状態で以下のコマンドを叩いてlocalhost:3000
にアクセスすると「Hello World」と表示されていることが確認できます。
npm run dev
次に作成したHomeコンポーネントがレンダリングされ「Hello World」と実際に表示されるかをテストするコードを書いていきます。
まずルート配下に__tests__
ディレクトリを作成しその中にHome.test.tsx
ファイルを作成します。
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import Home from "../pages/index";
describe("rendering", () => {
it("Should render Hello text", () => {
render(<Home />);
expect(screen.getByText("Hello World")).toBeInTheDocument();
});
});
-
describe
は関連するテストをブロック単位でまとめるメソット- 第1引数にテストブロックの説明
- 第2引数にテストケースを記述するコールバック関数
-
it
は実際のテストを記述する- 第1引数はテストの説明
- 第2引数はテスト内容を記述するコールバック関数
-
render()
は引数に指定したJSXをレンダリングする -
expect
はテスト結果を評価するメソット
ちなにみexpect内のメソットは種類が豊富なので下記の公式ドキュメントを参考にしてみてください。
今回のテストコードはHomeコンポーネントをレンダリングし画面内に「Hello World」という文字が表示されるかをテストしています。
import type { NextPage } from "next";
const Home: NextPage = () => {
return <div>Hello World</div>;
};
export default Home;
実際に下記のコマンドでテストを実行すると
npm run test
次にテストファイルを「Hello Suzuki」に変更してテストを実行してみます。
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import Home from "../pages/index";
describe("rendering", () => {
it("Should render hello text", () => {
render(<Home />);
expect(screen.getByText("Hello Suzuki")).toBeInTheDocument();
});
});
該当のHomeコンポーネントには「Hello Suzuki」は表示されないのでテストは失敗します。
次はレンダリング後に対象の要素が存在するかをテストしていきます。
検索フォームのレンダリングテスト
対象の検索フォームは以下の構成で成り立っています。
- 検索ワードを入れる入力フォーム
- 検索クエリを送信するボタン
レンダリング時に入力フォーム(Input)と送信ボタン(Button)が表示されているかをテストします。
検索フォームコンポーネントの作成
まずはルート配下にcomponentsディレクトリ
を作成しSearchForm.tsx
を作成します。
※ 今回テストを目的としているためスタイルは当てていません。
import React, { useState } from "react";
export const SearchForm = () => {
// 入力フォームの値を管理するステート
const [value, setValue] = useState<string>("");
// 入力フォームの値を変更する関数
const onchange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
// 検索ボタンをクリックした際に発火する関数
const onClick = () => {
// 検索の処理
};
return (
<div>
<input type="text" onChange={onchange} value={value} />
<button onClick={onClick}>検索</button>
</div>
);
};
SearchFormコンポーネント
について
-
value
は入力フォームに入れた値を管理するstate
-
onchange
で入力した内容にstate
を更新 -
onClick
は検索ボタンをクリックした際に発火する(一旦ダミー)
検索フォームコンポーネントのテストコードを書いていく
テスト内容としてはレンダリング時に、
- 入力フォーム(input)が表示されているか
- ボタン(button)が表示されているか
をテストします。
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import SearchForm from "../pages/index";
describe("rendering", () => {
it("Should render SearchForm", () => {
render(<SearchForm />);
expect(screen.getByRole("textbox")).toBeTruthy();
expect(screen.getByRole("button")).toBeTruthy();
});
});
前半は最初に書いたテストとほとんど同じです。
-
getByRole
は指定されたロールをもつ要素を検索します。
なおHTMLタグの各ロールについては下記に掲載されています。
該当ファイルをテストするコマンドで対象のテストを実行
npm run test SearchForm.test.tsx
同様にこちらも成功することがわかります。
今はinput
やbutton
といったHTMLタグのロールをテストの中で直接指定していましたが、IDを付与してテストをすることもできます。
IDを付与してテストをするために検索フォームを以下のように書き換えます。
return (
<div>
<input
data-testid="search-input"
type="text"
onChange={onchange}
value={value}
/>
<button data-testid="search-button" onClick={onClick}>
検索
</button>
</div>
);
入力フォームにsearch-input
、検索ボタンにsearch-button
というid
を付与しました。
テストファイルは以下のように書き換えます。
describe("rendering", () => {
it("Should render SearchForm", () => {
render(<SearchForm />);
expect(screen.getByTestId("search-input")).toBeTruthy();
expect(screen.getByTestId("search-button")).toBeTruthy();
});
});
-
getByTestId
で該当のテストIDをもつ要素を取得することができます
こちらも同様にテストを実行するとパスすることが確認できます。
npm run test SearchForm.test.tsx
レンダリングテストではレンダリング時に該当のコンポーネントの要素が正しく表示されるかをテストしました。
ユーザーイベントのテスト
次にユーザーが実際にクリックしたり入力したりするユーザーイベントのテストをしていきます。
入力フォームへの入力テスト
まずは検索フォームにおいて、ユーザーが入力した値が入力フォームに入ってくるかをテストします。
テスト対象は先ほどと同じくSearchForm
コンポーネントを利用します。
import React, { useState } from "react";
export const SearchForm = () => {
const [value, setValue] = useState<string>("");
const onchange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
const onClick = () => {
// 検索の処理
};
return (
<div>
<input type="text" onChange={onchange} value={value} />
<button onClick={onClick}>検索</button>
</div>
);
};
入力フォームのテストコードを書いていく
入力フォームのテストは以下の手順で行います
- 入力フォームの要素を取得する
- 取得した入力フォームにユーザーイベントで値を入れる
- 入力フォームの値がユーザーイベントで入れた値になっているかをテストする
上記のテストコードで書いていきます。
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import userEvent from "@testing-library/user-event";
import SearchForm from "../pages/index";
describe("input form onChange event", () => {
it("input test", () => {
render(<SearchForm />);
// HTMLElementに型推論されているのでasで型アサーション
const inputValue = screen.getByRole("textbox") as HTMLInputElement;
// ユーザーが入力フォームに"test"と入力する動作をテスト
userEvent.type(inputValue, "test");
// 入力フォームの値が"test"になっているかをチェック
expect(inputValue.value).toBe("test");
});
});
詳しく解説をしていきます。
- 先ほどと同様に
getByRole
で入力フォームの要素を取得-
as
で型アサーションしています
-
-
userEvent
で入力のテストをする-
userEvent.type
でユーザーのタイピングイベントを実行 - 第1引数にイベントを行う要素の取得したInput要素を格納
- 第2引数に入力する値である
"test"
という文字を格納
-
-
expect
で入力された値がinput
に入っているかをテスト
テストを実行するとパスするのがわかります
npm run test SearchForm.test.tsx
次に、SearchFormコンポーネントのInputフォームの入力値が変えられないようにvalueを固定値に書き換えて、テストが失敗するかを確認してみます。
return (
<div>
// valueを固定値に変更する
<input type="text" onChange={onchange} value={"変わりません"} />
<button onClick={onClick}>検索</button>
</div>
);
ローカル環境を立ち上げて確認すると、入力フォームに値を入力しても固定値のため値が変わらないようになっています。
実際にテストを実行してみると失敗します。
npm run test SearchForm.test.tsx
ボタンをクリックイベントのテスト
次にユーザーが検索フォームの検索ボタンをクリックした時の挙動をテストします。
検索ボタンがクリックされたタイミングで親コンポーネントから渡ってきた関数(onSubmit
)を発火させるようにsearchForm
コンポーネントの内容を書き換えます。
import React, { useState } from "react";
type SearchFormProps = {
onSubmit: (value: string) => void;
};
export const SearchForm: React.VFC<SearchFormProps> = ({ onSubmit }) => {
const [value, setValue] = useState<string>("");
const onchange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
// 入力フォームに値がある場合はonSbmitを発火し、そうでない場合はconsoleを表示
const onClick = () => {
if (value) {
onSubmit(value);
} else {
console.log("入力フォームが空です");
}
};
return (
<div>
<input type="text" onChange={onchange} value={value} />
<button onClick={onClick}>検索</button>
</div>
);
};
また親コンポーネントはsearchForm
コンポーネントに受け渡すonSbmit
関数を作成しprops
で受け渡せるように書き換えます。
import type { NextPage } from "next";
// components
import { SearchForm } from "../components/SearchForm";
const Home: NextPage = () => {
// SearchFormコンポーネントに渡す関数
const onSubmit = (value: string) => {
console.log(value);
};
return (
<div>
<SearchForm onSubmit={onSubmit} />
</div>
);
};
export default Home;
ボタンをクリックイベントのテストコードを書いていく
挙動としては以下のようになります
- ユーザーがボタンをクリックする
- 入力フォームが空の場合は
props
で渡ってきたonSbmit
は発火しない - 入力フォームが空でなかった場合は親から渡ってきた
onSbmit
が発火する
まずはじめに入力フォームが空の場合に親から渡ってきたonSbmit
が発火しないテストを書いてみます。
import { SearchForm } from "../components/SearchForm";
describe("input form onChange event", () => {
it("ボタンクリック", () => {
// propsで受け渡す用のモック関数をテスト用に作成
const onSubmit = jest.fn();
render(<SearchForm onSubmit={onSubmit} />);
// ユーザーのクリックをテスト
userEvent.click(screen.getByRole("button"));
expect(onSubmit).not.toHaveBeenCalled();
});
});
- 親から受け渡す関数をモックとして作成する(
jest.fn()
) - 作成したmock関数を
props
として渡す - ユーザーがボタンをクリックするテストを書く
- クリック時に親から渡ってきたonSbmitが発火しないことをテスト(
not.toHaveBeenCalled()
)
not.toHaveBeenCalled()
についての詳しい解説はJestの公式ドキュメントでも見れます。
テストを実行します
npm run test SearchForm.test.tsx
SearchForm
コンポーネント内の条件分岐によって親から渡ってきたonSbmit
は発火せずにconsole
が出力されていることが確認できます。
次にユーザーが入力フォームに値を入力した場合にボタンをクリックしたときの挙動をテストします。
この場合は親から渡ってきたonSbmit
が発火されます。
describe("input form onChange event", () => {
it("should trigger output function", () => {
// propsで受け渡す用のモック関数をテスト用に作成
const onSubmit = jest.fn();
render(<SearchForm onSubmit={onSubmit} />);
const inputValue = screen.getByRole("textbox");
userEvent.type(inputValue, "test");
userEvent.click(screen.getByRole("button"));
expect(onSubmit).toBeCalled;
});
});
テストを実行します
npm run test SearchForm.test.tsx
以上でユーザーイベントのテストについての解説は終了です。
次に親コンポーネントから渡ってきたpropsのデータ受け取り周りについてのテストを見ていきます。
propsでのデータ受け取りのテスト
次にprops
で配列型のデータが渡ってきてそれをmap
メソットのループで表示するテストをします。
Cardsコンポーネントの作成
まずは、props
で受け渡された配列をループで表示するためのCardsコンポーネントを作成します。
Cardコンポーネントは親コンポーネントからオブジェクト形式のユーザー情報の配列を受け取り、map
メソットで受け取った情報を全件表示するコンポーネントです。
import React from "react";
type User = {
id: number;
name: string;
};
type CardsProps = {
userInfos: User[];
};
export const Cards: React.VFC<CardsProps> = ({ userInfos }) => {
return (
<>
{userInfos.length === 0 ? (
<p>ユーザー情報は0です</p>
) : (
<ul>
{userInfos.map((userInfo) => (
<li key={userInfo.id}>
id:{userInfo.id} name:{userInfo.name}
</li>
))}
</ul>
)}
</>
);
};
※ mapの中はCardコンポーネントとして切り出した方がよいが今回は直書きしています。
親コンポーネントはCardsコンポーネントに対して配列が渡せるように書き換えます。
import type { NextPage } from "next";
// components
import { Cards } from "../components/Cards";
const userInfos = [
{ id: 1, name: "Tom" },
{
id: 2,
name: "Mary",
},
{
id: 3,
name: "Bob",
},
];
const Home: NextPage = () => {
return <Cards userInfos={userInfos} />;
};
export default Home;
Cardsコンポーネントのテストコードを書いていく
これによって該当のカードコンポーネントと配列を受け渡す親コンポーネントの準備が整いました。
下記の内容をテストしていきます。
- 配列の中身が空だった場合のテスト
- 配列の中身が存在する場合のテスト
まずは配列の中身が空だった場合のテストを書いていきます。
import { getByText, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { Cards } from "../components/Cards";
describe("cards component props pass test", () => {
it("Testing for missing array elements", () => {
// propsの配列データの中身が存在しないとき
render(<Cards userInfos={[]} />);
expect(screen.getByText("ユーザー情報は0です")).toBeInTheDocument();
});
});
props
の配列の中身が0の場合はCardsコンポーネントで「ユーザー情報は0です」と表示されるかをテスト。
npm run test Cards.test.tsx
こちらはテストが通ることが確認できます。
次に配列の中身が存在する場合に渡ってきたユーザー情報の配列が表示されるかをテストします。
少し長くなるのでコードを分けて解説をしていきます。
まずprops
で受け渡すユーザー情報の配列のダミーデータを用意します。
渡ってきたユーザー情報の配列はmap
でループされてli
の中で表示されるのでli
要素を取得する処理を記述します。
import { getByText, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { Cards } from "../components/Cards";
import { userInfo } from "os";
describe("cards component props pass test", () => {
it("Testing for the presence of array elements", () => {
// Cardsコンポーネントに渡すダミーデータ
const dummyUserInfos = [
{ id: 1, name: "tom" },
{
id: 2,
name: "mary",
},
{
id: 3,
name: "bob",
},
];
render(<Cards userInfos={dummyUserInfos} />);
// li要素を全件取得し配列に格納
const userInfos = screen.getAllByRole("listitem");
// 配列に格納された最初のli要素を取得しtextを表示する
console.log(userInfos[0].textContent);
});
});
データが取れているかを確認するためにテストコマンドを実行すると、console
の値が表示されているのが確認できます。
次に以下の手順に沿ってテストコードを追記していきます
- 取得した
userInfos
のtextContent
を配列に格納する - 受け渡したダミーデータをループで回し
li
で表示される形式にする - 上記の2つの配列を比較し一致しているかをテスト
it("Testing for the presence of array elements", () => {
// Cardsコンポーネントに渡すダミーデータ
const dummyUserInfos = [
{ id: 1, name: "Tom" },
{
id: 2,
name: "Mary",
},
{
id: 3,
name: "Bob",
},
];
render(<Cards userInfos={dummyUserInfos} />);
// liで表示される文言を取得し配列に格納
const userInfos = screen
.getAllByRole("listitem")
.map((item) => item.textContent);
// ダミーデータをliで表示される文言と形式を合わせる
const dummyItems = dummyUserInfos.map(
(item) => `id:${item.id} name:${item.name}`
);
// 上記の2つを比較し一致しているかをテスト
expect(userInfos).toEqual(dummyItems);
});
これによってprops
で受け渡されたダミーデータが実際にCards
コンポーネント内で表示されているデータと一致していることを確認できます。
Reactフックのテスト
次にReactフックのuseEffect
のテストを紹介します。
今回は下記の構成でuseEffect
を利用します。
- ブログ用のデータを外部API(JSONPlaceholder)から取得する
fetch
関数を作成 - 作成した関数を
useEffect
でレンダリング時に呼び出す - 外部APIから取得したデータを
state
へ格納 - 取得したデータをBlogページ内で表示させる
下記の内容をテストしていくコードを書いていきます。
- API通信中(dataがない時)はデータがBlogページ内には「ローディング中」と表示される
- API通信完了後は取得したデータが表示されている
まず新しくBlogページを作成します。
pages/blog.tsx
というファイルを作成します。
import type { NextPage } from "next";
// components
const BlogPage: NextPage = () => {
return <p>ブログです</p>;
};
export default BlogPage;
作成したら「localhost:3000/blog」にアクセスすすると以下の表示がされています。
次に実際に外部APIからデータを取得するfetch関数を作成し、レンダリング時に呼び出します。
少し処理が長いのでコメントアウトをつけています。
import axios from "axios";
import type { NextPage } from "next";
import { useEffect, useState } from "react";
// 取得するブログ記事の型
type Post = {
userId: number;
id: number;
title: string;
body: string;
};
const BlogPage: NextPage = () => {
// 取得したブログデータを格納するstate
const [postDate, setPostDate] = useState<Post>();
// 外部APIからブログデータを取得
const getPost = async (): Promise<Post> => {
const response = await axios.get(
"https://jsonplaceholder.typicode.com/posts/1"
);
return response.data;
};
// レンダリング時にAPIコール関数を実行し取得したデータでstateを更新
useEffect(() => {
try {
const getDate = async () => {
const result = await getPost();
setPostDate(result);
};
getDate();
} catch (e) {
console.log(e);
}
}, []);
return (
<div>
{!postDate ? (
<p>ローディング中</p>
) : (
<p>
記事ID{postDate.id}:{postDate.title}
</p>
)}
</div>
);
};
export default BlogPage;
-
getPost
でaxisoを使ってJSONPlaceholderからデータを取得します -
useEffect
内でgetPost
を呼び出し取得したデータをstateに格納します - JSX内では条件分岐でデータがない場合は「ローディング」、ある場合はデータを表示させます
これによってレンダリング時にAPIコール後にIDが1の記事が表示されるのを確認できます。
実際にテストコードを書いていきます。
最初にAPIコール中は「ローディング中」という文字が表示されるテストを書きます。
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import BlogPage from "../pages/blog";
describe("useEffect rendering", () => {
it("Should render fetch method before data is retrieved", async () => {
render(<BlogPage />);
expect(screen.getByText("ローディング中")).toBeInTheDocument();
});
});
テストを実行します。
npm run test Blog.test.tsx
次にAPIコール終了後データが表示されているかを確認します。
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import BlogPage from "../pages/blog";
describe("useEffect rendering", () => {
it("Should render fetch method before data is retrieved", async () => {
render(<BlogPage />);
expect(screen.getByText("ローディング中")).toBeInTheDocument();
});
it("Should render fetch method after data is retrieved", async () => {
render(<BlogPage />);
expect(await screen.findByText(/記事ID/)).toBeInTheDocument();
});
});
実際にテストが通っていることを確認できます。
APIの挙動をMockしテストする
React-testing-libraryの公式ドキュメントでも推奨されている「Mock Service Worker*」を使います。
まずはmswをプロジェクトにインストールします。
npm i msw --save-dev
ユーザー情報を表示するコンポーネントを作成
-
JSONPlaceholder
でuser
情報を取得するgetUser
を作成 - 取得前は「データはありません」という文言を表示
- 取得に成功した場合は
user
情報を表示する - 取得に失敗した場合は
error
を表示する - なお
getUser
はbutton
をクリックした時に発火するようにする
import axios from "axios";
import type { NextPage } from "next";
import { useEffect, useState } from "react";
// 取得するブログ記事の型
type User = {
id: number;
name: string;
username: string;
email: string;
};
const UserPage: NextPage = () => {
// 取得したブログデータを格納するstate
const [user, setUser] = useState<User>();
const [error, setError] = useState<string>("");
// 外部APIからブログデータを取得
const getUser = async () => {
try {
const response = await axios.get(
"https://jsonplaceholder.typicode.com/users/1"
);
// 必要な情報だけ抽出する
const userInfo = {
id: response.data.id,
name: response.data.name,
username: response.data.username,
email: response.data.email,
};
setUser(userInfo);
} catch (e) {
setError("Request failed ");
}
};
return (
<div>
{!user && !error && (
<>
<p>データはありません</p>
<button onClick={getUser}>ユーザー情報を取得</button>
</>
)}
{user && <h3>名前: {user.name}</h3>}
{error && <p data-testid="error">{error}</p>}
</div>
);
};
export default UserPage;
実際にボタンをクリックする前は下記のように表示されています。
ボタンをクリックすると下記のようになります。
取得するエンドポイントをhttps://jsonplaceholder.typicode.com/users/0
に変更すると、存在しないuser情報なのでエラーが表示されます。
APIのMockテストを書いていく
まずはじめに先ほどインストールしたmswのsetupServer
を利用してモックのサーバーを作成します。
- エンドポイントは
https://jsonplaceholder.typicode.com/users/1
- 成功したらstatusCodeが200番とユーザーの情報が返ってくる
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { rest } from "msw";
import { setupServer } from "msw/node";
// APIサーバーを作成する
const server = setupServer(
rest.get("https://jsonplaceholder.typicode.com/users/1", (_, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
id: 1,
name: "Leanne Graham dummy",
username: "Bret dummy",
email: "Sincere@april.biz.dummy",
})
);
})
);
これでモックの擬似的なサーバーを作成することができました。
サーバーの準備をする
次に作成したサーバーの起動、リセット、終了の設定をしていきます。
-
beforeAll
でファイルが読み込まれた際にサーバーを起動する -
afterEach
で各テストケースが終わるたびにリセットする -
afterAll
で処理終了後にサーバーを停止する
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
});
afterAll(() => server.close());
テストケースを書いていく
テストするコンポーネントのJSXは以下のようになっているので、
<div>
{!user && !error && (
<>
<p>データはありません</p>
<button onClick={getUser}>ユーザー情報を取得</button>
</>
)}
{user && <h3>名前: {user.name}</h3>}
{error && <p data-testid="error">{error}</p>}
</div>
下記の内容をテストするコードを書いていきます。
- 成功した場合はh3タグに「名前: Leanne Graham dummy」が表示される
- 失敗した場合はerrorというテストIDにエラー文が表示される
- 失敗した場合はh3タグは表示されない
成功した場合
-
button
要素をクリックしてAPIをコール -
findByRole
で非同期処理をしh3タグのテキストを取得(後で詳細解説) - 取得した値がAPIのレスポンス値と一致しているかを
toEqual
でテスト
describe("mocking API", () => {
it("Fetch success Should display fetched data correctly", async () => {
render(<UserPage />);
userEvent.click(screen.getByRole("button"));
expect((await screen.findByRole("heading")).textContent).toEqual(
"名前: Leanne Graham dummy"
);
});
});
ここで今までに登場したqueryの使い方を公式ドキュメントを元にまとめます。
query | 使い方 |
---|---|
getBy | 特定のクエリに一致する要素を取得する |
queryBy | 特定のクエリに一致する要素が存在しないことを取得 |
findBy | 特定のクエリに一致する要素を非同期で取得する |
実際にテストを実行するとpassしていることが確認できます。
npm run test User.test.tsx
失敗した場合
次にAPIコールが失敗した場合のテストを書いてきます。
it("Fetch failure Should display error message", async () => {
// error用のサーバーを作成
server.use(
rest.get(
"https://jsonplaceholder.typicode.com/users/1",
(_, res, ctx) => {
return res(ctx.status(404));
}
)
);
render(<UserPage />);
userEvent.click(screen.getByRole("button"));
expect((await screen.findByTestId("error")).textContent).toEqual(
"Request failed"
);
expect(screen.queryByRole("heading")).toBeNull();
});
-
server.use
で先ほど作成したサーバーのエラー用レスポンスを作成 -
findByTestId
でerrorが表示される要素を取得 -
toEqual
で表示されるエラー文と比較 -
queryByRole
で成功時に表示されるh3タグを取得しnullであることをチェック
実際にテストを実行するとpassしていることが確認できます。
npm run test User.test.tsx