Masayan tech blog.

  1. ブログ記事一覧>
  2. 【Testing Libraryで始める単体・結合テスト】保守性の高いテストのTIPSやポイント

【Testing Libraryで始める単体・結合テスト】保守性の高いテストのTIPSやポイント

公開日

環境

  • macOS Monterey 12.6
  • VSCode
  • node.js v18.6.0
  • npm 8.13.2
  • Typescript ^5.0.2
  • vite ^4.4.5
  • vitest ^0.34.2

概要

  • unit, integration, and end-to-endいずれのレイヤーのテストも記述できるテストライブラリ
  • 主要なフレームワーク(React、Vue)ごとにその違いなどがdocumentにまとめられている

例)react

指導原則

指導原則とは

テストも実装と同じくらい、書き始めのころは正解やベストプラクティスがわからない。testing-libraryでは、親切に指導原則を用意してくれているので、基本はテストを書くときはこの原則に従っておけば間違いない。

指導原則の概要

より信頼性の高いテストを書くためには、テストがソフトウェアの使用方法に似ていれば似ているほどいいとされており、具体的にはユーザーの操作に近い形でテストを書くことを重要視している。

(テストの際に、アプリのインスタンスなどへのアクセスするのではなく、実際のDOMに対する操作に近いほどいい)

理由としては、ユーザーの関心の薄い機能ではなく実装の変更がテストに含まれていると、関数やコンポーネントのリファクタリング によって簡単にテストが壊されてしまうというのと、テストの可読性が下がってしまうというのが挙げられる。

テスト例

以下は、いい例とされているケース。要素を取得する際は、実際にユーザーがブラウザから要素を見つける時と同じ形で記述するのがいい。具体的には、getByRoleやgetByLabelTextなどを優先的に使い、getByTestIdやgetByAltTextなどの通常ユーザーから見えない方法で要素を取得するのは好ましくない

import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import TextInput from "@/components/TextInput";

test("TextInputコンポーネントのテスト", async () => {
  const mockOnChange = vi.fn(); // propsとして渡すonChangeをモックに変える

  const { getByLabelText, getByRole } = render(
    <TextInput label="氏名" id="name-input" value="" onChange={mockOnChange} />
  );

  // labelが正しく表示されているか
  const nameLabel = getByLabelText("氏名");
  expect(nameLabel).toBeInTheDocument();

  // input要素を取得し、値を入力
  const input = getByRole("textbox");
  await userEvent.type(input, "大阪 太郎");

  // memo: onChangeをモックにしているので、valueは変更されない。これを保証するのはこのコンポーネントを使う側のテスト
  expect(input).toHaveValue("");

  // モック関数が正しく呼び出されたか
  expect(mockOnChange).toHaveBeenCalledTimes(5); // '大阪 太郎' の各文字がイベントとしてトリガーされるため 5回呼び出される
});

また、コンポーネントの内部状態(state,ref,reactive)やコンポーネントの内部メソッド(setStateなど)にアクセスするようなテストは好ましくない。なぜなら、ユーザーにはそのような概念は関心がないことであるため。幸い、testing-libraryを使用していれば、そのようなテストをすることは難しくなっている。(特に意識しなくても良くなっている)

input要素に文字を入力する場合、testing-libraryだと、userEvent.typeのように実際のユーザーの操作に近い記述だが、実装の詳細に近い記述が必要なvue-test-utilsを用いた場合だと、以下のようにsetValueなどのメソッドを使わないといけない。(valueをセットするというのはtypeと比べてユーザーの操作から遠い)

import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import TextInput from "../../src/components/TextInput.vue";

describe("TextInput", () => {
  it("テキストインプットに文字を入力できる", async () => {
    const wrapper = mount(TextInput);
    const input = wrapper.find("input[type='text']");

    await input.setValue("大阪太郎");

    // ↓wrapper.emitted()の返り値
    // {
    //     'update:modelValue': [ [ '大阪太郎' ] ],
    //     input: [ [ [Event] ] ],
    //     change: [ [ [Event] ] ]
    // }
    console.log(wrapper.emitted());

    // setValueしてinputvalueを確認するだけではテストとして足りない。emittedでv-modelが機能していることを検証する
    expect(wrapper.emitted("update:modelValue")).toBeTruthy(); // 未発火の場合はundefinedとなりfalsyなので失敗する
    expect(wrapper.emitted("update:modelValue")).toHaveLength(1); // 発火回数
    expect(wrapper.emitted("update:modelValue")?.at(0)).toEqual(["大阪太郎"]); //値
  });
});

環境構築

以下の記事を参照されたし。viteベースでreactコンポーネントをテストするための環境構築方法について紹介している。

【Vitestで始める単体・結合テスト】ReactコンポーネントとカスタムHooksのテストの環境構築と作成(testing-library/react)

以降では、AAAパターン(Arrange-Act-Assert)に従い、コンポーネントのレンダリング→クエリやイベントの実行→アサートの順序で紹介する

test('This is test', async () => {
  // Arrange
  // Act
  // Assert
})

コンポーネントのレンダリング

基本的な形式

コンポーネントをレンダリングしないことにはテスト始まらない。testing-libraryでは、renderメソッドによりテスト時にコンポーネントをレンダリングできる。<body><div></div></body>の中に対象のコンポーネントがレンダリングされるようになっており、必要に応じて、propsなどを指定することが可能。

renderの返り値には、DOM Testing Libraryからのクエリメソッドが、自動的に第一引数をbaseElement(コンポーネント)にバインドして返されるので、そのクエリをそのまま使用できる。バインドされるというのは、対象のクエリを実行する対象が自動的にレンダーした要素に紐づくということ。

import React from 'react';
import { render } from '@testing-library/react';

import App from './App';

describe('App', () => {
  test('renders App component', () => {
    render(<App />);
  });
});

propsを指定する場合

rende時にpropsを指定できる。書き方はプロダクトコードでコンポーネントを呼び出す時と同じ。

import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import TextInput from "@/components/TextInput";

test("TextInputコンポーネントのテスト", async () => {
  const mockOnChange = vi.fn(); // propsとして渡すonChangeをモックに変える

  const { getByLabelText, getByRole } = render(
    <TextInput label="氏名" id="name-input" value="" onChange={mockOnChange} />
  );

  // labelが正しく表示されているか
  const nameLabel = getByLabelText("氏名");
  expect(nameLabel).toBeInTheDocument();

  // input要素を取得し、値を入力
  const input = getByRole("textbox");
  await userEvent.type(input, "大阪 太郎");

  // memo: onChangeをモックにしているので、valueは変更されない。これを保証するのはこのコンポーネントを使う側のテスト
  expect(input).toHaveValue("");

  // モック関数が正しく呼び出されたか
  expect(mockOnChange).toHaveBeenCalledTimes(5); // '大阪 太郎' の各文字がイベントとしてトリガーされるため 5回呼び出される
});

クエリ

クエリとは

ページ上の要素を見つけるために Testing Library が提供するメソッド。クエリにはいくつかの形式("get"、"find"、"query")があり、その違いは、要素が見つからなかった場合にエラーを投げるか、Promiseを返して再試行するかなどが挙げられる。

一例として、getByTextは指定したテキストの要素を取得するクエリだが、要素が取得できること自体がアサート処理も含まれているので、expectで明示的にアサーションを書く代わりに暗黙的なアサーションとして利用することもできる。

import React from 'react';
import { render, screen } from '@testing-library/react';

import App from './App';

describe('App', () => {
  test('renders App component', () => {
    render(<App />);

    screen.getByText('この記事を読む');
  });
});

クエリの優先度

getByRole、getByLabelTextなどユーザーが画面上で識別できる形でのクエリを使用する。ユーザーからは見えないgetByTestIdやgetByAltTextは優先度が下がる。また、指導原則でも述べられている通り、getByTestIdはエスケープハッチとして使用することを推奨している。

https://testing-library.com/docs/queries/about/#priority

getByRole

アクセシビリティ・ツリーで公開されているすべての要素を問い合わせるために使用できる。最優先に使用する。divとかspanとかroleを持たない要素以外は大抵これで取得できる。roleについては、WAI-ARIAを参考されたし。また、getByroleは以下のように要素を取得できなかった場合、利用可能な全てのroleを表示してくれる。

Unable to find an accessible element with the role ""

Here are the accessible roles:

document:

Name "":
<body />

--------------------------------------------------
textbox:

Name "Search:":
<input
  id="search"
  type="text"
  value=""
/>

--------------------------------------------------

getByRoleのオプション

オプションを設定することで同じ複数のロールを持つ要素が存在する場合に 1 つのheadingのみフィルタリングすることが可能。オプションはオブジェクトで設定することができ、nameプロパティに h1 タグのテキストを設定する。

const headElement = screen.getByRole('heading', { name: 'Hello' });

getByTestId

使用する場合は、対象の要素にdata-testid 属性を設定する

<p data-testid="test">Test</p>

要素を取得したい場合には getByTestIdの引数にdata-testidの値を指定する

const element = screen.getByTestId('test');
expect(element).toBeInTheDocument();

クエリ一覧

クエリごとの返り値まとめ

https://testing-library.com/docs/vue-testing-library/cheatsheet#search-variants

クエリによってどのような要素が取得できるかまとめ

https://testing-library.com/docs/vue-testing-library/cheatsheet#search-types

クエリ名

役割

要素が見つからない場合の挙動

getBy...

クエリに一致するノードを返す

エラーをスロー。複数の一致する要素が見つかった場合も同様

getAllBy...

getBy...の複数バージョン。クエリに一致するすべてのノードの配列を返す。

エラーをスロー

queryBy...

クエリに一致するノードを返す。存在しない要素を後続の処理でアサートしたい場合に便利

nullを返す。複数の一致する要素が見つかった場合はエラーをスロー

queryAllBy...

queryBy...の複数バージョン。クエリに一致するすべてのノードの配列を返す。存在しない要素を後続の処理でアサートしたい場合に便利

空配列を返す

findBy...

指定されたクエリに一致する要素が見つかったときに解決されるPromiseを返す

Promiseが拒否される。また、デフォルトの1000ミリ秒のタイムアウト後に複数の要素が見つかった場合も同様

findAllBy...

findBy...の複数バージョン。指定されたクエリに一致する要素が見つかった場合に、要素の配列に解決されるPromiseを返す。

デフォルトの1000ミリ秒のタイムアウト後に要素が見つからない場合はPromiseが拒否される。

screenオブジェクト

クエリの呼び出し元については、実は以下のように2パターンあり、どちらの方法を使用するかについては公式ドキュメントで言及されていない。

screenから呼び出す

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import TextInput from "@/components/TextInput";

test("TextInputコンポーネントのテスト", async () => {
  const mockOnChange = vi.fn(); // propsとして渡すonChangeをモックに変える

  render(
    <TextInput label="氏名" id="name-input" value="" onChange={mockOnChange} />
  );

  // labelが正しく表示されているか
  const nameLabel = screen.getByLabelText("氏名");
  expect(nameLabel).toBeInTheDocument();

  // input要素を取得し、値を入力
  const input = screen.getByRole("textbox");
  await userEvent.type(input, "大阪 太郎");

  // memo: onChangeをモックにしているので、valueは変更されない。これを保証するのはこのコンポーネントを使う側のテスト
  expect(input).toHaveValue("");

  // モック関数が正しく呼び出されたか
  expect(mockOnChange).toHaveBeenCalledTimes(5); // '大阪 太郎' の各文字がイベントとしてトリガーされるため 5回呼び出される
});

renderの返り値から呼び出す

import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import TextInput from "@/components/TextInput";

test("TextInputコンポーネントのテスト", async () => {
  const mockOnChange = vi.fn(); // propsとして渡すonChangeをモックに変える

  const { getByLabelText, getByRole } = render(
    <TextInput label="氏名" id="name-input" value="" onChange={mockOnChange} />
  );

  // labelが正しく表示されているか
  const nameLabel = getByLabelText("氏名");
  expect(nameLabel).toBeInTheDocument();

  // input要素を取得し、値を入力
  const input = getByRole("textbox");
  await userEvent.type(input, "大阪 太郎");

  // memo: onChangeをモックにしているので、valueは変更されない。これを保証するのはこのコンポーネントを使う側のテスト
  expect(input).toHaveValue("");

  // モック関数が正しく呼び出されたか
  expect(mockOnChange).toHaveBeenCalledTimes(5); // '大阪 太郎' の各文字がイベントとしてトリガーされるため 5回呼び出される
});

しかし、以下のプルリクエストでのscreenが追加された経緯や

Kent C. Doddsさんのブログ(アンチパターン集)を見ると、screenメソッドを使用すべきであるということがわかる。理由はいくつかあるようだが、主なものとしては1つテストファイルの中に複数のテストケースがある場合、毎テストケースごとにrenderの返り値を用意するよりも、globalにscreenオブジェクトを一度ファイルの先頭でimportする方が簡潔に記述できるということである。

https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#not-using-screen

withinによる追加バインド

withinメソッドを使用すると、コンテナ(レンダリングしたコンポーネントのDOM全体)からさらにDOM要素を限定してクエリを実行することが可能

公式Docより引用

import {render, within} from '@testing-library/react'

const {getByText} = render(<MyComponent />)
const messages = getByText('messages')
const helloMessage = within(messages).getByText('hello')

ユーザーアクション

概要

testing-library組み込みでは、fireEventが使用できる。文字入力用のfireEvent.change、クリック用のfireEvent.clickなど。ユーザーアクションには、fireEventの他、testing-library/user-eventというライブラリが存在する。

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

fireEventとtesting-library/user-event

fireEventはDOMイベントを発火させるための関数で、user-eventはユーザーが実際に行う操作をシミュレートするための関数。指導原則に従うのであれば後者の方が好ましい記述が書ける(実際のブラウザに近い振る舞いをする)

なお、user-eventはfireEventと完全に互換性があるわけではないが、たいていの処理はできるようになっている。

コード例

user-eventでのコード例を示す。なお、user-eventのインタラクティブな処理は非同期処理なのでasync-awaitを使用する必要があるので注意。

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import TextInput from "@/components/TextInput";

test("TextInputコンポーネントのテスト", async () => {
  const mockOnChange = vi.fn(); // propsとして渡すonChangeをモックに変える

  render(
    <TextInput label="氏名" id="name-input" value="" onChange={mockOnChange} />
  );

  // labelが正しく表示されているか
  const nameLabel = screen.getByLabelText("氏名");
  expect(nameLabel).toBeInTheDocument();

  // input要素を取得し、値を入力
  const input = screen.getByRole("textbox");
  await userEvent.type(input, "大阪 太郎");

  // memo: onChangeをモックにしているので、valueは変更されない。これを保証するのはこのコンポーネントを使う側のテスト
  expect(input).toHaveValue("");

  // モック関数が正しく呼び出されたか
  expect(mockOnChange).toHaveBeenCalledTimes(5); // '大阪 太郎' の各文字がイベントとしてトリガーされるため 5回呼び出される
});

マッチャー

テストを実行した結果、期待通りの正しい値を持っているかを調べる関数

toBeInTheDocumentやtoBeVisible、toHaveValueなど

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import TextInput from "@/components/TextInput";

test("TextInputコンポーネントのテスト", async () => {
  const mockOnChange = vi.fn(); // propsとして渡すonChangeをモックに変える

  render(
    <TextInput label="氏名" id="name-input" value="" onChange={mockOnChange} />
  );

  // labelが正しく表示されているか
  const nameLabel = screen.getByLabelText("氏名");
  expect(nameLabel).toBeInTheDocument();

  // input要素を取得し、値を入力
  const input = screen.getByRole("textbox");
  await userEvent.type(input, "大阪 太郎");

  // memo: onChangeをモックにしているので、valueは変更されない。これを保証するのはこのコンポーネントを使う側のテスト
  expect(input).toHaveValue("");

  // モック関数が正しく呼び出されたか
  expect(mockOnChange).toHaveBeenCalledTimes(5); // '大阪 太郎' の各文字がイベントとしてトリガーされるため 5回呼び出される
});

否定系の場合(〜が存在しないこと)はマッチャーの前にnotをつける

expect(colorButton).not.toHaveStyle({ backgroundColor: 'red' })

デバッグ

screenオブジェクトからdebugメソッドを実行すると、レンダリングされた要素のdomツリーをコンソール上に表示してくれる

https://testing-library.com/docs/dom-testing-library/api-debugging#screendebug

import React from 'react';
import { render, screen } from '@testing-library/react';

import App from './App';

describe('App', () => {
  test('renders App component', () => {
    render(<App />);

    screen.debug();
  });
});

コンポーネントのモック

これはjestやvitestの機能にはなるが、vi.fn()を代入したモックコンポーネントをvi.mockの中で任意のhtmlに置き換えることが可能。どうしても実際に依存しているコンポーネントが使用できない(複雑すぎる等)場合にモック化することも必要になるだろう。

import { render } from "@testing-library/react";
import { Form } from "@/components/Form";

const mockInput = vi.fn();
test("Formコンポーネントのテスト:モック使用", async () => {
  vi.mock("@/components/TextInput", () => ({
    default: () => {
      mockInput();
      return (
        <>
          <label className="mock" htmlFor={"id-1"}>
            {"ラベル名"}
          </label>
          <input type="text" value="" id="id-1" />
        </>
      );
    },
  }));

  const mockOnSubmit = vi.fn();

  render(<Form handleSubmit={mockOnSubmit} />);

  expect(mockInput).toHaveBeenCalledTimes(2);
});

非同期API通信のテスト

これもjestやvitestの機能にはなるが、コンポーネント内で非同期通信を行なっている場合は、mockを使ってテストを実装することができる。非同期通信を行うaxiosのgetメソッドごとmockに差し替え、返り値が指定した配列を返すように書き換えることで、その配列が描画されているか、期待する回数非同期通信が行われているかどうかをアサートする。

import { describe, it, expect, vi } from "vitest";
import user from "@testing-library/user-event";
import axios from "axios";
import { render } from "@testing-library/react";
import TodoList from "@/components/TodoList";

describe("TodoList", () => {
  type Todo = {
    id: number;
    title: string;
  };

  it("送信ボタンを押すとTODOリストの一覧が表示", async () => {
    const mockTodos: Todo[] = [
      { id: 1, title: "title1-1" },
      { id: 2, title: "title1-2" },
      { id: 1, title: "title2-1" },
      { id: 1, title: "title3-1" },
      { id: 2, title: "title1-2" },
    ];
    const axiosGetMock = vi
      .spyOn(axios, "get")
      .mockResolvedValue({ data: mockTodos });

    const { getByRole } = render(<TodoList />);

    const submitButton = getByRole("button");

    await user.click(submitButton);

    expect(axios.get).toHaveBeenCalledWith(
      "https://jsonplaceholder.typicode.com/todos"
    );

    expect(axiosGetMock).toHaveBeenCalledOnce();

    expect(getByRole("list").childElementCount).toBe(5);
  });
});

waitFor

waitForは例えば何かしらのインタラクティブな処理により、DOMが更新された後に期待する要素が表示されているか等を検証する際などに役立つメソッド。非同期処理を待ってから後続の処理を実行することができる。ただ、Kent C. Doddsさんのブログ(アンチパターン集)を見ると、findBy...もしくはfindAllBy... の方が簡潔に記述できるため望ましいとされている。

https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#using-waitfor-to-wait-for-elements-that-can-be-queried-with-find

コンポーネント

3000ms後にpタグのテキストがHelloWorld!になる

src/components/Sample.tsx

import { useEffect, useState } from "react";

const Sample: React.FC = () => {
  const [text, setText] = useState<string>("");

  useEffect(() => {
    setTimeout(() => {
      setText("Hello, World!");
    }, 3000);
  }, []);

  return <p>{text}</p>;
};

export default Sample;

以下は waitForを使わずに、findBy...で5000msまでは要素が見つかるまで待機するように指定した例。waitForやfindBy...などを使わず、getByなどを使用してしまうと、レンダリング直後tにpタグにテキストが存在しないため、TestingLibraryElementError: Unable to find an element with the text: Hello, World!となってしまう。

tests/components/Sample.test.tsx

import Sample from "@/components/Sample";
import { render, screen } from "@testing-library/react";

test("Sampleコンポーネントのテスト", async () => {
  render(<Sample />);

  expect(
    await screen.findByText("Hello, World!", {}, { timeout: 5000 })
  ).toBeInTheDocument();
});

※findBy...もしくはfindAllBy... はデフォルトだとtimeoutが1000msなので、必要に応じて調整が必要。

便利なツール

ブラウザ拡張機能。画面に表示されている要素を取得したい場合のクエリを教えてくれる。

添付のように、ブラウザ上の取得したい要素にカーソールを合わせると画面右側にその要素を取得するためのクエリを表示してくれる。要素を取得する際に毎回自分で考える必要がないのでとても便利。

eslint関連

testing-library用のlinter

jest-dom用のlinter

まとめ

いかがでしたでしょうか。本記事では、Testing Libraryを用いてコンポーネントの単体・結合テストを作成する際に、保守性の高いテストを作成するためのTIPSやポイントについて紹介しています。公式ドキュメントの指導原則などに沿った比較的信頼性の高い内容を元に説明していますので、ぜひ参考にしてテストを作成してみてください。