Facebook 製 JavaScript テストツール Jest を使ってテストする ( Babel, TypeScript のサンプル付き )

この記事はRECRUIT MARKETING PARTNERS Advent Calendar 2017の投稿記事です。

はじめまして。11月にJoinしたフロントエンドエンジニアの福井(@fukumasuya)です。チームではスタディサプリEnglishのWebブラウザ版の開発を担当しています。

突然ですが皆さんはプロジェクトでJavaScriptのテストをするときにどのツールを使うか迷うことはないでしょうか?フレームワークは?アサーションライブラリは?テストランナーは?テストカバレッジはどうするか?などなど決めないとダメなことが多く苦労していませんか?そんな皆さんにはFacebook製オールインワンテストツールのJestをオススメします。

この記事ではJest初心者向けにJavaScriptES2015(ES6)+FlowTypeScriptの3パターンで一からプロジェクトを作成し、簡単なテストを書いて動作確認とテストカバレッジを取得する方法を紹介します。まだJestを触ったことがないかたにとってこの記事が素晴らしいJestの世界に触れるきっかけになれば幸いです。

参考

動作確認環境

  • yarn v1.2.1 もしくは npm v5.5.1
  • jest v21.2.1
  • MacOS Sierra(10.12.6)

サンプルコードは GitHub で公開していますので、ぜひ併せてご参照ください。

What is Jest?

冒頭に書いたようにFacebook社が提供しているMITライセンスのオールインワンJavaScriptテストツールです。以下にあるようなテストツールとして求められる機能をはじめから備えているのが特徴で同じFacebook製のReactと相性がよく、Reactを採用しているプロジェクトではよく一緒に利用されています。しかしJestは決してReact専用というわけではなくAngularVue.jsなどReact以外のプロジェクトでも普通に利用できます。言語に関しても通常のJavaScript以外にもES2015(ES6)FlowTypeScriptで書かれたコードに対応しています。またJestにはテストカバレッジを取得できる機能があり、コードのどの部分がテストされていないのかすぐ確認できます。この機能については記事の最後で簡単に紹介します。

Jestが提供する機能(一部)

  • beforeEach()afterAll()などのSetup、Teardown
  • expect()関数が提供する様々なマッチャー
  • ネイティブ関数や自作関数のモック化
  • PromiseAsync/Awaitなど非同期処理のテスト
  • UIの変更を検知できるスナップショット
  • テストカバレッジ取得

Hello Jest

では早速Jestをプロジェクトに導入して簡単なテストを実行してみましょう。

セットアップ

セットアップはyarnもしくはnpmで行います。適当な名前で空ディレクトリを作成して以下を実行してください。最初のgit initは必須ではありませんが、やっておくとコード変更を監視して影響するテストファイルだけテストするwatchオプションが使えるので便利になります。

yarn

$ git init
$ yarn init
...(適当に初期値を入力)
$ yarn add -D jest

npm

$ git init
$ npm init
...(適当に初期値を入力)
$ npm install --save-dev jest

テスト対象ファイルの作成

引数に指定した文字列を加工してHelloメッセージを出力する関数のテストをしてみます。プロジェクトにテスト対象ファイル(hello.js)を追加しましょう。

function hello(name) {
  return 'Hello ' + name + '!!';
}
module.exports = hello;

テストファイルの作成

次に作成したhello.jshello()関数をテストするためのhello.test.jssrcディレクトリに作成します。Jestはデフォルトで*.test.js*.spec.jsもしくは__tests__という名前のディレクトリ以下のファイルをテストファイルとみなします。これはtestRegexというオプションを使うことで変更できます。

テストはtest()関数の中で実行します。第一引数にテストのDescriptionを記述でき、第二引数にテスト内容を関数として記述します。その関数の中でexpect()関数にテストしたい値(hello('Jest'))を引数として渡し、マッチャーtoBe()で値をチェックします。すべてのテストは基本的にこの流れです。二つ目のテストはnotを使って結果を逆転させています。マッチャーは他にもたくさんあるので興味があるかたはぜひ調べてみてください。

var hello = require('./hello');
test('hello("jest") to be "Hello Jest!!"', function() {
  expect(hello('Jest')).toBe('Hello Jest!!');
});
test('hello("jest") not to be "Hello fukumasuya!!"', function() {
  expect(hello('Jest')).not.toBe('Hello fukumasuya!!');
});

プロジェクト構造はこのようになっているはずです。

root/
├ ...
├ package.json
└ src/
   ├ hello.js
   └ hello.test.js

テスト実行

いよいよテスト実行です。まずpackage.jsonscriptstestコマンドを追加してコマンドラインからテストを実行できるようにします。

{
  ...
  "devDependencies": {
    "jest": "^21.2.1"
  }
  ...
  "scripts": {
    "test": "jest"
  }
}

ターミナルでyarn testもしくはnpm run testを実行します。watchオプションで実行する場合はyarn test --watchもしくはnpm run test --watchを実行してください。

npmyarnの結果を両方載せると冗長なので今後はyarnのみ表示します。npmでも結果は同じです。)

$ yarn test
yarn run v1.2.1
$ jest
 PASS  src/hello.test.js
  ✓ hello("jest") to be "Hello Jest!!" (5ms)
  ✓ hello("jest") not to be "Hello fukumasuya!!"
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.406s
Ran all test suites.
Done in 2.07s.

watchオプションで実行した場合はこうなります。テストプロセスは終了せず監視モードになっているのでファイルを変更しただけで再テストが必要なファイルは自動的にテストされます。

PASS  src/hello.test.js
  hello
    ✓ hello("jest") to be "Hello Jest!!" (2ms)
    ✓ hello("jest") not to be "Hello fukumasuya!!"
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.892s, estimated 1s
Ran all test suites related to changed files.
Watch Usage
 › Press a to run all tests.
 › Press p to filter by a filename regex pattern.
 › Press t to filter by a test name regex pattern.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.

以上で終了です。どうでしたか?本当にJestを導入するだけでテストできましたね。

ES2015(ES6) + Flow でテストを書く

我々のチームでは開発言語としてTypeScriptを採用していますが、最近のモダンな開発チームでは通常のJavaScriptではなくES2015(ES6)ES2016(ES7)やFacebook製の静的型チェッカーであるFlowを採用していることも多いと思います。そういうプロジェクトでももちろんJestは利用可能です。JestにはデフォルトでBabelのJestプラグインbabel-jestがセットされているため、プロジェクトのBabelの設定を使ってソースをトランスパイルしてテストできます。

セットアップ

先程テストを実行したプロジェクトをそのまま使います。まずES6用のBabelプリセットbabel-preset-es2015Flow用のBabelプリセットbabel-preset-flowとFlow CLIのflow-binをインストールします。


package.json の編集

flowコマンドを実行できるよう、package.jsonに以下を追加します

{
  ...
  "devDependencies": {
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-flow": "^6.23.0",
    "flow-bin": "^0.61.0",
    "jest": "^21.2.1"
  },
  ...
  "scripts": {
    "test": "jest",
    "flow": "flow"
  }
}

続いてFlowを初期化します。以下を実行するとプロジェクトに.flowconfigファイルが追加されます。この記事のテストを動かすだけならこのファイルを編集する必要はありませんのでそのままにしておきます。


.babelrc の編集

次に.babelrcを編集します。presetsに先程インストールしたes2015flowを設定します。他に使いたいプリセットがあれば同様にインストールして.babelrcに書くことでJestも自動的にその設定にしたがってソースを解釈するようになります。

{
  "presets": ["es2015", "flow"]
}

以上でセットアップ完了です。

テスト対象ファイルとテストファイルの編集

次にテスト対象ファイルとテストファイルをES6Flowに対応したコードに変更します。まずFlowの型チェック対象としてファイルを指定するためには// @flowをファイルの先頭に指定する必要があります。hello()関数もせっかくなのでアロー関数として定義してみましょう。引数の型はstring、返り値の型は型チェックがされているか確認するためあえてnumberとしてみましょう。ES6なのでexportも使えます。

// @flow
export const hello = (name: string): number => `Hello ${name}!!`;

次にテストファイルです。テストファイルにも// @flowを追加してFlowのチェック対象にしてみましょう。hello()関数をimportでロードします。それ以外は基本的に同じですが、せっかくなので先程使ったtest()の代わりにdescribe()it()を使ってみましょう。describe()を使うと複数のテストをグルーピングして管理することができます。ネストもできるので関数やクラス単位でうまくグルーピングするとテストファイルの見通しがよくなります。it()test()のエイリアスで中身は同じです。この書き方はRSpecJasmineなど他のテストツールと同じなので馴染みがあるかたは多いのではないでしょうか。

// @flow
import { hello } from './hello';
describe('hello', () => {
  it('hello("jest") to be "Hello Jest!!"', () => {
    expect(hello('Jest')).toBe('Hello Jest!!');
  })
  it('hello("jest") not to be "Hello fukumasuya!!"', () => {
    expect(hello('Jest')).not.toBe('Hello fukumasuya!!');
  });
});

試しにyarn flow checkで型チェックを実行してみましょう。

yarn run v1.2.1
$ flow check
Error: src/hello.js:2
  2: export const hello = (name: string): number => `Hello ${name}!!`;
                                                    ^^^^^^^^^^^^^^^^^ string. This type is incompatible with the expected return type of
  2: export const hello = (name: string): number => `Hello ${name}!!`;
                                          ^^^^^^ number
Error: src/hello.test.js:4
  4: describe('hello', () => {
     ^^^^^^^^ describe. Could not resolve name
Error: src/hello.test.js:5
  5:   it('hello("jest") to be "Hello Jest!!"', () => {
       ^^ it. Could not resolve name
Error: src/hello.test.js:6
  6:     expect(hello('Jest')).toBe('Hello Jest!!');
         ^^^^^^ expect. Could not resolve name
Error: src/hello.test.js:9
  9:     expect(hello('Jest')).not.toBe('Hello fukumasuya!!');
         ^^^^^^ expect. Could not resolve name
Found 5 errors
error Command failed with exit code 2.

すごく怒られましたね…最初のエラーでは予期したとおりhello()の返り値に指定した型(number)と実際に返る型(string)が一致していないというもので、その他のエラーはテストファイルの中で未定義の関数(describe()it()expect())が使われてるというものです。これらのエラーは次のようにすることで解消できます。

hello.jsの返り値の型をstringにする

// @flow
export const hello = (name: string): string => `Hello ${name}!!`;

Jestの型ファイルをインストールする

テストファイル中の未定義関数エラーはFlowJestの型情報を知らないため起きているので、型ファイルをプロジェクトにインストールしてやります。Jestの型情報をチェックするためまずflow-typedをインストールしましょう。


インストール直後はパスが通ってないかもしれないので、ターミナルを再起動してflow-typed search jestで型情報を調べます。

$ flow-typed search jest
• rebasing flow-typed cache...done.
Found definitions:
╔======╤==================╤=====================╗
║ Name │ Package Version │ Flow Version        ║
╟------┼-----------------┼---------------------╢
║ jest │ v21.x.x         │ >=v0.39.x           ║
╟------┼-----------------┼---------------------╢
║ jest │ v21.x.x         │ >=v0.22.x <=v0.38.x ║
╟------┼-----------------┼---------------------╢
║ jest │ v20.x.x         │ >=v0.39.x           ║
╟------┼-----------------┼---------------------╢
║ jest │ v20.x.x         │ >=v0.22.x <=v0.38.x ║
╟------┼-----------------┼---------------------╢
║ jest │ v19.x.x         │ >=v0.16.x           ║
╟------┼-----------------┼---------------------╢
║ jest │ v18.x.x         │ >=v0.16.x           ║
╟------┼-----------------┼---------------------╢
║ jest │ v17.x.x         │ >=v0.16.x           ║
╟------┼-----------------┼---------------------╢
║ jest │ v16.x.x         │ >=v0.16.x           ║
╟------┼-----------------┼---------------------╢
║ jest │ v14.x.x         │ >=v0.16.x           ║
╟------┼-----------------┼---------------------╢
║ jest │ v12.0.x         │ >=v0.16.x           ║
╚======╧==================╧=====================╝

使っているJest v21.2.1に対応する型ファイルが見つかったのでプロジェクトにインストールします。

$ flow-typed install jest@21.x.x
• Searching for 1 libdefs...
• rebasing flow-typed cache...
• Installing 1 libDefs...
  • jest_v21.x.x.js
    └> ./flow-typed/npm/jest_v21.x.x.js
jest

これでjestの型情報ファイルがプロジェクトに入ったので、
再度yarn flow checkを実行してみましょう。

$ yarn flow check
yarn run v1.2.1
$ flow check
Found 0 errors
Done in 2.38s.

Found 0 errorsが表示されたらOKです!

テスト実行

最後にyarn testでテストを実行します。

$ yarn test
yarn run v1.2.1
$ jest
 PASS  src/hello.test.js
  hello
    ✓ hello("jest") to be "Hello Jest!!" (2ms)
    ✓ hello("jest") not to be "Hello fukumasuya!!"
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.751s
Ran all test suites.
Done in 3.66s.

JestES6Flowで書かれたコードがテストできました。おまけですが、describe()を使っているので2つのテストがhelloグループとして管理されていることがわかると思います。

最終的なプロジェクト構造はこのようになっています。

root/
├ ...
├ package.json
├ .babelrc
├ .flowconfig
├ src/
│ ├ hello.js
│ └ hello.test.js
└ flow-typed/
   └ npm/
      └ jest_v21.x.x.js

TypeScriptでテストを書く

今度はTypeScriptでテストを書いてみましょう。TypeScriptはMicrosoft製の静的型付け言語でAngularでも採用されており、プロジェクトで採用しているチームも多いと思います。Jestでも幾つか設定を加えることでTypeScirptのテストができるようになります。

セットアップ

まず、Jestを入れただけの状態のプロジェクトにtypescript、Jestの型ファイル@types/jestTypeScriptのJestプラグインのts-jestを導入します。


続いて以下のようにpackage.jsontscコマンドを登録します。

{
  ...
  "devDependencies": {
    "@types/jest": "^21.1.8",
    "jest": "^21.2.1",
    "ts-jest": "^21.2.4",
    "typescript": "^2.6.2"
  }
  ...
  "scripts": {
    "test": "jest",
    "tsc": "tsc"
  }
}

さらに、yarn tsc --initを実行してtsconfig.jsonファイルを作成します。


tsconfig.jsonが作成されたらファイルを開いて、compilerOptionstargetes2015に変更します。es2016が使いたい場合はそのように変えても構いません。その他はデフォルトのままで大丈夫です。

{
  "compilerOptions": {
    "target": "es2015",
    "module": "commonjs",
    ...
  }
  ...
}

次にts-jestのガイドに従ってpackage.jsonに以下を追記しましょう。この設定によってJest実行時に*.test.tsファイルがテスト対象となりTypescriptでトランスパイルされるようになります。

{
  ...
  "devDependencies": {
    "@types/jest": "^21.1.8",
    "jest": "^21.2.1",
    "ts-jest": "^21.2.4",
    "typescript": "^2.6.2"
  }
  ...
  "scripts": {
    "test": "jest",
    "tsc": "tsc"
  },
  "jest": {
    "transform": {
      "^.+\\.tsx?$": "ts-jest"
    },
    "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js",
      "jsx",
      "json",
      "node"
    ]
  }
}

以上でセットアップ完了です。

テスト対象ファイルとテストファイルの編集

ではテスト対象ファイルとテストファイルを編集しましょう。テスト対象ファイル(hello.ts)をsrcディレクトリに作成します。ソースの中身はES6版から// @flowを除いたものです。こちらもせっかくなのでhello()関数の返り値の型をnumberにして型チェックしてみましょう。


次にテストファイルです。
こちらもES6版から// @flowを除いたものとまったく同じものをsrcディレクトリにhello.test.tsとして作成しましょう。

import { hello } from './hello';
describe('hello', () => {
  it('hello("jest") to be "Hello Jest!!"', () => {
    expect(hello('Jest')).toBe('Hello Jest!!');
  })
  it('hello("jest") not to be "Hello fukumasuya!!"', () => {
    expect(hello('Jest')).not.toBe('Hello fukumasuya!!');
  });
});

作成したらyarn tscコマンドで型チェックしてみましょう。

$ yarn tsc
yarn run v1.2.1
$ tsc
src/hello.ts(1,48): error TS2322: Type 'string' is not assignable to type 'number'.
error Command failed with exit code 2.

想定したとおり型チェックでエラーになりました。ES6版と同じようにhello()関数の返り値の型をstringに戻して再実行しましょう。


$ yarn tsc
yarn run v1.2.1
$ tsc
Done in 1.30s.

無事成功しました。これでsrcディレクトリにトランスパイルされたhello.jshello.test.jsが作成されているはずです。興味があればどのように変換されたか覗いてみてください。今回は必要ないので確認が終わればファイルは削除して構いません。

テスト実行

いよいよテスト実行です。yarn testでテストを実行します。

$ yarn test
yarn run v1.2.1
$ jest
 PASS  src/hello.test.ts
  hello
    ✓ hello("jest") to be "Hello Jest!!" (2ms)
    ✓ hello("jest") not to be "Hello fukumasuya!!"
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.441s
Ran all test suites.
Done in 1.86s.

成功したでしょうか?これでTypeScriptJestでテストできることがお分かりいただけたと思います。

最終的なプロジェクト構造はこのようになります。型定義ファイルはFlowと違ってnode_modules@types以下に配置されています。

root/
├ ...
├ package.json
├ tsconfig.json
├ .flowconfig
├ src/
│ ├ hello.ts
│ └ hello.test.ts
└ node_modules/
   └ @types/
      └ jest/
         ├ index.d.ts
         ├ ...
         └  ...

テストカバレッジ

もう一つJestに初めから備わっている機能一つとしてテストコードのカバレッジを測定する機能があります。実際の開発でカバレッジを100%にすることはほぼ不可能ですし、100%にこだわることに意味はありませんが、自分たちのテストコードが対象ファイルをどの程度カバーできているのかを知ることは役に立ちます。ここでは試しに先程作成したテストファイルのカバレッジを測定してみましょう。

まずTypeScript版のテスト対象ファイルを以下のように編集します。引数のnameがある場合はそれを使ってhelloメッセージを出力し、ない場合はデフォルトのメッセージを出力するよう条件分岐させます。これくらいなら三項演算子で一行で書くこともできますが今回は見やすさみのためにあえて複数行で書いてみます。

export const hello = (name?: string): string => {
  if (name) {
    return `Hello ${name}!!`;
  } else {
    return 'Hello Jest!!';
  }
}

これで準備完了なので--coverageオプションつきでテストを実行します。

$ yarn test --coverage
yarn run v1.2.1
$ jest --coverage
 PASS  src/hello.test.ts
  hello
    ✓ hello("jest") to be "Hello Jest!!" (2ms)
    ✓ hello("jest") not to be "Hello fukumasuya!!" (1ms)
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.413s
Ran all test suites.
----------|----------|----------|----------|----------|----------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines |Uncovered Lines |
----------|----------|----------|----------|----------|----------------|
All files |       80 |       50 |      100 |       80 |                |
 hello.ts |       80 |       50 |      100 |       80 |              8 |
----------|----------|----------|----------|----------|----------------|

テスト結果とともにカバレッジも出力されました。中身を見てみるとFuncsは100%ですが、その他が100%ではありません。特にBranchが50%ですね。これはどこか辿ってない条件分岐があるということです。Jestは測定結果をlcovとしても出力してくれるのでそちらで詳細を確認してみましょう。プロジェクトのルートディレクトリ直下にcoverageディレクトリが作成されているはずです。その直下のlcov-reportの中にあるindex.htmlをブラウザで開いてみましょう。

コマンド出力と同じ情報が表示されています。hello.tsリンクを開くと各ファイルの詳細が確認できますので開いてみましょう。

4行目にEマークがついています。これは4行目のelseケースに分岐していないことを表しています。テストカバレッジをあげるためテストファイルの2つ目のテストでhello()を引数なしで呼ぶようテストファイルを編集してみましょう。

import { hello } from './hello';
describe('hello', () => {
  it('hello("jest") to be "Hello Jest!!"', () => {
    expect(hello('Jest')).toBe('Hello Jest!!');
  })
  it('hello() to be "Hello Jest!!"', () => {
    expect(hello()).toBe('Hello Jest!!');
  });
});

もう一度テストを実行してみます。

$ yarn test --coverage
yarn run v1.2.1
$ jest --coverage
 PASS  src/hello.test.ts
  hello
    ✓ hello("jest") to be "Hello Jest!!" (2ms)
    ✓ hello() to be "Hello Jest!!"
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.262s
Ran all test suites.
----------|----------|----------|----------|----------|----------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines |Uncovered Lines |
----------|----------|----------|----------|----------|----------------|
All files |      100 |      100 |      100 |      100 |                |
 hello.ts |      100 |      100 |      100 |      100 |                |
----------|----------|----------|----------|----------|----------------|
Done in 3.35s.

カバレッジが100%になりました!lcovでみてもこの通り100%になっていますね。

カバレッジがこんなに簡単に取得できるとはすごく便利ですね。lcovレポートをチームのサーバーなどでホストして見えるようにするとチームでカバレッジ情報を共有できるのでテストへの意識が高まることと思います。さらにJestではcoverageThresholdパラメータをセットすることでカバレッジにしきい値を設定することが可能です。テスト実行時に設定した割合を下回るとテスト失敗と判断されます。

いかがでしたでしょう?この記事ではJestのほんの一部を紹介しただけですが、魅力的なテストツールだと感じていただけたでしょうか?この記事を参考に皆さんもぜひJestを触ってみてください!