Jestでテストを書いてみよう(実践編)

こんにちは、エンジニアのYoheiです。

早速ですが、皆さんはテストコードを書いたことはありますか?

テストを書くとなると腰が重くなってしまう方もいるかもしれませんが、実はテストを書くと良いことがたくさんあるんです。

テストについてよくわからない方、これからテストを書き始めるという方はぜひ読んでみてください!

前半は、テストの概要について説明します。

後半は、Javascriptの テスティングフレームワークである Jest を使って実際のコードを例に説明していきます。

まずはテストの概要について簡単に見ていきましょう。

テストとは

※学校でやるテストではありません。

ここでのテストとは、プログラムのテストです。

プログラムのテストとは、実装したプログラムが意図した通りに動いているかを検証するために行うものです。

テストの種類

テストには主に3つの種類が存在し、以下のように分類できます。

  • 単体テスト
  • 結合テスト
  • システムテスト

これらはよく「テストレベル」と呼ばれるそうです。

各テストレベルの役割

  • 単体テスト:関数などプログラムの部品の最小単位のテスト
  • 結合テスト:関数を組み合わせた機能のテスト
  • システムテスト: 機能を組み合わせた、システムのテスト

上から下に向かってテストが対象とする機能の範囲が大きくなります。

テストの技法

テストの技法とはテストの仕方を意味します。

テストの技法の種類

簡単に以下2つを紹介します。

  • ホワイトボックステスト
  • ブラックボックステスト

ホワイトボックステスト

システム内部の構造を理解した上で、それらがちゃんと意図通りに動作するかを確認するテストの方法をホワイトボックステストと呼びます。

例えばある関数の中で処理Aが実行されたら、次に処理B実行され、最後に処理Cが実行されるはずだ。といったことをテストします。

特徴はシステムの中の構造に着目していることです。

ブラックボックステスト

システム内部の構造を理解する必要はなく、これをしたら結果はこうなるよねと、という意図通りにシステムが動作するかを確認するテストの方法をブラックボックステストと呼びます。

例えば、数字を2つ渡したら、足し算をした結果を返してくれる関数があるとします。

その関数に数字1と2を渡したら3が返されるはずだ、と言うことをテストします。

特徴はシステムの外から見た仕様に着目していることです。

その他

グレーボックステストやボトムアップテスト、トップダウンテストと呼ばれる技法も存在します。気になる方はぜひ調べてみてください。

テストコードを書くメリットデメリット

メリット

コードを書くメリットは様々ですが、 最終的にはサービスを利用してくれるユーザ様のためになるというところに帰結するでしょう。

ではどのようなメリットがあるのか見ていきます。

  • プログラミングコードの品質向上
  • バグの早期発見
  • バグの少ない機能開発
  • デグレ防止
  • 心理的安心

デメリット

  • 開発のスピードが落ちる
  • テストコードの実装に時間がかかる
  • 仕様変更によるテストコードのメンテナンス

※開発のスピードについては必ずしも落ちるとは言い切れないかもしれません。バグが発生した場合はその修正のコストが発生するからです。


テストのおおよその概要はつかめたでしょうか。

では実践に移りましょう!

実践編(Jest)

当記事では、 Node.js 上でテスティングフレームワークである Jest を使ってテストを書いていきます。

準備

まずは、以下のライブラリをインストールしましょう。

※ Node.js での開発を前提としています。

ライブラリ

  • Jest
    • Javascript のテスティングフレームワークです。

インストール後、package.json に以下を追加しておきましょう。

{
  "scripts": {
    "test": "jest"
  }
}

メソッド

動作をテストをするメソッドをまとめた TestService class を定義した index.js ファイルと、テストをするための service.test.js 用意します。

テストを目的としているのでコードを深く理解する必要ありません。コピー&ペーストで進んでいきましょう。

index.js

class TestService {

  multiplyNum(num, multiplyBy) {
    return num * multiplyBy;
  }

  saveNum(num, saveTo) {
    return new Promise((resolve) => {
      setTimeout(() => {
        saveTo.push(num);
        resolve('Saved');
      }, 100);
    })
  }

  multiplyAndSave(num, multiplyBy, saveTo) {
    const res = this.multiplyNum(num, multiplyBy);
    this.saveNum(res, saveTo);
    return res;
  }
}

module.exports = new TestService();

service.test.js

// テスト対象の TestService クラスのインスタンスを index.js から読み込む
const service = require('./index');

// ここから下にテストを記述

解説

各メソッドを解説します。

  • multiplyNum
    • 第1引数の数値を、第2引数の数値で掛け算し、結果の数値を返すメソッド。
  • saveNum
    • Promiseを返す非同期のメソッドです。
    • 0.1 秒後に第1引数の数値を、第2引数の配列に格納します。
  • multiplyAndSave
    • 同期的にmultiplyNumを実行し、非同期的にsaveNumを実行します。
    • multiplyNumの返り値を返すメソッドです。
    • 計算はすぐして欲しいけど、データの保存は裏でよしなにやっといて〜、なシチュエーションを想定。

実践 基礎編

最初にテスト用の下地を用意します。

service.test.js に以下のコードを追加してください。

describe('TestService', () => {
    // ここにテストを追加
});

describe はいくつかの関連するテストをまとめるためのブロックで、この中にテストを書いていきます。今回はTestServiceというブロック名にしました。

test1

下地ができたので、まずはmultiplyNumメソッドが正しく動作することを確認します。

以下の内容でテストしましょう。

  • 2 x 2 は4になる

service.test.jsの describe 内に以下のコードを記述してください。

test('2 times 2 should be 4', () => {
  const result = service.multiplyNum(2, 2);
  expect(result).toBe(4);
});

解説

上記を解説します。

テストを書くときは test と書きます(itでも同様)。

第1引数にテスト名を、第2引数にテストの確認項目を含む関数を設定します。 3番目の引数 (任意) は タイムアウト値 (ミリ秒単位) で、中止するまでの待ち時間を指定します。 注意: デフォルトのタイムアウトは5秒です。

const result = service.multiplyNum(2, 2);

このコードは見ての通りmultiplyNumメソッドを呼び出して返り値を受け取っています。

メソッドが正しく動作していれば result には 4 が入るはずです。

この、〇〇なはずだ、という期待を書くのがテストになります。そのコードが以下になります。

expect(result).toBe(4);

expect(A).toBe(B) => A は B なはずだ、という期待です。

では実際にテストを実行してみます。

実行

以下のコマンドで実行します。

npm test
or
yarn test

成功すると以下のようなログが表示されます。

% npm test

> @ test /Users/*****/jest-test
> jest

PASS  ./service.test.js
TestService
  ✓ 2 times 2 should be 4

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total

test2

では、数値にマイナス2をかけた場合も同様に動くでしょうか。上記の test に続けてテストしてみましょう。

test('2 times 2 should be 4', () => {
  ...
}
test('2 times negative 2 should be negative 4', () => {
  const result = service.multiplyNum(2, -2);
  expect(result).toBe(-4);
});

実行

PASS  ./service.test.js
TestService
  ✓ 2 times 2 should be 4 (1 ms)
  ✓ 2 times negative 2 should be negative 4

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total

無事に成功したようです。

test3

次に非同期メソッドのsaveNumメソッドをテストします。saveNumメソッドが正しく動作することを確認するために以下の内容でテストしましょう。

  • 数値の 100 と 空の配列 を渡して処理を実行すると、処理後の配列には数値の 100 が格納されている。

非同期メソッドも書き方はほぼ同じです。もし await をする場合は async を test メソッドの第2引数の始めにつけてあげるだけです。

test('number should be saved', async () => {
  const saveTo = [];
  await service.saveNum(100, saveTo);

  const expected = [100];
  expect(expected).toEqual(expect.arrayContaining(saveTo));
});

解説

分解してみていきましょう。

以下は空の配列を用意し、saveNumメソッドの引数に 数字の100と一緒に渡して処理を実行しているだけです。

const saveTo = [];
await service.saveNum(100, saveTo);

次のコードが読みにくいかもしれませんが実は簡単です。

const expected = [100];
expect(saveTo).toEqual(expect.arrayContaining(expected));

expectに渡した配列saveToが、arrayContainingに渡した配列expectedが持つ要素を含んでいることを期待します。

実行

ではテストを実行してみましょう。

PASS  ./service.test.js
TestService
  ✓ 2 times 2 should be 4 (1 ms)
  ✓ 2 times negative 2 should be negative 4 (1 ms)
  ✓ number should be saved (105 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total

こちらのテストも無事成功しました。


その他にも多くの expect メソッドが存在するので気になる方は以下をご参考ください。

詳細はこちら:https://jestjs.io/ja/docs/expect

実践 応用編

最後に少しトリッキーなservice.multiplyAndSaveメソッドをテストします。

※このメソッドは、内部で非同期メソッドであるsaveNumメソッド を await をせずに実行しています。

まずはこれまで通り書いてみましょう。

test4

test('returns array containing a multiplied number', async () => {
  const number = 10;
  const multiplyBy = 10;
  const saveTo = [];
  const numAfterMultiplied = await service.multiplyAndSave(number, multiplyBy, saveTo);

  const expectedNumAfterMultiplied = 100;
  const expectedArray = [100];

  expect(numAfterMultiplied).toBe(expectedNumAfterMultiplied);
  expect(saveTo).toEqual(expect.arrayContaining(expectedArray));
});

実行

テストを実行すると失敗します。

FAIL  ./service.test.js
TestService
  ✕ returns array containing a multiplied number (2 ms)

● TestService › returns array containing a multiplied number

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

  Expected: ArrayContaining [100]
  Received: []

    37 |
    38 |     expect(numAfterMultiplied).toBe(expectedNumAfterMultiplied);
  > 39 |     expect(saveTo).toEqual(expect.arrayContaining(expectedArray));
       |                    ^
    40 |   })
    41 | })

    at Object.<anonymous> (service.test.js:39:20)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 3 passed, 4 total

解説

multiplyAndSaveメソッドの内部で非同期に実行している saveNumメソッドは Promise を返す非同期なメソッドです。つまり、Promise が解決されていなくても処理は次に進んでいきます。

処理が expect を実行した段階ではまだ Promise が解決されていないため、saveTo変数は空の配列のままexpectedArrayの値と比較されてしまい、 expect の条件を満たしていないためテストが失敗してしまいました。

これを解決するためにsaveNumメソッドの mock化を行います。

mock関数でできること

  • 関数が持つ実際の実装を除去
  • 関数の呼び出し(また、呼び出し時に渡されたパラメータも含め)をキャプチャ
  • new によるコンストラクタ関数のインスタンス化をキャプチャ

などなど書いてありますが、つまりはメソッドの振る舞いを変更できたりするわけです。

詳細はこちら:https://jestjs.io/ja/docs/mock-functions


今回これを使ってやることは以下の3つです。

  1. jest.spyOn メソッドで、saveNumメソッドを一旦内部の処理は何もしない null を返すだけの関数にmock化
  2. mock 化したメソッドがmultiplyAndSaveメソッドの内部で呼び出された時に渡された引数を取得
  3. saveNumメソッド の mock 化を解消(元の関数の状態に修正)し、取得した引数と共にsaveNumメソッド を個別に呼び出し

修正

コードを次のように修正します。4つ目のテストを以下のコードで上書きしてください。

const syncMultiplyAndSave = async (number, multiplyBy, saveTo) => {
  const mockSaveNum = jest.spyOn(service, 'saveNum').mockReturnValue(null);
  const res = await service.multiplyAndSave(number, multiplyBy, saveTo);
  const argsCalledWithMockSaveNum = mockSaveNum.mock.calls[0];
  mockSaveNum.mockRestore();
  await service.saveNum(...argsCalledWithMockSaveNum);
  return res;
}
test('returns array containing a multiplied number', async () => {
  const number = 10;
  const multiplyBy = 10;
  const saveTo = [];
  const numAfterMultiplied = await syncMultiplyAndSave(number, multiplyBy, saveTo);

  const expectedNumAfterMultiplied = 100;
  const expectedArray = [100];

  expect(numAfterMultiplied).toBe(expectedNumAfterMultiplied);
  expect(saveTo).toEqual(expect.arrayContaining(expectedArray));
});

解説

新たにsyncMultiplyAndSave関数を定義し、service.multiplyAndSaveメソッドの代わりに呼び出しています。

mock化
const mockSaveNum = jest.spyOn(service, 'saveNum').mockReturnValue(null);

これは、syncMultiplyAndSaveメソッド内で、saveNumメソッドをmock化しています。

jest.spyOnは mock関数を返します。さらに、.mockReturnValue(null)で mock関数が呼び出された際の返り値を null にしています。

メソッド呼び出し
const res = await service.multiplyAndSave(number, multiplyBy, saveTo)

次にmultiplyAndSaveメソッドを通常通り実行します。

この時、内部で呼ばれるsaveNumメソッドは mock関数になっており、null を返す以外は何もしません。

メソッド呼び出し時の引数取得
const argsCalledWithMockSaveNum = mockSaveNum.mock.calls[0];

メソッドを実行時に内部で呼び出された mock関数は、自身が呼び出された時に渡された引数を記憶しているのでそれを取得します。

詳細はこちら:https://jestjs.io/ja/docs/mock-function-api#mockfnmockcalls

mock関数を元の状態に戻す
mockSaveNum.mockRestore();

その後 mock関数の saveNumメソッドを元のメソッドに戻します。

saveNum を await 実行

最後に、ここまでに取得した引数を使って、saveNumメソッドを個別に呼び出しています。

ここでは同期的に実行したいので、awaitをつけています。

await service.saveNum(...argsCalledWithMockSaveNum) // 配列なので スプレッド構文を使用

テスト実行

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

PASS  ./service.test.js
TestService
  ✓ 2 times 2 should be 4 (1 ms)
  ✓ 2 times negative 2 should be negative 4
  ✓ number should be saved (101 ms)
  ✓ returns array containing a multiplied number (106 ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total

お疲れ様です、無事に成功しました。

まとめ

お疲れ様でした。

当記事では、なるべく簡易な説明にするために、テストコードの記述は少なめにしましたが、プロジェクトによっては、さらにテスト用のダミーデータを用いて検証したりもします(ユーザーデータなど)。

今回私も実際のプロジェクトでテストコードを書いたことで、リリース前に多くのバグの早期発見・未然防止ができました。

特に複雑なコードを納期などがある中で書こうとすると、ほぼ必ず buggy なコードが紛れます(人間なのでしょうがないのでしょう)。

時間がないときはブラックボックステストだけでも書くと、アプリの品質をあげることができマスので、みなさんもぜひ可能な箇所からテストを書いてみてください。

最後に改めてメリット・デメリットを掲載しますでぜひご覧ください。

テストコードを書くメリットデメリット

ここまでご覧いただきありがとうございました。

参照