📝

【React,Redux】JEST+testing-library+MSWで始めるテスト入門

2022/06/12に公開

こんにちは、@nerusanです。
本日は、テストに関することを記事にします。
皆さんはテストコードは記述していますでしょうか?
自身が所属しているプロジェクトでも最近導入したので、その時学んだことを書きます。

今回は、React, ReduxToolKitを前提としたテストを想定しております。
利用したツールは以下です。

  • JEST
    • テストフレームワーク
    • テストの枠組みを提供(describe(), test()など)
  • @testing-library/react
    • Reactのコンポーネントをレンダリングし、HTMLを表示したりできる
  • @testing-library/jest-dom
    • 便利なカスタムマッチャが使える
  • @testing-library/user-event
    • キーボード操作やテキスト入力、マウス操作などユーザイベント処理を扱う
  • msw
    • Mock Service Workder
    • サーバーをモックし、APIのダミーデータをレスポンスする

導入

以下のコマンドで導入

shell
$ yarn add -D @testing-library/jest-dom @testing-library/react @testing-library/user-event msw

簡単なテストをしてみよう

以下簡単なコンポーネントに対して、テストコードを書いてみました。

Render.tsx
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;
render.test.tx
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...
    • 特定クエリに一致する要素を非同期で取得する
    • 要素を取得するまで探す

詳しくは以下公式ページ参照
https://testing-library.com/docs/queries/about/#types-of-queries

取得の種類は、getByTextgetByRolegetByLabelTextgetByPlaceholderTextなどがあります。

詳しくは公式のチートページ参照ください。

https://testing-library.com/docs/dom-testing-library/cheatsheet/#queries

roleについて

各HTMLタグには、roleがあり、そちらで取得することができます。
例えば、inputタグ, textareaタグは、textboxというroleがあります。

つまりscreen.getByRole("textbox")で取得することができます。

各タグのrole一覧表は以下のリンク参照。

https://github.com/A11yance/aria-query#role-to-element

マッチャー関数(アサーション関数)

toBeTruthy()は要素が存在するかを判断するマッチャー関数であります。他にも、プリミティブな値を比較したりするtoBe()、オブジェクトを比較するtoBeEqaulなどがあります。

一覧は以下参照。

https://jestjs.io/ja/docs/expect#メソッド

また、jest-domではさらに便利なマッチャーも用意されています。
例えば、toHaveAttribute()は、タグの属性を調べることができます。

詳細は、以下の公式ページ参照。

https://github.com/testing-library/jest-dom

ユーザ操作のテスト

ユーザ操作が絡むテストは、@testing-library/user-eventを利用します。
userEvent.setup()でインスタンス生成をして、それに対し操作のためのメソッドを呼び出します。
ユーザ操作は、非同期のため、async/await処理を施してあげる必要があります。

以下テストの例を載せまず。

  • コンポーネント概要
    • inputタグに入力した内容はinputとしてstateに保持
    • 「アウトプット」ボタン押下
      • stateのinputの状態が空文字なら何も実行しない
      • stateのinputの状態が空文字でなければ、その内容がoutput関数に渡され、アウトプットされる
RenderInput.tsx
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;
Render.test.tsx
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の種類

ユーザイベントは他にも、キーボード操作やマウス操作などができます。ホバー時やファイルアップロード、ダブルクリック時などかなり細かくユーザ操作を指定することができます。
詳しくは以下の公式ページを参照ください。

https://testing-library.com/docs/user-event/intro

関数のモック

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");

詳細は以下参照

https://jestjs.io/ja/docs/mock-functions

APIのテスト

APIのテストでは、実際のサーバーに対して行うのはよくないです。理由は、レスポンスの結果によってテスト結果が変わるためです。つまり、テストする時期・場所(通信状況が悪い場所)によってテストの結果が変わるのはよくないです。

そこで、実際にアクセスするのはなく、モックデータを返すようにしてあげます。
モックサーバーに対してリクエストをして、モックデータを返します。
このモックサーバーを実現するには、MSWというライブラリを使います。

https://mswjs.io/

次のコンポーネントのテストを考えます。

  • コンポーネント概要
    • 「APIを実行する」ボタンを押下するとユーザーデータを取得
    • 取得に成功するとボタンの下にユーザー名を表示(ボタンは非活性になる)
    • 取得に失敗するとボタンの下にエラーメッセージが表示
MockServer.tsx
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;
MockServer.test.tsx
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 を表示
slice.ts
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;
Redux.tsx
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;
Redux.test.tsx
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つのメソッドが小さいか?
    • 設計を学ぶ
  • 仕様がわかるように書き、その後具体例のテストを書く

    • ネストで区切ってあげると、みやすい
    • ドキュメントにもなるし、初めてみた人でのすぐキャッチアップできる
sample.test.tsx
// どう言ったクラス、コンポーネント、関数なのか?
describe('これは〜するコンポーネント(関数、クラス)', () => {
  // どう言ったことをする機能を持っているのか?
  describe('〜(引数)を〜(返り血)にする', () => {
    // 具体例な実際の値に対してどうするのか?
    test('〜が「true」の場合、〜になる', () => {
    
    });
  });
});
  • 適当に書くのではなく、厳選して書く
    • 必要最低限する
    • 同じようなテストは書かない
    • メンテナンスコスト、開発コストがかかりかえって技術負債になる可能性がある
    • 例)境界値の部分はバグになりやすいため、網羅するなど

まとめ

以上、簡単なテストと、テストを書く際の観点をまとめてみました。

参考

https://www.youtube.com/watch?v=Q-FJ3XmFlT8&ab_channel=TDDBC-Online

GitHubで編集を提案

Discussion