実用

ユニットテストを書こう (TypeScript版)

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

みなさん、ユニットテストを書いていますか?
このページに訪れた方は、少なくともユニットテストに興味がある方でしょう。

今回のブログでは、ユニットテストの流れをサンプルプログラムとともに簡単にご紹介します。
これからユニットテストを始めたいという方の参考になれば幸いです。

今回のサンプルプログラムはTypeScriptで記述していますが、
Node.jsやその他の言語でも考え方はほぼ同じです。

ユニットテストとは?

ユニットテスト(単体テスト)は、プログラムを構成する単位(ユニット)が正しく動作することを検証するテストです。一般的には、クラスやメソッドがテストの対象となります。

ユニットテストにはテスト自体の合否のほかに、カバレッジ(網羅率)という指標があります。ユニットテストがテスト対象のコードをどのぐらい網羅しているかを表すものです。カバレッジについては後述のサンプルでも触れます。

さて、ユニットテストを書くとどのようなメリットがあるのでしょうか。

メリット
・プログラムの品質を担保できる。
・機能を追加・変更したときの再テストが楽になる。

一般的にはこのようなことが挙げられることが多いですが、これが必ずしも正解とは限りません。当然デメリットもあります。

デメリット
・ユニットテストの実装にコストがかかる。(アプリケーション本体の実装に比べて数倍のコストがかかる)
・ユニットテストの実装にはプログラミングスキル以上にテストスキルも必要となる。
・ユニットテストのメンテナンスを続ける体制が必要。
 →メンテナンスが放棄されてテストが通らない状態になってしまうと意味のないものとなってしまう。
 →繰り返しテストを行うことによって初めて意味が出てくる。
・カバレッジが100%だからといって品質が良いとは限らない。カバレッジはテストのための手段であって目的ではない。

時間・コストの問題もありますから、ただやみくもにテストを行なえばよいというものでもありません。ユニットテストが有用となるケースには次のようなものがあります。
・複雑なロジック、アルゴリズムに対するテスト
・アプリケーションの共通部品に対するテスト
・独自ライブラリーやフレームワークのテスト

各種OSSのライブラリーやフレームワークのリポジトリーを覗くと、たいていテストコードもセットになっています。とくにNode.jsのライブラリーはバージョンアップのサイクルが短スパンであることが多いため、バージョンアップによりAPIがdeprecatedになったり、正常に動かなくなったりすることがあります。そのようなケースにもユニットテストは有効です。ユニットテストを自動で実行することによりそのような問題を検出できることもあります。

プロジェクトの開発体系によっても、ユニットテストの位置づけは変わってきます。

ウォーターフォール型の受注案件では、納品物としてユニットテスト仕様書やテスト結果報告書などを求められるケースもあります。ユニットテストを書くということは、すなわち納品物を作るようなものです。

一方、アジャイル開発ではどうでしょうか。アジャイル開発は、テスト駆動開発 (TDD: test driven development) と合わせて語られることが多いです。TDDとは、その名のとおり最初にテストを書いてから、そのテストに合格するようにアプリケーションのコードを実装する方法です。最初にテストを書くということは、検討・設計を最初にきちんと行なうことになりますので、プログラムの質も上がると言われています。

弊社では、アジャイル寄りで開発するプロジェクトが多く「まずは動くものを作る。最初は多少のバグはあってもよい」というやり方で進めることが多いです。

Facebook社のマーク・ザッカーバーグの言葉にDone is better than Perfect (完璧を目指すよりまず終わらせろ) がありますが、それに通じますね。

ですので、弊社ではテストを書くという文化はあまり根付いていないのですが、要所ではテストを書くようにしています。

ユニットテストの準備

サンプルの紹介に入っていきます。
まずはサンプル用のディレクトリーを作成します。

mkdir test-sample
cd test-sample/
mkdir src
mkdir test

Node.js のパッケージ管理ツールであるnpmに対する初期設定を行ないます。
npmは既にインストール済みであるものとします。

npm init
# (いろいろ聞かれますが、とりあえずすべてEnterで構いません)

package.jsonが生成されます。次のようになっています。

{
  "name": "test-sample",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "directories": {
    "test": "test"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

TypeScriptのユニットテストに必要となるモジュールをインストールします。

typescript : TypeScriptのコンパイラー
mocha : JavaScriptのテストフレームワーク
istanbul : JavaScriptのカバレッジ測定ツール
remap-istanbul : TypeScriptをコンパイルした際に生成されるSourceMapをもとに、JavaScriptのカバレッジのレポートをオリジナルのTypeScriptのファイルにリンクさせるツール
postinstall-build : 後述するtypemoqが依存するモジュール

これらはコマンドとして使用するものなので、グローバルにインストールします。複数人で共有環境を使用されている方は注意してください。

npm install -g typescript mocha istanbul remap-istanbul postinstall-build

次のモジュールはローカルにインストールします。(test-sample 配下へのインストール)

chai : アサーションライブラリー
typemoq : モッキングライブラリー

npm install chai typemoq --save-dev

# Windows環境の場合には symlink のエラーが出ることがあります。その場合には次のようにシンボリックリンクを作成しないオプションを追加してください。
# npm install chai typemoq --save-dev --no-bin-links

TypeScriptの型定義もインストールします。

npm install @types/{mocha,chai} --save-dev

以上でユニットテスト環境の準備は完了です。

ユニットテスト対象のプログラム

ユニットテスト対象のプログラムを作成します。
今回のサンプルは、UserUtilsクラスにユーザーIDを渡すと、UserManagerクラスを使って該当のユーザーを検索し、成人かどうかを判定するプログラムとしています。UserUtilsクラスがテスト対象となります。

src/user.ts

/**
 * ユーザークラスです。
 */
export class User {
    /**
     * ID, 名前, 年齢をプロパティとして持ちます。
     */
    constructor(public id: number, public name: string, public age: number) {
    }
}

src/user-manager.ts

import { User } from './user';

/**
 * ユーザーを管理するクラスです。 
 */
export class UserManager {
    constructor() {
    }

    /**
     * ユーザーIDをキーにユーザーを検索します。
     * 
     * @param userId ユーザーID
     * @return ユーザーのオブジェクト
     */
    public find(userId: number): User {
        // サンプルのため、見つからない旨のエラーをthrowします。
        throw new Error('not found!');

        // 実際には、データベース検索やWEB-API呼び出しなどで該当IDのユーザーを検索する処理などが実装されます。
        // なお、それらの処理は一般的には非同期処理となり、コールバックやPromiseなどでデータを返却する処理が必要となりますが、
        // このサンプルでは説明の簡略化のために同期処理のインターフェースにしています。
        // (async/await の仕組みを使って非同期処理を同期処理のように記述することもできます。)
    }
}

src/user-utils.ts

import { UserManager } from './user-manager';
import { User } from './user';

/**
 * ユーザーのユーティリティーです。
 */
export class UserUtils {
    constructor() {
    }

    /**
     * ユーザーが成人かどうかをチェックします。
     * 
     * @param userId ユーザーID
     * @return 成人の場合はtrue、未成年の場合はfalse
     */
    public isAdult(userId: number): boolean {
        // ユーザーを検索します。
        // 見つからない場合はErrorがthrowされるのでそのまま上位にthrowします。
        const manager = new UserManager();
        const user = manager.find(userId);

        let isAdult = false;
        if (user.age >= 20) {
            isAdult = true;
        } else {
            isAdult = false;
        }
        return isAdult;
    }
}

ユニットテストを書いてカバレッジを見る

前述のUserUtilsクラスをテスト対象としてテストを書きます。

test/user-utils.test.ts

import * as chai from 'chai';

import { UserUtils } from '../src/user-utils';

describe('UserUtilsのテスト', () => {

    it('存在しないユーザーの場合', () => {
        const target = new UserUtils();

        // Errorがthrowされることを確認
        chai.assert.throws(() => { target.isAdult(99999) }, Error, 'not found!');
    })
});

ここまでで一度実行してみます。その前にTypeScriptのコンパイル定義を作成します。

tsconfig.json

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
    "sourceMap": true,
    "outDir": "./build"
  },
  "exclude": [
    "node_modules"
  ],
  "files": [
    "src/user.ts",
    "src/user-manager.ts",
    "src/user-utils.ts",
    "test/user-utils.test.ts"
  ]
}

コンパイルします。

tsc

とくに問題がなければ build ディレクトリー配下にjsファイルとmapファイルが生成されています。
mapファイルはSourceMapと呼ばれるもので、tsファイルとjsファイルの対応付けがなされるためのものです。
この時点でのファイルは次のようになっています。

.
├── build
│   ├── src
│   │   ├── user-manager.js
│   │   ├── user-manager.js.map
│   │   ├── user-utils.js
│   │   ├── user-utils.js.map
│   │   ├── user.js
│   │   └── user.js.map
│   └── test
│       ├── user-utils.test.js
│       └── user-utils.test.js.map
├── node_modules
├── package.json
├── src
│   ├── user-manager.ts
│   ├── user-utils.ts
│   └── user.ts
├── test
│   └── user-utils.test.ts
└── tsconfig.json

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

mocha ./build/test/*.test.js


  UserUtilsのテスト
    ✓ 存在しないユーザーの場合


  1 passing (12ms)

テストに成功(passing)していますね。

テスト実行時のカバレッジも見てみましょう。
次のコマンドでテストの実行とカバレッジの取得が同時に行われます。mochaではなく_mochaとなっているのは誤記でありません。おまじないだと思ってください。

istanbul cover _mocha -- ./build/test/*.test.js


  UserUtilsのテスト
    ✓ 存在しないユーザーの場合


  1 passing (9ms)

=============================================================================
Writing coverage object [/path/to/test-sample/coverage/coverage.json]
Writing coverage reports at [/path/to/test-sample/coverage]
=============================================================================

=============================== Coverage summary ===============================
Statements   : 76.19% ( 16/21 )
Branches     : 0% ( 0/2 )
Functions    : 100% ( 6/6 )
Lines        : 76.19% ( 16/21 )
================================================================================

カバレッジが計測されました。
./coverage/lcov-report/index.html にカバレッジレポートがHTML形式で出力されていますので見てみましょう。

TypeScriptで書いたのにJavaScriptのカバレッジとなっていますね。
TypeScriptのカバレッジで見れるようにします。

remap-istanbul -i ./coverage/coverage.json -o ./coverage/ts-report -t html

./coverage/ts-report/index.html を見てみましょう。

TypeScriptのカバレッジが見れましたね。
赤い箇所がテストで実行されていない部分です。

モックオブジェクトを使う

ここまででユーザーが見つからない場合のテストができました。

続いて、ユーザーが成人の場合と未成年の場合のテストをしたいところですが、肝心のユーザーを検索するUserManagerクラスがまだ実装されていません。実際のシステム開発においても、複数人で分担作業している場合には、クラス間のインターフェースは事前に定まってはいるものの、呼び出し先のクラスがまだ実装されていないといったことがあります。

そういった場合には、呼び出し先のUserManagerクラスを、疑似の振る舞いを行なうモックオブジェクト(Mock Object)に差し替えることにより、UserUtilsクラスのテストを行なうことができます。
UserUtilsクラスのテストとしては、UserManagerクラスの実装までは関与する必要がないためです。
UserManagerクラスが既にデータベース検索やWEB-API呼び出し処理が実装済みであった場合にも、それらを省略したいケースにもモックオブジェクトは有用です。

モックオブジェクトを使用したテストを書いてみます。

ですが、その前に本体側でリファクタリングが必要な部分があります。
UserUtilsクラス内でUserManagerクラスをnewしている部分です。

    public isAdult(userId: number): boolean {
        const manager = new UserManager();
        ~(略)~        

このように依存するクラスを直接newしていると、モックオブジェクトへの差し替えができないので、UserUtilsクラスをリファクタリングして外部からUserManagerを渡せるようにします。これは依存性の注入(DI: Dependency Injection)と言われるものです。DIの方法はいくつかありますが、今回はコンストラクターで渡すようにします。リファクタリング後のコードは次のようになります。

src/user-manager.ts

import { UserManager } from './user-manager';
import { User } from './user';

/**
 * ユーザーのユーティリティーです。
 */
export class UserUtils {
    constructor(private manager: UserManager) {
    }

    /**
     * ユーザーが成人かどうかをチェックします。
     * 
     * @param userId ユーザーID
     * @return 成人の場合はtrue、未成年の場合はfalse
     */
    public isAdult(userId: number): boolean {
        // ユーザーを検索します。
        // 見つからない場合はErrorがthrowされるのでそのまま上位にthrowします。
        const user = this.manager.find(userId);

        let isAdult = false;
        if (user.age >= 20) {
            isAdult = true;
        } else {
            isAdult = false;
        }
        return isAdult;
    }
}

なお、テストしやすいように本体側をリファクタリングすることはよくあることですが、上級者はあらかじめテストのしやすさを考えてプログラミングします。

最終的に、モックオブジェクトを使用したテストは次のようになります。
モックオブジェクトを使うためのモッキングライブラリーとしてtypemoqを使用しています。

test/user-utils.test.ts

import * as chai from 'chai';
import * as typemoq from "typemoq";

import { UserUtils } from '../src/user-utils';
import { UserManager } from '../src/user-manager';
import { User } from '../src/user';

describe('UserUtilsのテスト', () => {
    // テスト用ユーザー
    const user1: User = { id: 1, name: '成人ユーザー', age: 20 };
    const user2: User = { id: 2, name: '未成年ユーザー', age: 19 };

    it('存在しないユーザーの場合', () => {
        const manager = new UserManager();
        const target = new UserUtils(manager);

        // Errorがthrowされることを確認
        chai.assert.throws(() => { target.isAdult(99999) }, Error, 'not found!');
    })

    it('成人の場合', () => {
        // UserManagerのモックオブジェクトを設定します。
        // findメソッドにおいてユーザーIDが1ならuser1(成人)を返却するようにします。
        const mock: typemoq.IMock<UserManager> = typemoq.Mock.ofType<UserManager>();
        mock.setup(mock => mock.find(typemoq.It.isValue(1))).returns(() => user1);

        // テスト対象のクラスにモックオブジェクトをDIします。
        const target = new UserUtils(mock.object);

        // ユーザーID: 1 は成人と判定されることを確認
        chai.assert.isTrue(target.isAdult(1));
    })

    it('未成年の場合', () => {
        // UserManagerのモックオブジェクトを設定します。
        // findメソッドにおいてユーザーIDが2ならuser2(未成年)を返却するようにします。
        const mock: typemoq.IMock<UserManager> = typemoq.Mock.ofType<UserManager>();
        mock.setup(mock => mock.find(typemoq.It.isValue(2))).returns(() => user2);

        // テスト対象のクラスにモックオブジェクトをDIします。
        const target = new UserUtils(mock.object);

        // ユーザーID: 2 は成人ではない(未成年)と判定されることを確認
        chai.assert.isFalse(target.isAdult(2));
    })
});

もう一度コンパイルしてテストを実行してみます。

tsc
istanbul cover _mocha -- ./build/test/*.test.js
remap-istanbul -i ./coverage/coverage.json -o ./coverage/ts-report -t html

テストもすべて合格でカバレッジが100%になりました。カバレッジが上がるとテストのモチベーションもあがりますね。
ただし、先にも述べた通り、カバレッジはあくまで指標のひとつです。100%=品質が良い ということにはなりませんので注意してください。

テストが失敗するケースもみてみます。
ためしに、成人かどうかの判定条件に「20歳は成人とみなされないバグ」を仕込んでみます。

        // 変更前
        if (user.age >= 20) {
        ↓
        // 変更後
        if (user.age > 20) {

もう一度コンパイルしてテストを再実行すると、次のようにテストに失敗します。

tsc
istanbul cover _mocha -- ./build/test/*.test.js


  UserUtilsのテスト
    ✓ 存在しないユーザーの場合
    1) 成人の場合
    ✓ 未成年の場合


  2 passing (21ms)
  1 failing

  1) UserUtilsのテスト 成人の場合:

      AssertionError: expected false to be true
      + expected - actual

      -false
      +true

      at Context. (build/test/user-utils.test.js:25:21)



=============================================================================
Writing coverage object [/path/to/test-sample/coverage/coverage.json]
Writing coverage reports at [/path/to/test-sample/coverage]
=============================================================================

=============================== Coverage summary ===============================
Statements   : 95% ( 19/20 )
Branches     : 50% ( 1/2 )
Functions    : 100% ( 6/6 )
Lines        : 95% ( 19/20 )
================================================================================

今回、当然のように19歳と20歳の2パターンでテストしましたが、これは境界値テストと呼ばれるものです。仮に10歳と30歳の2パターンでテストを行なったとしたら、カバレッジは100%になりますが上記のバグは検出できません。テストパターンも正しく設計する必要があります。

まとめと予告

今回はTypeScriptのユニットテストについてご紹介させていただきました。

エンジニアとしては、品質を担保できてテストも自動化できるのであれば是非ともユニットテストを導入したいと思うでしょうが、コストや時間の問題もあるので導入にあたってはマネージャー層の判断も必要になります。テストおよびテストしやすいコードを書くスキルが低い場合には、手動テストに比べて時間がかかってしまい、最終的にコスト増に繋がってしまうこともあります。テストしやすいコードを書くのは簡単ではないですが、徐々に慣れていきましょう!


次回は別のエンジニアが「マイコン開発やってみた」をご紹介させていただく予定です。乞うご期待ください!

引用元・出典
TypeScript でユニットテストする。カバレッジもねっ!!

この記事を書いた人・プロフィール
オガティー
ニックネーム: オガティー

2014年3月から現職。当社のベンチャービジネスに心惹かれて入社。
それ以前はソフトハウス(2社)に在籍。
エンジニア歴はもう少しで20年。通信キャリアーのシステム開発、サーバーサイドプログラミングの経験が長い。
エンジニアとしてのベースはJava屋。Androidアプリ開発も。サーバーサイドはPHP, Node.jsが多い。
フロントエンドエンジニアとしては駆け出し。
趣味:ギター、ドラム、フットサル、お酒