LoginSignup
0
0

More than 1 year has passed since last update.

jestとReact Testing Libraryを使ってのテストコードの作成

Last updated at Posted at 2022-09-21

概要

reactアプリでのテストコードの作成手順
※React Testing Libraryを使用
参照したURL

目次

1. テスト環境の準備
2. jestテストファイルの記述
3. メソッドのテスト
4. React Testing LibraryでReactコンポーネントをレンダリングする
5. getByText関数で要素を検索する
6. その他の検索タイプ
7. queryByを使って存在しない要素を検査する
8. findByを使って非同期で取得する
9. イベントの発火をテスト
10. コールバックハンドラのテスト
11. 非同期処理(API呼び出し)のテスト

1. テスト環境の準備

開発環境の構築は以下参照
Node.js+React+Herokuでアプリ開発環境を構築する

create-react-appのコマンドでアプリ作成を行った場合jestとReact Testing Libraryは標準でインストールされている
Reactを自前でセットアップした環境の場合はjestとReact Testing Libraryをインストールする必要がある
How to test React with Jest

コマンドプロンプトで作成したアプリのfrontendフォルダに移動
「npm test」コマンドで、xxxx.test.jsファイルを対象にテストを実行してくれる

> frontend@0.1.0 test c:\heroku\jest-test\frontend
> react-scripts test
 PASS  src/App.test.js
  ● Console

/* テスト内容 */

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)

 PASS  src/Main.test.js (5.314 s)
  ● Console

/* テスト内容 */

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)


Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        6.459 s
Ran all test suites related to changed files.

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.

2. jestテストファイルの記述

describeブロックとtestブロックで構成される
describeブロック(テストスイート)が複数のテストケースの集合
testブロック(テストケース)が個別のテストを意味する
testブロックはdescribeに含めず単体で記述することも可能

React Testing Libraryを使用する場合はrenderでテスト要素をレンダリングして、screenで要素にアクセスしてテストを実行する。

App.test.js
describe('テストスイート名称', () => {
  test('テストケース名称1', () => {
    render(<App />);
    screen.debug();
  });
  test('テストケース名称2', () => {

  });
});
test('個別テストケース名称', () => {

});

3. メソッドのテスト

jestのExpectを使った値の合致を判定するテストの記述方法の例
参照
第一因数と第二因数の和を返すSumTest.jsがあり、そのメソッドの動作をテストする場合の記述

SumTest.js
function SumTest(x, y) {
  return x + y;
}
export default SumTest;
App.test.js
import SumTest from './SumTest';

describe('Test Case1', () => {
  test('sums up two values', () => {
    expect(SumTest(2, 4)).toBe(6);
  });
});
describe('Test Case2', () => {
  test('sums up two values', () => {
    expect(SumTest(2, 4)).toBe(7);
  });
});

SumTest(2, 4)の結果がtoBeの因数6と合致する'Test Case1'は成功
因数7と合致しないので'Test Case2'は失敗になる

4. React Testing LibraryでReactコンポーネントをレンダリングする

React Testing Libraryのrender関数でJSXをレンダリングし、screen.debug()でHTMLを出力する記述例

App.js
import React,{ useState,useEffect } from 'react';

function App() {

  const [search, setSearch] = useState('');

  function handleChange(event) {
    setSearch(event.target.value);
  }

  return (
    <div className="container is-fluid">
      <Search value={search} onChange={handleChange}>
        検索
      </Search>
      <p>Searches for {search ? search : '...'}</p>
    </div>
  );
}

function Search({ value, onChange, children }) {
  return (
    <div>
      <label htmlFor="search">{children}</label>
      <input
        id="search"
        type="text"
        value={value}
        onChange={onChange}
      />
    </div>
  );
}

export default App;
App.test.js
import React,{ useState,useEffect } from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';

describe('App', () => {
  test('render test', () => {
    render(<App />);
    screen.debug();
  });
});

npm testの実行結果

> frontend@0.1.0 test c:\heroku\jest-test\frontend
> react-scripts test
  console.log
    <body>
      <div>
        <div
          class="container is-fluid"
        >
          <div>
            <label
              for="search"
            >
              検索
            </label>
            <input
              id="search"
              type="text"
              value=""
            />
          </div>
          <p>
            Searches for
            ...
          </p>
        </div>
      </div>
    </body>

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)

 PASS  src/App.test.js
  App
    √ render test (84 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.921 s
Ran all test suites related to changed files.

5. getByText関数で要素を検索する

getByText関数で要素を取得してexpectのtoBeInTheDocument()で存在をチェックする
※getByTextは合致する要素が複数ある場合、最初に合致したもののみ取得するので注意が必要

App.test.js
import React,{ useState,useEffect } from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';

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

    /* 明示的にexpectのアサーションを記述 */
    expect(screen.getByText(/検索/)).toBeInTheDocument();

    /* 要素が取得出来なかった場合にエラーになるので、上記記述と同様のチェックとなる */
    screen.getByText(/検索/);

    screen.debug(screen.getByText(/検索/));

  });
});

「npm test」実行結果

> frontend@0.1.0 test c:\heroku\jest-test\frontend
> react-scripts test
  console.log
    <label
      for="search"
    >
      検索
    </label>

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)

 PASS  src/App.test.js
  App
    √ render test (130 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.371 s
Ran all test suites related to changed files.

6. getByRole関数で要素を検索する

getByRole関数で要素を取得してexpectのtoBeInTheDocument()で存在をチェックする記述例
getByRole関数はaria-label属性で要素を取得する、HTML要素には暗黙的なroleがある(button要素のbuttonなど)
利用できないroleを選択すると代案をサジェストしてくれる
※getByTextと違い合致する要素が複数ある場合エラーになる、複数ある場合は「screen.getByRole('button', {name: 'TEST'});」のようにタグの中身を name として指定する

App.test.js
import React,{ useState,useEffect } from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';

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

    expect(screen.getByRole('textbox')).toBeInTheDocument();

    screen.debug(screen.getByRole('textbox'));

  });
});

「npm test」実行結果

> frontend@0.1.0 test c:\heroku\jest-test\frontend
> react-scripts test
  console.log
    <input
      id="search"
      type="text"
      value=""
    />

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)

 PASS  src/App.test.js
  App
    √ render test (177 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.421 s
Ran all test suites related to changed files.

6. その他の検索タイプ

他にも、より要素を特定した検索タイプが存在する
getByLabelText: label for="search"
getByPlaceholderText: input placeholder="Search"
getByAltText: img alt="profile"
getByDisplayValue: input value="JavaScript"

7. queryByを使って存在しない要素を検査する

getByで検査すると要素が存在しない場合エラーになってしまうので
要素が存在しない事を確認する場合queryByを使用する
※Role,LabelText,PlaceholderText等にも使用出来る

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

import App from './App';

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

    /* JavaScriptが存在しない場合nullになるのでエラーにならない */
    expect(screen.queryByText(/JavaScript/)).toBeNull();

    /* JavaScriptが存在しない場合エラーになる */
    expect(screen.getByText(/JavaScript/)).toBeNull();
  });
});

8. findByを使って非同期で取得する

findByでまだ存在しないものの最終的に存在する要素について検査する

App.js
import React,{ useState,useEffect } from 'react';

function getMessage() {
  return Promise.resolve('testMessage');
}

function App() {

  const [message, setMessage] = useState('');

  const [search, setSearch] = useState('');

  function handleChange(event) {
    setSearch(event.target.value);
  }

  useEffect(() =>{
    const loadMessage = async () => {
      const mes = await getMessage();
      setMessage(mes);
    };
    loadMessage();
  },[])

  return (
    <div className="container is-fluid">
      <Search value={search} onChange={handleChange}>
        検索
      </Search>
      <p>Searches for {search ? search : '...'}</p>
      {message && <div>Message:{message}</div>}
    </div>
  );
}

function Search({ value, onChange, children }) {
  return (
    <div>
      <label htmlFor="search">{children}</label>
      <input
        id="search"
        type="text"
        value={value}
        onChange={onChange}
      />
    </div>
  );
}

export default App;
App.test.js
import React,{ useState,useEffect } from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';

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

    /* queryByで検査するとまだ存在しないのでnullになる */
    expect(screen.queryByText(/Message:/)).toBeNull();

    screen.debug();

    /* findByで検査すると取得することが出来る */
    expect(await screen.findByText(/Message:/)).toBeInTheDocument();

    screen.debug();

  });
});

実行結果
初回のscreen.debug()ではMessageが生成されておらず、2回目のscreen.debug()ではMessageが生成されている

  console.log
    <body>
      <div>
        <div
          class="container is-fluid"
        >
          <div>
            <label
              for="search"
            >
              検索
            </label>
            <input
              id="search"
              type="text"
              value=""
            />
          </div>
          <p>
            Searches for
            ...
          </p>
        </div>
      </div>
    </body>

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)

  console.log
    <body>
      <div>
        <div
          class="container is-fluid"
        >
          <div>
            <label
              for="search"
            >
              検索
            </label>
            <input
              id="search"
              type="text"
              value=""
            />
          </div>
          <p>
            Searches for
            ...
          </p>
          <div>
            Message:
            testMessage
          </div>
        </div>
      </div>
    </body>

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)

 PASS  src/App.test.js
  App
    √ render test (116 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.361 s
Ran all test suites related to changed files.

9. イベントの発火をテスト

React Testing LibraryのfireEvent関数を使ってinputに値が入力された際の動作をテストする事が出来る

App.test.js
import React,{ useState,useEffect } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import App from './App';

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

    await screen.findByText(/Message:/);

    /* Searches for inputValueは検出出来ずnullになる */
    expect(screen.queryByText(/Searches for inputValue/)).toBeNull();

    screen.debug();

    /* inputのvalueをinputValueに変更 */
    fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'inputValue' },
    });

    /* Searches for inputValueが検出される */
    expect(screen.getByText(/Searches for inputValue/)).toBeInTheDocument();

    screen.debug();

  });
});

実行結果
fireEvent実行前後のHTML構造が確認出来る

  console.log
    <body>
      <div>
        <div
          class="container is-fluid"
        >
          <div>
            <label
              for="search"
            >
              検索
            </label>
            <input
              id="search"
              type="text"
              value=""
            />
          </div>
          <p>
            Searches for
            ...
          </p>
          <div>
            Message:
            testMessage
          </div>
        </div>
      </div>
    </body>

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)

  console.log
    <body>
      <div>
        <div
          class="container is-fluid"
        >
          <div>
            <label
              for="search"
            >
              検索
            </label>
            <input
              id="search"
              type="text"
              value="inputValue"
            />
          </div>
          <p>
            Searches for
            inputValue
          </p>
          <div>
            Message:
            testMessage
          </div>
        </div>
      </div>
    </body>

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)

 PASS  src/App.test.js
  App
    √ render test (162 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.42 s
Ran all test suites related to changed files.

fireEvent.changeだとonchangeのイベントのみ発火するのでより正確にテストしたい場合はuserEventを使用する
参照
※userEvent.typeはchangeイベントだけでなく、keyDown、keyPressやkeyUpイベントも発火させる

App.test.js
import React,{ useState,useEffect } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';

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

    await screen.findByText(/Message:/);

    /* Searches for inputValueは検出出来ずnullになる */
    expect(screen.queryByText(/Searches for inputValue/)).toBeNull();

    screen.debug();

    /* inputのvalueをinputValueに変更 */
    await userEvent.type(screen.getByRole('textbox'), 'inputValue');

    /* Searches for inputValueが検出される */
    expect(screen.getByText(/Searches for inputValue/)).toBeInTheDocument();

    screen.debug();

  });
});

10. コールバックハンドラのテスト

App.jsのSearchコンポーネントのonChangeの動作をチェックする

App.js
export function Search({ value, onChange, children }) {
  return (
    <div>
      <label htmlFor="search">{children}</label>
      <input
        id="search"
        type="text"
        value={value}
        onChange={onChange}
      />
    </div>
  );
}
App.test.js
import React,{ useState,useEffect } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import App, {Search} from './App';

describe('App', () => {
  test('callback handler test', () => {

    const mockFunc = jest.fn();

    render(
      <Search value="test" onChange={mockFunc}>
        検索
      </Search>
    );

    fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'JavaScript' },
    });

    expect(mockFunc).toBeCalledTimes(1);

  });
});

fireEventの実行でonChangeが動作し、mockFuncの呼ばれた回数が1回となる
※userEventはキー入力するごとに作動するので、同様に'JavaScript'とすると回数が10回となる

11. 非同期処理(API呼び出し)のテスト

fetchのモックを作成し、fetchの処理をテストする
fetch-mockとnode-fetchを使用するのでインストールする

$ npm install fetch-mock node-fetch

App.jsのが押された時に動作するapiTestでのfetch('/api')の呼び出しをテストする

App.js
import React,{ useState,useEffect } from 'react';

function App() {

  const [message, setMessage] = useState('');

  const apiTest = () => {
    fetch('/api')
      .then((res) => res.json())
      .then((data) => setMessage(data.message))
  }

  return (
    <div className="container is-fluid">
      {message && <div>Message:{message}</div>}
      <div>
        <button onClick={apiTest}>api-test</button>
      </div>
    </div>
  );
}

export default App;
App.test.js
import React,{ useState,useEffect } from 'react';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
import fetchMock from 'fetch-mock';

describe('App', () => {

  afterEach(() => {
    /* fetchMockをリセット */
    fetchMock.restore();
  });

  test('fech mock test', async () => {

    /* /apiでのgetのfetchに対してデータを返すmock */
    fetchMock
      .get('/api', {
        message: "backend-index-api-mock",
      });

    const mockFunc = jest.fn();

    render(<App />);

    await userEvent.click(screen.getByRole('button', {name: 'api-test'}));

    /* waitFor→getByでfetchの処理が終わるのを待つ */
    await waitFor(() => screen.getByText(/backend-index-api-mock/));
    /* findByでもOK */
    await screen.findByText(/backend-index-api-mock/);

    screen.debug();

  });

});

実行結果

  console.log
    <body>
      <div>
        <div
          class="container is-fluid"
        >
          <div>
            Message:
            backend-index-api-mock
          </div>
          <div>
            <button>
              api-test
            </button>
          </div>
        </div>
      </div>
    </body>

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)

 PASS  src/App.test.js
  App
    √ fech mock test (183 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        3.118 s
Ran all test suites related to changed files.
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0