🐥

Reactテスト入門(Jest, React Testing Library,MSW)

に公開

はじめに

普段はReact.jsやNext.jsを使用していますが、テストを書いたことがなかったので自分の為の忘備録、はじめてReactでテストを書く方のためになればと思い本記事を書かせていただきました。

テストとは

アプリケーションの動作を検証するものであり、品質や信頼性を高めるために行います。
テストには大きく分けてStatic test,Unit test,Integration test,E2E testがあります。
下記の下に行けば行くほど実装コストが高くなり、信頼性が高くなります。

  1. Static test(静的テスト)
    コードを実行せずにするテストのことです。以下のような技術が用いられます。
    • Eslint
    • TypeScript
       
  2. Unit test(単体テスト)
    モジュールやコンポーネントなどの機能単位の動作を単体で確認するテストです。
    本記事で取り扱います。以下のような技術が用いられます。
    • Jest
    • React Testing Library
    • Vitest
       
  3. Integration test(統合テスト)
    単体テストと異なり、複数ユニットの連携のテストです。
    本記事で取り扱います。単体テストと同じ技術が用いられます。
     
  4. E2E test(エンドツーエンドテスト)
    システム全体の機能を、ユーザーの視点から確認するテストです。
    以下のような技術が用いられます。
    • Cypress
    • Playwright

テスト駆動開発(TDD)とは

TDDとはアプリケーション開発プロセスの手法で、テストファーストな開発手法です。
具体的なソースを書く前に、そのコードが期待通りに機能することを確認するテストから書き始めます。
品質の向上やバグの早期発見などのメリットがあります。
以下のような順序を繰り返すことで開発を行います。

  1. 失敗(レッド)
    実装したい機能のテストを作成します。
    テストの対象となるコードがないので失敗します。
  2. 成功(グリーン)
    テストの対象となるコードを、成功するように作成します。
  3. 改善(リファクタリング)
    コードをリファクタリングし、保守しやすく効率的なコードに変更します。

ただし、本記事では理解のしやすさに焦点を当てたいため、テスト対象のコードを書いてからテストを記述します。

開発環境作成

Reactのひな型を作成します。

npx create-react-app@latest test --template typescript

package.jsonを確認するとJestとReact Testing Libraryがあらかじめインストールされていることがわかります。

"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",

テストの実行

Reactのひな型を確認するとApp.test.texというファイルがあるので開いてみます。

test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});

このコードはAppコンポーネントの中にlearn reactを含む記述があるかどうかのテストです。
また、Appコンポーネントを開くと実際にその記述があることが確認できるため、テストには合格するはずです。
実際にテストを実行してみます。

npm run test

 

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.

pを押してApp.test.tsxを選択します。

PASS  src/App.test.tsx
√ renders learn react link (17 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.748 s, estimated 1 s
Ran all test suites matching /a/i.

Watch Usage: Press w to show more.

PASSしているのでテストに合格していることがわかります。

基本的なtestの書き方

import { render, screen } from "@testing-library/react";
import Greet from "./Greet";

describe("test rendering greed", () => {
  test("test rendering the Hello", () => {
    render(<Greet />);
    const textElement = screen.getByText("Hello");
    expect(textElement).toBeInTheDocument();
  });

  test("test rendering the こんにちは", () => {
    render(<Greet />);
    const textElement = screen.getByText("こんにちは");
    expect(textElement).toBeInTheDocument();
  });
});

test(name, fn, timeout?)

テストの実行に必要なメソッドです。
第一引数にテスト名、第二引数に実行する関数、第三引数はテストのタイムアウト時間で任意、かつデフォルトは5秒です。

render(value)

テストの対象となるコンポーネントを設定します。

expect(value)

後ろにマッチャーをつけることでテストの評価条件を設定します。

describe(name, fn)

テストをグループ分けすることができます。また、階層構造を持たせることもできます。
ターミナルには下のように表示されます。

PASS  src/components/greet/Greet.test.tsx
test rendering greed
√ test rendering the Hello (11 ms)
√ test rendering the こんにちは (1 ms)

React Testing Library Queriesについて

React Testing Libraryで用意されているクエリについてご紹介します。

getメソッドについて

要素が存在しているかどうかをテストするために用います。

以下のコンポーネントを例にとります。

const Contact = () => {
  return (
    <div>
      <h1>お問い合わせフォーム</h1>
      <p>すべてのフィールドは必須項目です</p>

      <form>
        <div>
          <label htmlFor="name">お名前</label>
          <input type="text" id="name" placeholder="田中太郎"/>
        </div>

        <div>
          <label htmlFor="questions">お問い合わせ内容</label>
          <select id="questions">
            <option value="">お問い合わせ内容を選択してください</option>
            <option value="dev">開発案件のご相談</option>
            <option value="video">撮影のご相談</option>
            <option value="sns-marketing">SNSマーケティングのご相談</option>
          </select>
        </div>
        <div>
          <label>
            <input type="checkbox" id="terms" />
            利用規約に同意します
          </label>
        </div>
      </form>
    </div>
  );
};

export default Contact;

getByRole(role, options?)

HTMLのタグのロールを指定することによって、単一の要素を検索するためのクエリです。
ロールについてはこちらを参照してください。

describe("getByRole", () => {
  // textbox
  test("test render input textbox", () => {
    render(<Contact />);
    const textBoxElement = screen.getByRole("textbox");
    expect(textBoxElement).toBeInTheDocument();
  });

  // select
  test("test render select", () => {
    render(<Contact />);
    const selectElement = screen.getByRole("combobox");
    expect(selectElement).toBeInTheDocument();
  });

  // h1
  test("test render h1", () => {
    render(<Contact />);
    const h1Element = screen.getByRole("heading", {
      level: 1,
    });
    expect(h1Element).toBeInTheDocument();
  });

  // option
  test("test render option", () => {
    render(<Contact />);
    const optionElement = screen.getByRole("option", {
      name: "開発案件のご相談",
    });
    expect(optionElement).toBeInTheDocument();
  });

  // checkbox
  test("test render checkbox", () => {
    render(<Contact />);
    const checkboxElement = screen.getByRole("checkbox");
    expect(checkboxElement).toBeInTheDocument();
  });
});

同一のタグが複数ある場合はoptionsを用います。
上のテストではh1タグを検索するためにロールのheadingを指定していますが、h1とh2があるためオプションを指定しないと単一のタグを取得できないためエラーとなります。
そこで、level:1とオプションを指定することでh1タグのみを取得しています。
optionタグも複数あるため、name:"開発案件のご相談"とオプションを指定することで単一のタグを取得しています。

getByLabelText(text, optons?)

labelタグのテキストから単一の要素を検索するためのクエリです。

describe("getByLabelText", () => {
  // textbox
  test("test render text label", () => {
    render(<Contact />);
    const textboxElement = screen.getByLabelText("お名前");
    expect(textboxElement).toBeInTheDocument();
  });

  // checkbox
  test("test render checkbox label", () => {
    render(<Contact />);
    const checkboxElement = screen.getByLabelText("利用規約に同意します");
    expect(checkboxElement).toBeInTheDocument();
  });
});

getByPlaceholderText(text, optons?)

inputタグのプレースホルダのテキストから単一の要素を検索するためのクエリです。

describe("getByPlaceholderText", () => {
  // textbox
  test("test render input textbox", () => {
    render(<Contact />);
    const textboxElement = screen.getByPlaceholderText("田中太郎");
    expect(textboxElement).toBeInTheDocument();
  });
});

getByText(text, optons?)

テキストから単一の要素を検索するためのクエリです。

describe("getByText", () => {
  // textbox
  test("test render input textbox", () => {
    render(<Contact />);
    const textboxElement = screen.getByText("お名前");
    expect(textboxElement).toBeInTheDocument();
  });
});

getAllByRole(role, options?)

HTMLのタグのロールを指定することによって、複数の要素を検索するためのクエリです。
以下のコンポーネントを例にとります。

type SkillsProps = {
  skills: string[];
};

const Skills = (props: SkillsProps) => {
  const { skills } = props;
  return (
    <div>
      <p>Skills</p>
      <ul>
        {skills.map((skill: string) => (
          <li key={skill}>{skill}</li>
        ))}
      </ul>
    </div>
  );
};

export default Skills;

 

describe("skills", () => {
  const skills = ["HTML", "CSS", "JavaScript"];

  test("test render skills", () => {
    render(<Skills skills={skills} />);
    const listElement = screen.getAllByRole("listitem");
    expect(listElement).toHaveLength(3);
  });
});

複数のliタグを取得し、その要素数が3つであるということがわかります。

queryメソッドについて

今まではgetを使用することでその要素が存在しているかどうかをテストしていました。
queryは存在していないことをテストすることができます。

queryByRole(role, options?)

import { useState } from "react";

const Login = () => {
  const [isLogin, setIsLogin] = useState<boolean>(false);

  return (
    <div>
      {isLogin ? (
        <button onClick={() => setIsLogin(false)}>ログアウト</button>
      ) : (
        <button onClick={() => setIsLogin(true)}>ログイン</button>
      )}
    </div>
  );
};

export default Login;

 

describe("login", () => {
  // loginボタンが存在するテスト
  test("test render the login button", () => {
    render(<Login />);
    const loginButton = screen.getByRole("button", {
      name: "ログイン",
    });
    expect(loginButton).toBeInTheDocument();
  });
  // logoutボタンが存在しないテスト
  test("test not render the logout button", () => {
    render(<Login />);
    const logoutButton = screen.queryByRole("button", {
      name: "ログアウト",
    });
    expect(logoutButton).not.toBeInTheDocument();
  });
});

デフォルトではisloginがfalseに設定されているのでログアウトボタンは表示されていません。
queryByRoleを用いてログアウトボタンが表示されていないかをテストしています。

findメソッドについて

非同期で要素が表示する場合に用います。

findByRole(role, options?)

import { useEffect, useState } from "react";

const Login = () => {
  const [isLogin, setIsLogin] = useState<boolean>(false);

  useEffect(() => {
    setTimeout(() => {
      setIsLogin(true);
    }, 1600);
  }, []);

  return (
    <div>
      {isLogin ? (
        <button onClick={() => setIsLogin(false)}>ログアウト</button>
      ) : (
        <button onClick={() => setIsLogin(true)}>ログイン</button>
      )}
    </div>
  );
};

export default Login;

 

describe("find", () => {
  test("test logout button rendering after 1600ms", async () => {
    render(<Login />);
    const logoutButton = await screen.findByRole(
      "button",
      {
        name: "ログアウト",
      },
      {
        timeout: 2000,
      }
    );
    expect(logoutButton).toBeInTheDocument();
  });
});

1600ms後にログアウトボタンが表示されることをテストしています。
なおfindByRoleの受付時間はデフォルトだと1500msなので、オプションでtimeoutを2000msに延長しています。

userEventについて

React Testing Libraryには、ユーザーが実際に行う操作をシミュレートするためのuserEventというコンパニオンライブラリがあります。
ここではいくつかメソッドをご紹介します。

click(element, eventInit, options)

ユーザーがボタンをクリックしたというシミュレーション。

import { useState } from "react";

const Counter = () => {
  const [count, setCount] = useState<number>(0);
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

export default Counter;

 

describe("counter", () => {
  test("test initial count is 0", () => {
    render(<Counter />);
    const count = screen.getByRole("heading", {
      level: 1,
    });
    expect(count).toHaveTextContent("0");
  });

  test("test count is 1 after clicking the button", async () => {
    render(<Counter />);
    const user = userEvent.setup();
    const count = screen.getByRole("heading", {
      level: 1,
    });
    const buttonElement = screen.getByRole("button");
    // ユーザーにボタンを1回クリックさせる
    await user.click(buttonElement);
    expect(count).toHaveTextContent("1");
  });
});

type(element, text, options?)

ユーザーが文字を入力したというシミュレーション。

import { useState } from "react";

const Input = () => {
  const [text, setText] = useState<string>("");
  return (
    <div>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
    </div>
  );
};

export default Input;

 

describe("input", () => {
  test("test render the text", async () => {
    render(<Input />);
    const user = userEvent.setup();
    const inputElement = screen.getByRole("textbox");
    // ユーザーにaiueoと入力させる
    await user.type(inputElement, "aiueo");
    expect(inputElement).toHaveValue("aiueo");
  });
});

カスタムフックのテスト

import { useCounter } from "./hooks/useCounter";

const CustomHook = () => {
  const { count, decrement, increment } = useCounter(10);
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => decrement()}>Decrement</button>
      <button onClick={() => increment()}>Increment</button>
    </div>
  );
};

export default CustomHook;

 

import { useCallback, useState } from "react";

export const useCounter = (initialCount:number) => {

  const [count, setCount] = useState<number>(initialCount);
  const increment = useCallback(() => setCount((initialCount) => initialCount + 1), []);
  const decrement = useCallback(() => setCount((initialCount) => initialCount - 1), []);
  return { count, increment, decrement };
};

 

import { act, renderHook } from "@testing-library/react";
import { useCounter } from "./hooks/useCounter";

describe("useCount", () => {
  test("if the initial count is 0, test render the initial count", () => {
    const { result } = renderHook(useCounter, { initialProps: 0 });
    expect(result.current.count).toBe(0);
  });

  test("if the initial count is 10, test decrement and increment", () => {
    const { result } = renderHook(useCounter, { initialProps: 10 });

    // incrementを1回使用
    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(11);

    // decrementを2回使用
    act(() => {
      result.current.decrement();
      result.current.decrement();
    });

    expect(result.current.count).toBe(9);
  });
});

カスタムフックは関数コンポーネント内でしか呼び出すことはできません。
テストは関数コンポーネントではないため、renderHookを用いることで疑似的に関数コンポーネント内で呼び出している、という挙動になります。
また、戻り値のresultオブジェクトのcurrentプロパティの中にカスタムフックの戻り値が格納されています。
また、メソッドを使用する場合はact関数でラップする必要があります。

モックAPIを使用したテスト

MSW(Mock Service Worker)というJavaScript用のAPIモックライブラリがあります。
名前の通りモックであり疑似的なAPIであい、フロントエンド開発の際にバックエンドでまだ実装されていないAPIの挙動を確かめたい、テストしたいというときに役立ちます。
MSWを使用してテストを行います。
下のようなコンポーネントがあったとします。

"use client";

import { useState, useEffect } from "react";

export const Users = () => {
  const [users, setUsers] = useState<string[]>([]);
  useEffect(() => {
    fetch("api/users")
      .then((res) => res.json())
      .then((data) =>
        setUsers(data.map((user: { name: string }) => user.name))
      );
  }, []);
  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map((user) => (
          <li key={user}>{user}</li>
        ))}
      </ul>
    </div>
  );
};

まずはフェッチ前の状態をテストします。

import { render, screen } from "@testing-library/react";
import { Users } from "./Users";

describe("Users", () => {
  test("test render elements before fetching", () => {
    render(<Users />);

    // h1が表示されているかの確認
    const h1Element = screen.getByRole("heading", {
      name: "Users",
    });
    expect(h1Element).toBeInTheDocument();

    //  ulが表示されているかの確認
    const ulElement = screen.getByRole("list");
    expect(ulElement).toBeInTheDocument();

    // liが表示されていないことの確認
    const liElement = screen.queryAllByRole("listitem");
    expect(liElement).toHaveLength(0);
  });
});

フェッチ後のテストをしたいのでMSWでapi/usersというAPIのモックを作成します。
インストールします。

npm install msw@latest --save-dev

mocksフォルダにnode.tsとhandlers.tsを作成し、プロジェクト作成時に作成されているsetupTests.tsを変更します。

// node.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

 

// handlers.ts
import { rest } from "msw";

export const handlers = [
  rest.get("api/users", (req, res, ctx) => {
    return res(ctx.status(200), ctx.json([{ name: "Taro" }, { name: "Tom" }]));
  }),
];

 

// setupTests.ts
import '@testing-library/jest-dom'
import { server } from './mocks/node'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

セットアップが完了しました。
api/usersをたたくことができた場合、handlers.tsで設定した{ name: "Taro" }と { name: "Tom" }が返されるはずなのでテストしてみます。

  test("test render elements after fetching", async () => {
    render(<Users />);

    // 2つのusernameが表示されているかの確認
    const userNames = await screen.findAllByRole("listitem");
    expect(userNames).toHaveLength(2);
  });

テストにパスするのでモックが正常に動いていることがわかります。

次にフェッチに失敗した場合を想定してテストします。

"use client";
import { useState, useEffect } from "react";

export const Users = () => {
  const [users, setUsers] = useState<string[]>([]);
  const [error, setError] = useState<string | null>(null);
  useEffect(() => {
    fetch("api/users")
      .then((res) => res.json())
      .then((data) => setUsers(data.map((user: { name: string }) => user.name)))
      .catch(() => setError("Error"));
  }, []);
  return (
    <div>
      <h1>Users</h1>
      {error && <p>{error}</p>}
      <ul>
        {users.map((user) => (
          <li key={user}>{user}</li>
        ))}
      </ul>
    </div>
  );
};

フェッチに失敗した場合、Errorが表示されるかをテストします。

test("test render error fetching", async () => {
// fetch失敗時を想定
server.use(
  rest.get("api/users", (req, res, ctx) => {
    return res(ctx.status(500));
  })
);
render(<Users />);
const errorText = await screen.findByText('Error');
expect(errorText).toBeInTheDocument()
});

テストにパスするのでモックが正常に動いていることがわかります。

カバレッジについて

Jestには、コードカバレッジ(コード網羅率)を計測できる機能があります。
カバレッジとは、書いたコードの何%がテストされているかを表し、Statements、Branches、Functions、Linesの4項目があります。

  • Statements : プログラム内の各命令が実行されたかの割合
  • Branches : if文やswitch文などの分岐の処理がされたかの割合
  • Functions : プログラム内の各関数が呼び出されたかの割合
  • Lines : ソースがファイルの実行可能な行が実行されたかの割合

ただし、あくまで網羅率なので質の悪いテストでカバレッジを高めることもできてしまします。
カバレッジが高いこととアプリケーションの品質はイコールではありません。

カバレッジの表示方法

以下のコマンドを実行します。

npm test -- --coverage

 

PASS  src/components/greet/Greet.test.tsx
test rendering greed
√ test rendering the Hello (15 ms)
√ test rendering the こんにちは (2 ms)

-----------|---------|----------|---------|---------|-------------------
File       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-----------|---------|----------|---------|---------|-------------------
All files  |     100 |      100 |     100 |     100 |                   
 Greet.tsx |     100 |      100 |     100 |     100 |                   
-----------|---------|----------|---------|---------|-------------------

しきい値の設定

カバレッジの各項目にしきい値を設定し、それ以下だった場合は警告を表示することができます。
package.jsonにて設定できます。

"jest": {
    "coverageThreshold": {
      "global": {
        "statements":90,
        "branches":80,
        "functions":90,
        "lines":90
      }
    }
  }

まとめ

今回はReactのテストを学びました。
個人開発しかやったことがなかったのですが、集団で開発を行う際にテストを行う機会があると思いますのでその際に役立つと思います。
また、初めての記事投稿となりますので間違っている箇所があるかもしれません。
その場合はご遠慮なく指摘いただければと思います。

参考文献

Discussion