JestでReactアプリのUIテストを自動化してみた(スナップショットテスト)

2022.01.09

こんにちは、CX事業本部 IoT事業部の若槻です。

今回は、JavaScriptのテストフレームワークJestを使用して、ReactアプリケーションのUIテストの自動化をしてみました。

ReactアプリのUIテストをするためには

Reactアプリケーションのユーザーインターフェース(UI)のテストを自動化する場合、次のような観点が考えられます。

  • DOM要素の出力が期待通りであるかのテスト
  • コードの変更前後でDOM要素の出力に差分が発生していないか(又はしているか)のテスト

特に後者のテストは、ある時点の画面のスナップショットを用意したテストとなるため、スナップショットテストと呼びます。

そして、これらいずれのUIテストもJestを使用して自動化が可能です。

やってみた

テスト環境の作成

テスト対象となるReactアプリケーションを新規作成します。

$ npx create-react-app sample-ss-test-app
$ cd sample-ss-test-app

react-test-rendererをインストールしておきます。jestによるReactアプリケーションのテストで必要となります。

$ npm i -D react-test-renderer

react-test-rendererを使用することにより、ブラウザなどを使わずにReact componentsからDOM tree(正確にはDOM treeに類似したオブジェクト)をレンダリング可能となります。スナップショットテストでは、このDOM treeのスナップショットを作成してテストを行います。

DOM要素の出力が期待通りであるかのテスト

最初に、DOM要素の出力が期待通りであるかのテストを作成してみます。

こちらのドキュメントのExampleを参考にして進めます。

まずはExampleにあるテストファイルtest/example.test.jsを作成して試してみます。

$ mkdir test
$ touch test/example.test.js

test/example.test.js

import TestRenderer from 'react-test-renderer';

function Link(props) {
  return <a href={props.page}>{props.children}</a>;
}

const testRenderer = TestRenderer.create(
  <Link page="https://www.facebook.com/">Facebook</Link>
);

console.log(testRenderer.toJSON());

ここでjestを実行するとJest encountered an unexpected tokenというエラーとなりました。

$ npx jest test/example.test.js
 FAIL  test/example.test.js
  ● Test suite failed to run

    Jest encountered an unexpected token

    Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.

    Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

    By default "node_modules" folder is ignored by transformers.

    Here's what you can do:
     • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
     • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
     • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation specify a "transform" option in your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

    You'll find more details and examples of these config options in the docs:
    https://jestjs.io/docs/configuration
    For information about custom transformations, see:
    https://jestjs.io/docs/code-transformation

    Details:

    SyntaxError: /Users/wakatsuki.ryuta/projects/example-ss-test-app/test/example.test.js: Support for the experimental syntax 'jsx' isn't currently enabled (4:10):

      2 |
      3 | function Link(props) {
    > 4 |   return <a href={props.page}>{props.children}</a>;
        |          ^
      5 | }
      6 |
      7 | const testRenderer = TestRenderer.create(

    Add @babel/preset-react (https://git.io/JfeDR) to the 'presets' section of your Babel config to enable transformation.
    If you want to leave it as-is, add @babel/plugin-syntax-jsx (https://git.io/vb4yA) to the 'plugins' section to enable parsing.

      at Parser._raise (node_modules/@babel/parser/src/parser/error.js:147:45)
      at Parser.raiseWithData (node_modules/@babel/parser/src/parser/error.js:142:17)
      at Parser.expectOnePlugin (node_modules/@babel/parser/src/parser/util.js:205:18)
      at Parser.parseExprAtom (node_modules/@babel/parser/src/parser/expression.js:1221:16)
      at Parser.parseExprSubscripts (node_modules/@babel/parser/src/parser/expression.js:670:23)
      at Parser.parseUpdate (node_modules/@babel/parser/src/parser/expression.js:650:21)
      at Parser.parseMaybeUnary (node_modules/@babel/parser/src/parser/expression.js:621:23)
      at Parser.parseMaybeUnaryOrPrivate (node_modules/@babel/parser/src/parser/expression.js:374:14)
      at Parser.parseExprOps (node_modules/@babel/parser/src/parser/expression.js:384:23)
      at Parser.parseMaybeConditional (node_modules/@babel/parser/src/parser/expression.js:342:23)

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        0.831 s
Ran all test suites matching /test\/example.test.js/i.

どうやら@babel/preset-reactが無いためJSXが解釈出来てないみたいですね。

下記を参考に対処を行います。

@babel/preset-reactをインストールし、.babelrcを作成して記述を追加します。

$ npm i -D @babel/preset-react
$ touch .babelrc

.baelrc

{
  "presets": ["@babel/preset-env","@babel/preset-react"]
}

再度jestを実行すると、エラーの内容が変わりました。Reactのインポートを忘れていますね。

$ npx jest test/example.test.js          
 FAIL  test/example.test.js
  ● Test suite failed to run

    ReferenceError: React is not defined

       6 |
       7 | const testRenderer = TestRenderer.create(
    >  8 |   <Link page='https://www.facebook.com/'>Facebook</Link>
         |   ^
       9 | );
      10 |
      11 | console.log(testRenderer.toJSON());

      at Object.<anonymous> (test/example.test.js:8:3)
      at TestScheduler.scheduleTests (node_modules/@jest/core/build/TestScheduler.js:333:13)

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        0.776 s
Ran all test suites matching /test\/example.test.js/i.

1行目に下記を追記。

test/example.test.js

import React from 'react';

jestを実行するとまたFAILとなりましたが、次はtestの記述が一つもないためエラーとなっているだけです。そしてDOM treeをJSON化したオブジェクトを取得できています。

$ npx jest test/example.test.js
  console.log
    {
      type: 'a',
      props: { href: 'https://www.facebook.com/' },
      children: [ 'Facebook' ]
    }

      at Object.<anonymous> (test/example.test.js:12:9)

 FAIL  test/example.test.js
  ● Test suite failed to run

    Your test suite must contain at least one test.

      at onResult (node_modules/@jest/core/build/TestScheduler.js:175:18)
      at node_modules/@jest/core/build/TestScheduler.js:316:17
      at node_modules/emittery/index.js:260:13
          at Array.map (<anonymous>)
      at Emittery.emit (node_modules/emittery/index.js:258:23)

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        0.849 s
Ran all test suites matching /test\/example.test.js/i.

これでやっとExampleのテストを試す準備が整いました。

test/example.test.jsを次のように更新します。

test/example.test.js

import React from 'react';
import TestRenderer from 'react-test-renderer';

function MyComponent() {
  return (
    <div>
      <SubComponent foo="bar" />
      <p className="my">Hello</p>
    </div>
  )
}

function SubComponent() {
  return (
    <p className="sub">Sub</p>
  );
}

const testRenderer = TestRenderer.create(<MyComponent />);
const testInstance = testRenderer.root;

test("snapshots test", () => {
  expect(testInstance.findByType(SubComponent).props.foo).toBe('bar');
  expect(testInstance.findByProps({className: "sub"}).children).toEqual(['Sub']);
})

テスト対象のMyComponentSubComponentを使用してtestInstanceを作成し、DOM treeに期待した値が設定されているかテストをしています。

1つ目のテストでは、testInstanceの中で持つSubComponentfoopropがbarであることを、2つ目のテストでは、testInstanceでclassNamesubの値がSubであることをテストしています。

jestを実行すると、テストがPASSしました。

$ npx jest test/example.test.js
 PASS  test/example.test.js
  ✓ snapshots test (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.902 s
Ran all test suites matching /test\/example.test.js/i.

期待通りとならない場合も試してみます。2つ目のテストでtoEqualの値をMainに変更します。

test/example.test.js

test("snapshots test", () => {
  expect(testInstance.findByType(SubComponent).props.foo).toBe('bar');
  expect(testInstance.findByProps({className: "sub"}).children).toEqual(['Main']);
})

jestを実行すると、ちゃんとテストがFAILしました。

$  npx jest test/example.test.js
 FAIL  test/example.test.js
  ✕ snapshots test (5 ms)

  ● snapshots test

    expect(received).toEqual(expected) // deep equality

    - Expected  - 1
    + Received  + 1

      Array [
    -   "Main",
    +   "Sub",
      ]

      22 | test("snapshots test", () => {
      23 |   expect(testInstance.findByType(SubComponent).props.foo).toBe('bar');
    > 24 |   expect(testInstance.findByProps({className: "sub"}).children).toEqual(['Main']);
         |                                                                 ^
      25 | })
      26 |

      at Object.<anonymous> (test/example.test.js:24:65)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.886 s, estimated 1 s
Ran all test suites matching /test\/example.test.js/i.

これでDOM要素の出力が期待通りであるかのテストを作成できました。

スナップショットテスト

次にスナップショットテストを作成してみます。

こちらのドキュメントを参考に進めます。

準備として、テストファイルにJSXをインポートするので、babelの設定が必要となります。

必要なパッケージをインストールします。

$ npm i -D babel-jest @babel/preset-env @babel/preset-react react-test-renderer

次のbabel.config.jsファイルを作成します。

babel.config.js

module.exports = {
  presets: ['@babel/preset-env', '@babel/preset-react'],
};

テスト対象のReact ComponentとなるLinkを作成します。onMouseEnterおよびonMouseLeaveによりDOMの要素が変わるComponentです。

src/Link.js

import React, {useState} from 'react';

const STATUS = {
  HOVERED: 'hovered',
  NORMAL: 'normal',
};

const Link = ({page, children}) => {
  const [status, setStatus] = useState(STATUS.NORMAL);

  const onMouseEnter = () => {
    setStatus(STATUS.HOVERED);
  };

  const onMouseLeave = () => {
    setStatus(STATUS.NORMAL);
  };

  return (
    <a
      className={status}
      href={page || '#'}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      {children}
    </a>
  );
};

export default Link;

テストファイルを次の通り作成します。インポートしたLinkのDOM treeに対して、toMatchSnapshot()を使用して既定時、onMouseEnter時、onMouseLeave時のスナップショットを作成しています。

test/Link.react.test.js

import React from 'react';
import renderer, { act } from 'react-test-renderer';
import Link from '../src/Link';

test('Link changes the class when hovered', () => {
  const component = renderer.create(
    <Link page="http://www.facebook.com">Facebook</Link>,
  );
  let tree = component.toJSON();
  expect(tree).toMatchSnapshot();

  // manually trigger the callback
  act(() => {
    tree.props.onMouseEnter();
  });
  // re-rendering
  tree = component.toJSON();
  expect(tree).toMatchSnapshot();

  // manually trigger the callback
  act(() => {
    tree.props.onMouseLeave();
  });
  // re-rendering
  tree = component.toJSON();
  expect(tree).toMatchSnapshot();
});

ここで注意点として、onMouseEnter時およびonMouseLeave時はReact Hooksによる動作が発生するため、act()を使用する必要がありました。

  console.error
    Warning: An update to Link inside a test was not wrapped in act(...).
    
    When testing, code that causes React state updates should be wrapped into act(...):
    
    act(() => {
      /* fire events that update state */
    });
    /* assert on the output */

    This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act
        at Link (/Users/wakatsuki.ryuta/projects/cm-rwakatsuki/sample-ss-test-app-2/src/Link.js:8:16)

jestを実行するとすべてPASSしました。

$ npx jest test/Link.react.test.js 
 PASS  test/Link.react.test.js
  ✓ Link changes the class when hovered (9 ms)

 › 3 snapshots written.
Snapshot Summary
 › 3 snapshots written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   3 written, 3 total
Time:        1.01 s
Ran all test suites matching /test\/Link.react.test.js/i.

そして__snapshots__ディレクトリ配下にLink.react.test.js.snapというスナップショットファイルが作成されています。

test/__snapshots__/Link.react.test.js.snap

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Link changes the class when hovered 1`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

exports[`Link changes the class when hovered 2`] = `
<a
  className="hovered"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

exports[`Link changes the class when hovered 3`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

このスナップショットファイルは初回実行時のみtoMatchSnapshot()により作成されるもので、2回目以降はすでに作成されているスナップショットファイルと、テスト実行時のスナップショットの比較のテストが行われます。これによりソースコードの変更によりComponentのスナップショットに想定外の影響が発生していないかどうかをテストすることができるようになります。

テスト失敗パターンも確認してみます。ホバー時の表示文字列を変更してみます。

test/Link.react.test.js

const STATUS = {
  HOVERED: 'hovered!!!',
  NORMAL: 'normal',
};

jestを実行すると、想定通り失敗しました。

$  npx jest test/Link.react.test.js
 FAIL  test/Link.react.test.js
  ✕ Link changes the class when hovered (15 ms)

  ● Link changes the class when hovered

    expect(received).toMatchSnapshot()

    Snapshot name: `Link changes the class when hovered 2`

    - Snapshot  - 1
    + Received  + 1

    @@ -1,7 +1,7 @@
      <a
    -   className="hovered"
    +   className="hovered!!!"
        href="http://www.facebook.com"
        onMouseEnter={[Function]}
        onMouseLeave={[Function]}
      >
        Facebook

      16 |   // re-rendering
      17 |   tree = component.toJSON();
    > 18 |   expect(tree).toMatchSnapshot();
         |                ^
      19 |
      20 |   // manually trigger the callback
      21 |   act(() => {

      at Object.<anonymous> (test/Link.react.test.js:18:16)

 › 1 snapshot failed.
Snapshot Summary
 › 1 snapshot failed from 1 test suite. Inspect your code changes or re-run jest with `-u` to update them.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   1 failed, 2 passed, 3 total
Time:        1.896 s
Ran all test suites matching /test\/Link.react.test.js/i.

UIに変更が発生するコード変更を行った場合は、合わせてスナップショットファイルを変更し、またGitで他のファイルと一緒にバージョンを管理します。

おわりに

Jestを使用して、ReactアプリケーションのUIテストの自動化をしてみました。

今までReactアプリケーションのUIの動作確認をいちいち画面を見て行っていたため、時間を要したり確認が漏れたりというペインがありました。

今後はJestを活用してUIのテストもガンガン実装していきたいと思います!

参考

以上