Subterranean Flower

JavaScriptでも単体テストを導入しよう!ってかテストって何?

Author
古都こと
ふよんとえんよぷよぐやま!!!

昔はお遊び程度の使われ方をしていたJavaScriptも、本格的な開発に使われるようになってからだいぶ経ちました。

開発の規模が大きくなってくると、どうしても「テスト」という考え方からは逃れられません。そこで、JavaScriptの開発でもテストを導入してみましょう。

この記事では、「JavaScriptのテストってどうするの」という方や「そもそもテストって何」という方に向けて、JavaScriptにおけるテストについて紹介します。

テストってなんだろう

個人で普通にプログラミングしていると「テスト」という単語にはなかなか触れる機会がないと思います。ですが、プロジェクトがそこそこの規模になってくるとテストは非常に重要になってきます。

まずはテストとはなんなのか、どういった効果があるのかについて説明します。

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

プログラミングにおいて、特に小さな規模の開発においては、単にテストというと「単体テスト(ユニットテスト)」のことを指す場面が多いです。

単体テストとは、プログラムをモジュール(いくつかの部品)ごとに分けて考えて、「モジュールが単体できちんと動作しているか」を確かめる作業になります。モジュールは基本的に関数やクラスといった単位でまとまっています。

例えば「clamp関数がきちんと数値を範囲内に収めることができているのか」「行列クラスがきちんと行列の足し算ができるか」などを確かめるのが単体テストです。

単体テストを行うことで、プログラムがモジュールごとに正確に動作しているのか、またはどこが正確に動作していないのかを確かめることができます。「なんとなくこれぐらい書いてこれぐらい動いた」みたいなふわふわした感覚ではなく、「この機能とこの機能は正常に動作し、これとこれは動作していません。現在50%正常に動作しています」という感じにプログラムの定量的な品質保証になるわけですね。また、どこが動いていないのかを把握することができるので、バグの修正もしやすくなります。

単体テストと対になる概念として「結合テスト」があります。結合テストではその名の通り、複数のモジュールを組み合わせたテストをします。加えて、より大きなテストとして「システムテスト」などを設けることもあります。今回は単体テストにフォーカスするため、これらは取り扱いません。

テストの目的

テストをする目的は、先ほどもちょろっと触れましたが、プログラムの品質を保証することです。

ユーザも開発者も、当然プログラムにはバグがないことを期待します。しかし「なんとなく」の自分独自の方法論でやっていると、どうしてもバグが紛れ込んでしまいます。一方で、テストという考え方は歴史も実績もあり、定量的にプログラムの品質を測ることができるので、重宝されています。

また、副次的な作用ですが、テストを記述するためにはまず「このモジュールはどんな機能を有しているべきなのか」を書き出す必要が生まれるため、プロジェクトの整理にもなります。特にぐだぐだしやすい個人開発においてかなり有益です。

テストの利点は様々ですが、目的をまとめると「プログラムの品質を保証する」の一言になります。テストをすることで、品質がちゃんとしているということを確認したいわけです。

テストの自動化

テスト(特に細かい作業が必要になる単体テスト)は、手動でやるにはなかなかつらいものがあります。規模が大きくなってくると大量のテストをしないといけませんし、人間が頑張ってやるとミスも起きます。

そこで一般的に単体テストは「自動化」します。「こういう入力があったらこのモジュールはこういう動作をするよね」というのをあらかじめ記述しておき、それを自動的に実行するテストフレームワークにテストを任せます。テストフレームワークは、テストを通過したかどうか、どのテストが落ちたか、ソースコードの網羅率は、などの様々な情報を出力します。

テストは普通のプログラムの形で記述します。なので「テスト自体にバグがある」という悲しい状況にも陥ることがあります。テスト自体にバグがあるとどうしようもないので、そこは気をつけましょう。

JavaScriptに単体テストを導入する

様々なテストフレームワーク

どのプログラミング言語にも、単体テストを補助するフレームワークは存在します。特にJavaScriptはいろいろなフレームワークがあり、有名どころでは、mocha + chai、karma + jasmine、jestなどがあります。

どれを使っても実はそんなに大差ないのですが、今回はfacebook製の単体テストフレームワークであるjestを使うことにします。jestは近年では最もポピュラーなフレームワークであり、facebookが作っているためReactなどにも対応しているのがポイントです。

jestの導入

jestの実行環境はNode.jsとなります。Node.jsについては詳しくは解説しませんので、各自調べてください。

jestでテストを行うためには、当然Node.jsのモジュールシステムに準拠している必要があります。また、jestは現時点でES Modulesに対応していないので、ES Modulesを使用する場合はbabelの導入が必要になります。今回はbabelと一緒にいれることにします。

まずプロジェクトディレクトリをNode.jsのプロジェクトとして構成します。プロジェクトディレクトリ内で以下のコマンドを実行します。すでにNode.jsで作られているプロジェクトにjestを導入する場合は次に進んでください。

npm init

いろいろ質問されますが、とりあえず全部そのままEnterを押しておけば大丈夫です。Node.jsのプロジェクトとして初期化できたら、必要なパッケージを導入します。開発時依存モジュールとして導入するので–save-devが要ります。

npm install --save-dev jest babel-jest babel-core@^7.0.0-bridge.0 @babel/core @babel/preset-env

次にjestがES Modulesに対応していない問題に対処するため、babelでCommonJSに変換するためのプラグインを導入します。

npm install --save-dev @babel/plugin-transform-modules-commonjs

このプラグインをテスト時に適用するために、babelの設定ファイルを記述します。プロジェクトディレクトリの中に「.babelrc」というファイルを作成して、以下の内容を書き込みます。ファイル名の最初のピリオドを忘れないように!

{
  "env": {
    "test": {
      "plugins": [ "@babel/plugin-transform-modules-commonjs" ]
    }
  }
}

これで準備は完了です。

テストしてみる

まずは簡単なテストを走らせてみましょう。単純に数を足して返すだけのadd関数を作ります。プロジェクトディレクトリの下にmylib.jsというファイルを作ってください。

export function add(a, b) {
  return a + b;
}

この関数はexportされているので外部からモジュールとして利用可能です。

次に「__tests__」ディレクトリを作成します。アンダースコアは前後に2つずつなのと、”tests”と複数形なのに気をつけてください。この中に置かれた*.jsファイルをjestはテストファイルをして認識します。

__tests__ディレクトリの中にmylib.test.jsファイルを作成します。

import { add } from '../mylib';

test('add 1 + 2', () => {
  expect(add(1, 2)).toBe(3);
});

これがテストの実行ファイルとなります。test関数に説明(表示用)と、実際に実行する関数を渡します。関数の中でexpect(value).toBe(expectedValue)と記述することで、valueがexpecteValueであることをテストできます。

それではjestを実行してみましょう。以下のコマンドを実行します:

npx jest

npxです。npmと打ち間違えないでください!これでjestがテストファイルを探しに行き、テストを自動的に実行します。

 PASS  __tests__/mylib.test.js
  ✓ add 1 + 2 (2ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.901s
Ran all test suites.

テストが実行され、見事パスしました!add関数が1+2を3と出力するのを検証できたわけです。これでテストの基本ではできました。

ここで、テストファイルひとつひとつのことを「テストスイート」と呼びます。また、個々のテストのことを「テストケース」と呼びます。テストは複数のテストケースから成るテストスイートを、複数個組み合わせたものになります。

複数のテストを行う

実際にはテストは複数行われるはずです。複数のテストを記述してみましょう。

テストを複数実行するには、テストファイルの中に複数のtest関数を書くだけですが、ある程度グループ化しておくと綺麗になります。describe関数を用いることでテストをグループ化できます。

__tests__/mylib.test.jsを書き換えます:

import { add } from '../mylib';

describe('add', () => {
  test('1 + 2', () => {
    expect(add(1, 2)).toBe(3);
  });

  test('2 + 2', () => {
    expect(add(2, 2)).toBe(4);
  });

  test('-1 + 2', () => {
    expect(add(-1, 2)).toBe(1);
  });
});

これでnpx jestを実行してください。複数のテストが走るのがわかるはずです。

 PASS  __tests__/mylib.test.js
  add
    ✓ 1 + 2 (2ms)
    ✓ 2 + 2 (1ms)
    ✓ -1 + 2

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.939s, estimated 1s
Ran all test suites.

全てテストを通過しましたね。

また、ひとつのテストスイートの中に複数のdescribeがあってもよいので、そのあたりの記述は自由にしてください。

マッチャー

テストケースで用いるtoBeの部分を、jestでは「マッチャー(Matcher)」と呼びます。

マッチャーには様々な種類が存在します。一覧は公式サイトのドキュメントをご覧ください。

例えば有名な話として、現代のコンピュータは0.1 + 0.2を正確に計算できないため、以下のテストは失敗します:

import { add } from '../mylib';

describe('add', () => {
  test('0.1 + 0.2', () => {
    expect(add(0.1, 0.2)).toBe(0.3);
  });
});

npx jestで実行してみてください。失敗するはずです。

 FAIL  __tests__/mylib.test.js
  add
    ✕ 0.1 + 0.2 (6ms)

  ● add › 0.1 + 0.2

    expect(received).toBe(expected) // Object.is equality

    Expected: 0.3
    Received: 0.30000000000000004

      3 | describe('add', () => {
      4 |   test('0.1 + 0.2', () => {
    > 5 |     expect(add(0.1, 0.2)).toBe(0.3);
        |                           ^
      6 |   });
      7 | });

      at Object.toBe (__tests__/mylib.test.js:5:27)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.879s, estimated 1s
Ran all test suites.

こういったときはマッチャーを変更するとうまく行きます。浮動小数点数の比較にはtoBeCloseToマッチャーを使用すると良いでしょう。toBeCloseToはある程度の誤差を許容するマッチャーです。

import { add } from '../mylib';

describe('add', () => {
  test('0.1 + 0.2', () => {
    expect(add(0.1, 0.2)).toBeCloseTo(0.3);
  });
});

これをnpx jestすると成功します。

 PASS  __tests__/mylib.test.js
  add
    ✓ 0.1 + 0.2 (2ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.923s, estimated 1s
Ran all test suites.

他にもtoBeTruthyやtoBeFalsyといったbooleanに対するマッチャーや、配列の中に値が含まれるかを検証するtoContainマッチャー、関数がエラーを投げるか検証するtoThrowマッチャーなどが存在します。場合によって適切なマッチャーを使いましょう。

また、notマッチャーを挟むことで、任意のマッチャーの意味を逆転できます。

import { add } from '../mylib';

describe('add', () => {
  test('0.1 + 0.2', () => {
    expect(add(0.1, 0.2)).not.toBeCloseTo(5);
  });
});

非同期処理のテスト

JavaScriptで非常に多いのが非同期な処理です。jestでは非同期処理のテストもできます。

mylib.jsを書き換えるか追記してください:

// 指定時間が経過するとresolveされる非同期関数
export async function timer(timeMs) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve('success!'), timeMs);
  });
}

このtimer関数は指定時間が過ぎると’success!’としてresolveされる非同期関数です。これをテストするにはresolvesマッチャーを使用します。__tests__/mylib.test.jsを以下のように書き換えます。

import { timer } from '../mylib';

test('async timer', () => {
  expect(timer(1000)).resolves.toBe('success');
});

これをnpx jestで実行すると成功します。resolvesマッチャーはPromiseの値を自動的に展開してくれます。あとは普通のマッチャーで検証するだけです。

なお、rejectしたのを検証したい場合はrejectsマッチャーを使用します。

モック関数

モジュールは値を返すものばかりではありません。内部で処理を完結させてしまって、外に値が出てこないこともあります。

たとえばコールバック関数を受け取り、何度かコールバック関数を呼び出すような高階関数loopを考えます。このloop関数は値を返しません。mylib.jsに書いてみましょう:

export function loop(callback, loopNum) {
  for(let i = 0; i < loopNum; i++) {
    callback(i);
  }
}

これをテストするには、コールバック関数の部分にモック関数と呼ばれる関数を入れて、モック関数が何度呼ばれたかを検証します。

モック関数は「自分が何回呼ばれたか」「自分にどんな引数が渡されたか」などを記憶する関数なので、内部で処理が完結していてもその結果を外部から参照することができます。

モック関数はjest.fn()で作ることができます。テストは以下のように書きます:

import { loop } from '../mylib';

test('loop', () => {
  const loopNum = 5;
  const mock = jest.fn(); // モック関数を作る
  loop(mock, loopNum); // mockを渡して実行する

  // mock関数の呼び出しについて検証する
  expect(mock).toBeCalledTimes(loopNum); // 5回呼び出されているはず
  expect(mock).nthCalledWith(1, 0); // 1回目はmock(0)で呼び出される
  expect(mock).nthCalledWith(2, 1); // 2回目はmock(1)で呼び出される
});

これでコールバック関数の呼び出し回数、呼び出された時の引数を検証することができます。

実際にモジュールをテストしてみる

さて、ここまで材料が揃ったらあとは実際にテストをやってみるだけです。今回はclamp関数を題材にテストを行ってみましょう。以下の関数をmylib.jsに書きます:

// clamp関数はvalueをminからmaxの間に収める
//
// valueがminより小さいならminを返し、
// valueがmaxより大きいならmaxを返す
// minとmaxの間ならそのままのvalueを返す
export function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

clamp関数は数値をminからmaxの間に収める関数です。様々なテストケースが考えられますが、大きくは以下のように分けられるはずです:

  • value < min
  • value = min
  • min < value < max
  • value = max
  • value > max

特に境界(条件が変わる)値が重要になることが多いです。境界値付近はバグが出やすいところなのでできるだけチェックしておいた方が良いです。

これをテストに落とし込むと、例えば以下のようになります:

import { clamp } from '../mylib';

describe('clamp', () => {
  test('value < min', () => {
    expect(clamp(0, 2, 5)).toBe(2);
  });

  test('value = min', () => {
    expect(clamp(2, 2, 5)).toBe(2);
  });

  test('min < value < max', () => {
    expect(clamp(4, 2, 5)).toBe(4);
  });

  test('value = max', () => {
    expect(clamp(5, 2, 5)).toBe(5);
  });

  test('value > max', () => {
    expect(clamp(6, 2, 5)).toBe(5);
  });
});

そしてこれをnpx jestでテストしてみます。

 PASS  __tests__/mylib.test.js
  clamp
    ✓ value < min (2ms)
    ✓ value = min
    ✓ min < value < max (1ms)
    ✓ value = max
    ✓ value > max

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        0.872s, estimated 1s
Ran all test suites.

成功しましたね。これでこの関数はある程度正しく動作していることが確認できました。

テストにまつわるあれこれ

テストの限界

テストは人間が記述するものなので、やはりどうしても限界は存在します。テストケースを作成する人間が思いつかなかった入力に対しては、単体テストは無力です。また、テスト自体にバグがあってちゃんとテストが走っていなかった、というのもよくあります。

テストはある程度の品質の保証はしてくれますが、完璧であることは保証してくれません。テストを信頼すると同時に、テストを信頼しすぎないという姿勢も大事になってきます。

テストのための設計

今回紹介したテストは、実に単純なものでした。関数がひとつあり、その関数をいくつかのパターンでテストするだけです。

しかし現実にはテストをするのも難しいモジュールも存在します。モジュール内部で状態をもつためテストがしにくかったり、他のモジュールに強く依存するため単体テストとして切り離せなかったり、呼び出すたびに結果が違ったり、様々なテスト困難なモジュールがあります。

テストを導入するとわかっている場合、あらかじめテストを意識した設計をしておくと、単体テストがスムーズにいきます。メソッドに冪等性を持たせるようにしたり、直接的なHTTPリクエストをラップしたり、データベースのアクセスなどにはモックを差し込めるようにしたり、様々な改善策が存在します。

テスト困難なモジュールは、つまり品質が保証できないということでもあります。テストを導入する場合は、できるだけテストに優しいインターフェイスを構築することを心がけましょう。

ホワイトボックス/ブラックボックス

単体テストには2つの手法が存在します。ホワイトボックステストと、ブラックボックステストです。

ホワイトボックステストは実際にプログラムのコードを読み、「こういう条件ではこの命令が走り、こういった結果になる」ということを全部把握した上でやるテストです。中身を把握しているので質の高いテストが可能になりますが、労力もその分増えてしまいます。

ブラックボックステストは「この値を入れるとこういう値が返ってくる」ということをだけを意識して、中身の実装までは見ないテストです。表層だけを把握していれば良いのでテストを書くのが楽になりますが、内部実装の都合がわからないので潜在的なバグを見落としやすくなります。

どちらがいいとか悪いとかはないので、場合によって使い分けてください。

カバレッジ

テストがどれだけソースコードの中を網羅したか、ということを示す値としてカバレッジがあります。例えばカバレッジ100%なら全てのコードを実行したことになります。

カバレッジが高ければ良いというわけではないのですが、低すぎてもあまり意味はないので、ある程度カバーできているような値を目指しましょう。

jestでは以下のようにすることでカバレッジをファイルに出力できます。

npx jest --coverage

テスト駆動開発(Test-Driven Development / TDD)

テストはモジュールに機能を追加したときに行うのが一般的ですが、あえてテストを先に書き、テストに従ってモジュールを構成していくテスト駆動開発という手法も存在します。

テスト駆動開発ではテスト主導の開発になるので、最初にテストを書きます。そして当然テストは失敗します。なのでそのテストに合格するようにモジュールを組み立てていきます。そしてモジュールがテストに通れば次のテストを書き……と繰り返します。

テスト駆動開発ほど極端なやり方を導入するのは難しくても、こういった思想が存在するということは覚えておいて損はないでしょう。

まとめ

テストは現代のソフトウェア開発において欠かせない存在です。JavaScriptに要求される規模も徐々に肥大化しており、テストからは逃れられません。

JavaScriptではjestというテストフレームワークを導入することで簡単に単体テストを実現することができました。jestは様々なマッチャーを持っており、同期処理はもちろん非同期処理も扱うことができます。また、モック関数による関数呼び出しのテストも行えます。

テストはソフトウェアが完全であることを保証はしてくれません。ですが、一定の品質に達しているということは保証してくれます。テストを行うことで、安全に、定量的に、ソフトウェアの品質をはかることができます。

JavaScriptではいままでテストなしでの開発が多かったと思いますが、規模が大きくなるにつれ、テストが重要になってきています。今後のプロジェクトでは、テストを導入することを検討してみましょう。