Help us understand the problem. What is going on with this article?

イマドキのJSテスト - jasmine + rewireで他モジュールに依存したモジュールテストを単体でやりきる編 〜 JSおくのほそ道 #030

More than 3 years have passed since last update.

こんにちは、ほそ道です。

今回から連作でテストコーディングの進め方・考え方を取り上げます。
フロントエンドテストはブラウザ・DOMが絡んできたりしてとかく複雑で厄介なものです。
あとGulpとかのビルドツールと連携しようとしたり、WebpackとかでテストコードもES6で描きたいなーとかのトライもいっぺんに含めようとすると軽くカオスな状態になります。
最近はReactのTestUtilだったりFluxテストでJestをお勧めしてたりとモダンな奴らもチラホラ。

というわけで知ることテンコ盛りなので今回は第一歩としてテストフレームワークJasmineを使ってブラウザレスでしっかりと単体テストを行い基盤を固めたいと思います。

目次はこちら

前提:モジュール間の依存関係をどう受け止めるか

今回の主題は「モジュール間の依存関係をどう受け止めるか」に設定しました。
テストしやすいコードを書くには、TDDで進めるためには、とかもあるんですが
今回は依存関係に影響を受けないための道具の使い方をチェックしていきます。

なんで依存したくないのか

例えば、あるオブジェクトOのメソッドmの引数にaを与えるとrが帰ってくるべし。みたいなテストはシンプルだし想像つきやすいと思います。
ところがあるオブジェクトOが別モジュールのオブジェクトPのメソッドを呼び出していたり、オブジェクトPを生成していたりすると
話は一気にややこしくなってきます。

「いや、オブジェクトPを実装・参照すればいいじゃない」。それも一理あります。
ではもしも、オブジェクトPは別担当者のJさんが作っていたら、Jさんブランチがマージされるまで実装コードは直接もらわないとわかりません。
Jさんは実装コードの中のロジックを書き換えるかもしれません。またコピペしなおすのかぁ。。とか。
サーバーサイドJSでテスト実行するごとにDBのレコードが増えたら嫌だなぁ、とかもありますし。

うーん、オブジェクトPに依存したオブジェクトOのテストは単体で実行できる方がいいなあ。
モジュールOのテストモジュールはあくまでも対象モジュールであるOのテストができればいいなぁ。

というわけでそんな悩みを解決していきます!

Jasmineを実行してみる

テスト実行環境を整える

それではテストフレームワークであるJasmineを実行するところからやってみましょう。
まずは空のプロジェクトディレクトリに入ってコンソールで必要なライブラリを取り込みます。

コンソール
npm init
npm i -D jasmine jasmine-spec-reporter rewire

次に実行コマンドを整えます。次のコマンドを打ちます。

コンソール
jasmine init

specディレクトリができたと思います。
併せて生成されたspec/support/jasmine.jsonを見ると下記のようになっています。

spec/support/jasmine.json
{
  "spec_dir": "spec",
  "spec_files": [
    "**/*[sS]pec.js"
  ],
  "helpers": [
    "helpers/**/*.js"
  ]
}

specディレクトリにある*[sS]pec.jsファイルが実行されそうですね。
早速作ってみることにします。足し算

spec/testSpec.js
describe('テストことはじめ', function() {
  function add(x, y) { return x + y; }
  it('足し算テスト', function() {
    expect(add(1,2)).toBe(3);
  });
});

Jasmineのメソッド群を簡単に解説いれますと
- describe: 第一引数がテストすべき内容の大枠の説明文字列、第二引数がテストコードの入れ物になります。describeは入れ子にできます。
- it: 第一引数がテストすべき内容の詳細説明。第二引数がテストコードになります。
- expect: 期待値を表すExpectationオブジェクトを生成します。テストするため引数の内容を格納します。
- Expectation.toBe: ===チェックを行います。他にもtoXXで様々なチェックができます。

それでは下記のコマンドを叩いてみましょう

コンソール
jasmine

なんか味気ない画面でテスト成功が伝えられたかと思います。

  • 実行結果
    スクリーンショット 2015-05-25 2.27.40.png

うーん。。
ほそ道的にはなんか物足りない!テスト通したカタルシスが足りないっす!!!
ということで次は結果レポートの表示を変えてみたいと思います。

結果レポートの表示内容を変更

プロジェクトルートにjasmine-runner.jsというファイルを作ります。内容は公式から引っ張ってきた下記となります。

jasmine-runner.js
var Jasmine = require('jasmine');
var SpecReporter = require('jasmine-spec-reporter');
var noop = function() {};

var jrunner = new Jasmine();
jrunner.configureDefaultReporter({print: noop});
jasmine.getEnv().addReporter(new SpecReporter());
jrunner.loadConfigFile();
jrunner.execute();

ここは本筋ではないので解説省きます。というかほそ道も実装やオプションを追いかけてませんので。。
それでは実行してみましょう。

コンソール
node jasmine-runner.js
  • 実行結果
    スクリーンショット 2015-05-25 2.27.52.png

うん、こっちの方が好みです。とりあえずコレでいっておきましょう。

npm test

最後にひとネタ。
より楽チン・よりスタンダードにテストコマンドを実行できるようにしておきましょう。
package.jsonの一行を書き換えます。
下記の部分を

package.json
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },

以下のように変更します。

package.json
  "scripts": {
    "test": "node jasmine-runner.js"
  },

これで以降は

コンソール
npm test

でテストが実行できるようになります。

実践的なテストケース

非依存なモジュールテスト

それでは簡単なケースからやっていきましょう。
さっき作ったspec/testSpec.jsは削除して構いません。
src/Food.jsspec/FoodSpec.jsの2つのファイルを作ります。
プロジェクトの状態は下記のようになっていると思います。

プロジェクトツリー
.
|-- jasmine-runner.js
|-- node_modules
|-- package.json
|-- spec
|   |-- FoodSpec.js
|   `-- support
|       `-- jasmine.json
`-- src
    `-- Food.js

それではFood.jsからみていきます。
これはweightというプロパティを持ったコンストラクタで、食べ物の名前によってweightが異なります。
他のどのモジュールも参照していません。

src/Food.js
var Food = function(name) {
  this.weight = 0;
  switch(name) {
    case 'chikuwa':
      this.weight = 1;
      break;
    case 'fish':
      this.weight = 2;
      break;
    case 'beef':
      this.weight = 3;
  }
};
module.exports = Food;

それではテストコードを書きましょう

spec/FoodSpec.js
var Food = require('../src/Food');

describe('Food constructor', function() {
  it('ちくわのweightは1であるべし', function() {
    expect(new Food('chikuwa').weight).toBe(1);
  });
  it('魚のweightは2であるべし', function() {
    expect(new Food('fish').weight).toBe(2);
  });
  it('ビーフのweightは3であるべし', function() {
    expect(new Food('beef').weight).toBe(3);
  });
  it('未設定なFoodのweightは0であるべし', function() {
    expect(new Food('Daikon').weight).toBe(0);
  });
});

引数を変えながら期待値を確かめていきます。
それでは実行してみましょう。

  • 実行結果
    スクリーンショット 2015-05-25 2.29.22.png

いい感じですね。
一応コカしてもおきましょう。
ビーフをポークに変えてみます。3つ目のテストのbeefをporkに変えてみましょう。

  • 実行結果
    スクリーンショット 2015-05-25 2.29.58.png

エラーが出てくれました。
ここまでは簡単だと思います。

軽依存なモジュールテスト

さて、いよいよ本題です。
依存関係が現れた時、どのようにモジュール単体でテストしていくのかをみていきましょう。
今度はさっきの食べ物を食べる主体者としてネコを登場させてみましょう。
src/Cat.jsspec/CatSpec.jsを作りましょう。
プロジェクトの状態は下記のようになります。

プロジェクトツリー
.
├── jasmine-runner.js
├── node_modules
├── package.json
├── spec
│   ├── CatSpec.js
│   ├── FoodSpec.js
│   └── support
│       └── jasmine.json
└── src
    ├── Cat.js
    └── Food.js

それではコードをみていきます。

src/Cat.js
var Cat = function (name) {
  this.name = name ? name : 'Tama';
  this.weight = 5;
};
Cat.prototype.eat = function (food) {
  this.weight += food.weight;
};
module.exports = Cat;

デフォルトの名前はTamaだよ、とか入ってますが注目すべきところはeatメソッドです。
引数にFoodをとって、食った分だけネコの体重が増加するようになっています。
さて、どのように単体でテストできるよう解決すべきか。
ちと考えてみましょう。
Hint:ここでCatを軽依存なモジュールと定義しているのはFoodモジュールをrequireしていないことに起因しています。
本当のFoodは必要か…?

それではspec/CatSpec.jsをみていきましょう

spec/CatSpec.js
var Cat = require('../src/Cat');

describe('Cat', function () {
  describe('.constructor()', function() {

    it('デフォルト名前はTamaになるべし', function () {
      expect(new Cat().name).toBe('Tama');
    });

    it('名前を指定すればその名が設定されるべし', function () {
      expect(new Cat('Mike').name).toBe('Mike');
    });

    it('最初の体重は5となるべし', function () {
      expect(new Cat().weight).toBe(5);
    });
  });

  describe('.eat()', function() {
    it('食べ物のweighだけネコの体重が増加すべし', function () {
      var c1 = new Cat();
      var c2 = new Cat();
      c1.eat({weight: 8});
      expect(c1.weight - c2.weight).toBe(8);
    });
  });
});

一番下のeatメソッドのテストがポイントです。
要はweihtプロパティに数値を持ったオブジェクトを引数として渡してあげればいいわけですね。
なんだ、大したことないねぇ。なんて思いながら一応テストを実行しておきましょう。あ、ポークの件は元に戻しておきましょう

  • 実行結果
    スクリーンショット 2015-05-25 2.31.27.png

重依存なモジュールテスト

それでは今回の本丸です。
モジュール内で他モジュールをrequireして、メソッドコールしていた場合です。
ここでネコのことならなんでもお任せ、CatServiceモジュールを登場させます。
このモジュールは外部モジュールからのコールでネコに関する依頼をいろいろ承ります。
src/CatService.jsspec/CatServiceSpec.jsを作りましょう。
プロジェクトの状態は下記のようになります。

プロジェクトツリー
.
├── jasmine-runner.js
├── node_modules
├── package.json
├── spec
│   ├── CatServiceSpec.js
│   ├── CatSpec.js
│   ├── FoodSpec.js
│   └── support
│       └── jasmine.json
└── src
    ├── CatService.js
    ├── Cat.js
    └── Food.js

それではsrc/CatService.jsをみていきましょう。

src/CatService.js
var Cat = require('./Cat');
var Food = require('./Food');

function CatService() {
  this.cats = {};
}

// お泊まりサービスをやっています
CatService.prototype.checkin = function (cat) {
  this.cats[cat.name] = cat;
};
CatService.prototype.checkout = function (name) {
  var cat = this.cats[name];
  delete this.cats[name];
  return cat;
};

// 依頼された猫に指定の餌をあげるサービスです
CatService.prototype.feed = function (name, foodName) {
  this.cats[name].eat(new Food(foodName));
};

// 猫を産んでお客様に差し上げるサービスです
CatService.prototype.yield = function (name) {
  return new Cat(name);
};

module.exports = CatService;

はい、バリバリ依存(require)しています。
特にfeedメソッド、yieldメソッドに関してはnewまでしちゃってるのでCatSpec.jsの時のような誤魔化しは効きません。
いやさ、その難題、誤魔化しきってみようじゃないっすか!

あと補足でメモ入れておきます。
対象モジュール以外のテストはしなくて良い。例えばCatのデフォルト名がTamaだとかeatすると体重が増えるとかはここでテストせずにCatSpecに任せれば良い。

rewireでモック注入

はい、ここで最初にnpmインストールしておいたrewireが登場します。
rewireはなにが嬉しいんでしょう。
モジュールが参照しているオブジェクトにモックを注入できる。
すなわちCatServiceが参照するCatやFoodを必要なインターフェースだけ持った簡易なCatモック、Foodモックに差し替えることができます。
 うぉぉ
使い方はいたって簡単。レシピを下記に書き添えます。

  1. rewireをrequireします
  2. テストモジュールをrequireではなくrewire(Module)で取り込みます
  3. Module.__set__('差し替えたいモジュール名', 差し替えたいモジュール)を実行します

以上です。簡単そうですね!

jasmine + rewireであくまで単体モジュールテスト

それではspec/CatServiceSpec.jsの実装を見てみましょう。

spec/CatServiceSpec.js
var rewire = require('rewire'),
    CatService = rewire('../src/CatService');

describe('CatService', function () {
  var service = new CatService(),
      // ネコモック
      Cat = function (name) {
        this.name = name;
        this.weight = 5;
        this.eat = function(food){};
      },
      // 食い物モック
      Food = function(weight) {
        this.weight = weight;
      };
  // モック注入!
  CatService.__set__('Cat', Cat);
  CatService.__set__('Food', Food);

  describe('.checkin()', function () {
    beforeEach(function() {
      // 検査ごとにserviceを初期化
      service = new CatService();
    });

    it('別名のネコを2匹泊めたらcatsには2匹いるべし', function () {
      service.checkin(new Cat('Tama'));
      service.checkin(new Cat('Mike'));
      expect(Object.keys(service.cats).length).toBe(2);
    });

    it('同名のネコのお泊まりは残念ながら受け付けぬべし', function () {
      service.checkin(new Cat('Mike'));
      service.checkin(new Cat('Mike'));
      expect(Object.keys(service.cats).length).toBe(1);
    });
  });

  describe('.checkout()', function() {
    // あえてbeforeEachせずに直上の状態を引き継いでみる

    it('指定したネコを返却すべし', function() {
      var returnedCat;
      service.checkin(new Cat('Tama'));
      returnedCat = service.checkout('Tama');
      // Mikeだけが残ってるはず
      expect(Object.keys(service.cats).length).toBe(1);
      expect(Object.keys(service.cats)[0]).toBe('Mike');
      // Tamaが返却されるはず
      expect(returnedCat.name).toBe('Tama');
    })
  });

  describe('.feed()', function () {
    beforeEach(function() {
      service = new CatService();
    });

    it('Cat.eatメソッドが呼ばれるべし', function () {
      var mike = new Cat('Mike');
      spyOn(mike, 'eat');
      service.checkin(mike);
      service.feed('Mike', new Food(3));
      expect(mike.eat).toHaveBeenCalled();
    });
  });

  describe('.yield()', function () {
    it('新しいネコを生成すべし', function () {
      expect(service.yield('Mike') instanceof Cat).toBeTruthy();
    });
  });
});

上の方でCatモックとFoodモックを生成しています。
そして__set__メソッドでCatServiceにCatモックとFoodモックを注入しています。
あとはさも、自分が書いたインターフェースを元にCatとFoodがあるかのようにCatServiceをテストするコードを書けば良いのです。
一応、新しく登場したjasmineのメソッドも取り上げておきます。

  • beforeEach: describe内の各it実行前に共通の初期化処理を行います。呼ばなかった場合、オブジェクトの状態は次に持ち越されます。
  • spyOn: 第一引数にモジュール、第二引数にそのモジュールのメソッド名で指定します。指定したメソッドを追跡します。
  • Expectation.toHaveBeenCalled: spyOnしたメソッドが呼ばれたかどうかをチェックします。
  • Expectation.toBeTruthy: trueであるかどうかをチェックします。

さて、テスト実行してみたいところですがこのままだとCatやFoodが普通に使われちゃってるかもわかりません。
CatとFoodモジュールのコードは大胆に全てコメントアウトしちゃいましょう!
さて、実行。

  • 実行結果
    スクリーンショット 2015-05-25 2.26.00.png

はい、CatとFoodのテストはガッツリ失敗してますがCatServiceのテストはオールグリーンです!
これはCatServiceの単体モジュールテストがうまくいってる証です。


Jasmineのメソッドをおさらい

最後に今回登場したJasmineのメソッドを見やすいように改めて全部まとめておきます。

メソッド 内容
describe 第一引数がテストすべき内容の大枠の説明文字列、第二引数がテストコードの入れ物になります。describeは入れ子にできます。
it 第一引数がテストすべき内容の詳細説明。第二引数がテストコードになります。
beforeEach describe内の各it実行前に共通の初期化処理を行います。呼ばなかった場合、オブジェクトの状態は次に持ち越されます。
spyOn 第一引数にモジュール、第二引数にそのモジュールのメソッド名で指定します。指定したメソッドを追跡します。
expect 期待値を表すExpectationオブジェクトを生成します。テストするため引数の内容を格納します。
Expectation.toBe ===チェックを行います。他にもtoXXで様々なチェックができます。
Expectation.toBeTruthy trueであるかどうかをチェックします。
Expectation.toHaveBeenCalled spyOnしたメソッドが呼ばれたかどうかをチェックします。

今回は以上です。
次回はブラウザやDOMでどうやってテストするんだーというあたりをまとめていきたいなと思います。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away