React + TypeScript + Jest + react-testing-libraryのテスト その1

手順とかを淡々と載せるだけです。
フロントエンドのテストがつらい、というような話は一切ありません。

インストール

reactを入れます。

shell
yarn add react react-dom

typescriptやらwebpackやらを入れます。

shell
yarn add -D typescript webpack webpack-cli webpack-dev-server html-webpack-plugin ts-loader url-loader @types/react @types/react-dom @types/webpack @types/webpack-dev-server

jestとjest-domとtesting-library、型定義ファイルをインストールします。

shell
yarn add -D jest @testing-library/jest-dom @testing-library/react @types/jest

babelと各種プラグインをインストールします。

shell
yarn add -D @babel/core @babel/plugin-proposal-class-properties @babel/preset-env @babel/preset-react @babel/preset-typescript babel-jest

設定

まだReactには触れませんが、webpackの設定だけしておきます。

webpack.config.js
const path = require('path');
const htmlWebpackPlugin = require('html-webpack-plugin');

module.exports = (env, argv) => {
  const mode = process.env.NODE_ENV || 'development';
  const isProduction = mode === 'production';

  return {
    mode: isProduction ? 'production' : 'development',
    entry: {
      app: [path.resolve(__dirname, 'src/App.tsx')],
    },
    output: {
      filename: isProduction ? 'bundle.[chunkhash].js' : '[name].js',
      path: path.resolve(__dirname, 'dist'),
      publicPath: '/',
    },
    module: {
      rules: [
        { test: /\.tsx?$/, loader: 'ts-loader' },
        {
          test: /\.(png|jpg|gif)$/i,
          use: [
            {
              loader: 'url-loader',
              options: {
                limit: 8192,
              },
            },
          ],
        },
      ],
    },
    resolve: {
      extensions: ['.ts', '.tsx', '.js', '.json'],
      alias: {
        '@': path.resolve(__dirname, 'src/'),
      },
    },
    optimization: {
      splitChunks: {
        name: 'vendor',
        chunks: 'initial',
      },
    },
    devServer: {
      historyApiFallback: true,
    },
    plugins: [
      new htmlWebpackPlugin({
        template: path.resolve(__dirname, 'src/index.html'),
      }),
    ],
  };
};

ルートフォルダに.babelrc.jsを作成します。

.babelrc.js
module.exports = {
  presets: ['@babel/react', '@babel/typescript', ['@babel/preset-env']],
  plugins: [
    '@babel/proposal-class-properties',
    '@babel/proposal-object-rest-spread',
  ],
  env: {
    test: {
      presets: [
        [
          '@babel/preset-env',
          {
            modules: 'commonjs',
          },
        ],
      ],
    },
  },
};

そしてjest.config.jsを作成します。
webpackで使っているaliasと同じ設定を、moduleNameMapperで行っています。
あとは、後にjest-domを使用することになるのでjest-dom/extend-expectというjestの拡張を設定しておきます。

jest.config.js
module.exports = {
  roots: ['<rootDir>/'],
  testRegex: '((\\.|/)(test|spec))\\.(jsx?|tsx?)$', // テスト対象ファイルの指定
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
  moduleNameMapper: { '^@/(.+)': '<rootDir>/src/$1' }, // @/はsrc/のalias
  setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], // 拡張
  transform: { '\\.(js|ts|tsx)?$': 'babel-jest' },
};

package.jsonのscriptsに"test": "jest"を追加します。

package.json
...
"scripts": {
  "test": "jest"
 },
...

以降、yarn testでテストを行うことができます。
(テスト内容がないので、エラーが出ます)

shell
yarn test

jestの基本

jestは通常、__test__フォルダ内のファイルか、.test.js.spec.js拡張子のファイルを認識します。
今回はsrc/specフォルダを作成し、その中にfoo.spec.tsを作成します。
(今回の設定では__test__を省いています)

テストしてみる

it、またはtestでテストを行います。
仮引数にテスト名とcallbackを指定して、テストの中身を記載していきます。

src/spec/foo.spec.ts
it('テスト名', () => {
  // テストの内容
});

jestでは、expect()の仮引数に渡した計算などを、toBe()のようなmatcherを用いて、
望んだ結果になっているかどうかを判定することができます。
例えば、1+1の結果が2であってほしいプログラムの時、次のようにテストを書きます。

src/spec/foo.spec.ts
it('1 + 1 = 2', () => {
  // exceptに計算式、toBeで望む値
  expect(1 + 1).toBe(2);
});

これをターミナルから実行します。

shell
yarn test

expectで1 + 1の結果を評価して、2になればこのテストは通ります。

matcherの種類

matcherには様々な種類があります。
値がtruthyであるかを判定するtoBeTruthy()、厳密に比較するtoEqual()などです。
また、これらの前に.notを挟むことで否定形になります。

src/spec/foo.spec.ts
const foo = 3;
const bar = 4;
const baz = 3;
const hoge = null;
const fuga = undefined;
const text = 'テスト';
const text2 = '';
const array = [0, 1, 2];
const item1 = { apple: 300 };
const item2 = { apple: 300 };
const item3 = { pen: 150 };
const sum = (x: number, y: number) => x + y;

it('fooは3か', () => {
  expect(foo).toBe(3);
});

it('fooはbarより小さいか', () => {
  expect(foo).toBeLessThan(bar);
});

it('barはfooより大きいか', () => {
  expect(bar).toBeGreaterThan(foo);
});

it('fooとbazは等しいか', () => {
  expect(bar).toBeGreaterThan(foo);
});

it('hogeはNullか', () => {
  expect(hoge).toBeNull();
});

it('fugaはUndefinedか', () => {
  expect(fuga).toBeUndefined();
});

it('textに「テスト」というテキストが入っているか', () => {
  expect(text).toBe('テスト');
});

it('text2は空か', () => {
  expect(text2).toBeFalsy();
});

it('arrayのlengthは3か', () => {
  expect(array).toHaveLength(3);
});

it('item1とitem2のデータは等しいか', () => {
  expect(item1).toEqual(item2);
});

// 'not'を挟むと否定になる
it('item1とitem3のデータは等しくないか', () => {
  expect(item1).not.toEqual(item3);
});

it('fooとbarをsum関数に渡すと7が返るか', () => {
  expect(sum(foo, bar)).toBe(7);
});

長くなってしまいましたが、全てのテストが通ります。
その他のmatcherについては、公式ドキュメントに一覧があります。
https://jestjs.io/docs/ja/expect#toequalvalue

複数のテスト

describeを用いて、テストをグループ、階層化することができます。
下記はnumber型とany型をとるfoo関数のテストです。

src/spec/callback.spec.ts
const foo = (x: number, y: any) => x + y;

describe('foo関数の処理', () => {
  it('3と4で7', () => {
    expect(foo(3, 4)).toBe(7);
  });
  it('1とNaNでNaN', () => {
    expect(foo(1, NaN)).toBe(NaN);
  });
  it('文字列が混ざるとfalseを返す', () => {
    expect(foo(2, '算')).toBe(false);
  });
});

foo関数について色々なテストができました。
上記テストでは、一番下の「yに文字列が渡されたらfalseを返す」テストが通りません。
foo関数は期待通りになっていない実装であることがわかります。

callbackのテスト

callbackもテストできます。
下記コードでは、textに「テスト通りました」が入っていないにも関わらず、テストが通ります。
setTimeoutの処理を待たず、テストが終了するためです。

src/spec/callback.spec.ts
let text = '';

it('「テスト通りました」と言う', () => {
  setTimeout(() => {
    text = '通らない。現実は非情である';
    expect(text).toBe('テスト通りました');
  }, 3000);
});

// okが出てしまう

仮引数にcallbackを指定することで、それが実行されるまで終了を待ってくれます。
下記は、doneを渡していて、それを実行することでテスト終了となります。

src/spec/callback.spec.ts
let text = '';

// doneを引数に指定
it('「テスト通りました」と言う', done => {
  setTimeout(() => {
    text = '通らない。現実は非情である';
    expect(text).toBe('テスト通りました');
    done(); // doneを実行でテスト終了
  }, 3000);
});

Promiseのテスト

Promiseを返す関数をテストする場合です。
itの中でPromiseを返し、then()の中でexpectを書きます。
Promiseを返さないと、先ほどのケースと同様、処理が行われないままテストが終了します。

src/spec/fetchData.spec.ts
type ResolveType = {
  data: string;
};

// Promise
const fetchData = (): Promise<ResolveType> => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        data: 'foo',
      });
    }, 1000);
  });
};

it('dataからfooが取得できているか', () => {
  // Promiseを返す
  return fetchData().then(res => {
    expect(res.data).toBe('foo');
  });
});

resolvesを使い、expectを返すことでこのように書くこともできます。
rejectされた場合は失敗します。

src/spec/fetchData.spec.ts
it('dataからfooが取得できているか', () => {
  return expect(fetchData()).resolves.toEqual({ data: 'foo' });
});

rejectのテストもしたくなるのが常だと思います。
その場合はrejectsを挟みます。
また、reasonをテストするにはtoThrowというmatcherを使います。

src/spec/fetchData.spec.ts
const rejected = (): Promise<never> => {
  return Promise.reject(new Error('error'));
};

it('rejectされるか', () => {
  return expect(rejected()).rejects.toThrow('error');
});

returnする代わりにAsync / Awaitでも書けます。
ただしregenerator-runtime/runtimeが必要です。
@babel/polyfillがあればそちらでも大丈夫です。

src/spec/fetchData.spec.ts
it('dataからfooが取得できているか', async () => {
  await expect(fetchData()).resolves.toEqual({ data: 'foo' });
});

it('rejectされるか', async () => {
  await expect(rejected()).rejects.toThrow('error');
});

テストの種類

ユニットテスト(単体テスト)

関数やメソッドに値を渡し、期待する値が返るかどうかをチェックする基本的なテストです。
ユニットテストを書く意味については、こちらの項目が参考になります。
ユニットテストを記述する · JavaScript Primer #jsprimer より「なぜユニットテストをおこなうのか」

インテグレーションテスト(結合テスト)

いくつかの機能を組み合わせ、正しく動作していることを期待するテストです。

スナップショットテスト

レンダーツリーの差分を比較し、変更点をチェックするテストです。
変更により意図せずUIが崩れてしまうことは稀によくあるので、行われることがあります。
react-test-rendererを使うことができます。

ビジュアルリグレッションテスト(ビジュアル回帰テスト)

スクリーンショットの差分を比較し、デザインの変更点をチェックするテストです。
jestではjest-image-snapshotなどありますが、詳細は割愛します。
https://github.com/americanexpress/jest-image-snapshot

E2Eテスト(End to Endテスト、UIテスト)

目標達成のために必要な機能を一通り実行し、期待通りの操作ができるかどうかをチェックするテストです。
フロントエンドの文脈では、クライアント側の操作をシミュレートして、正しく動作しているかなどをチェックする時に行われます。
Selenium、最近ではPuppeteerがよく使われている印象です。

参考

Facebook製のJavaScriptテストツール「Jest」の逆引き使用例 - Qiita
フロントエンドのテストに真面目に向き合う - Qiita

次回に続きます。