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

イマドキのJSテスト - テスト環境をモダンフロントエンドツール群で整備する編 〜 JSおくのほそ道 #032

More than 3 years have passed since last update.

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

今回はビルドツール・モジュールローダ・altJS(JSコンパイラ)を組み合わせてさらにテストコードのおくのほそ道に入り込んでいきます。
今回Gulp + Webpack + Karma + Jasmine + ES6を使用しますが、
GruntだったりBrowserifyだったりMochaだったりCoffeescriptだったりに置き換えても基本的な考え方は近しい感じになるかと思います。
あとはここにSPAフレームワークとかを組み合わせればいよいよモダンなテストコードになっていきますね。

目次はこちら

今回解決すること・設計方針

  • Webpackとテストコードを組み合わせる。なんかバンドルしたりごにょごにょやってるどこにテストを挟むのか?を解決する。
  • ソースコードもテストコードもES6で書く。babelないしはaltJSのコンパイルごにょごにょやってるどこにテストを挟むのか?を解決する。
  • Gulpも使ってるんだけど誰に何の仕事をさせてどういう順番で動かせばテストになるのか・・・を解決する

今回の解決方法としてはアプリケーションビルドとは別枠でテストを走らせます
それぞれのテストモジュールごとにloader付きのwebpackビルドが走りテストを行わせます
GulpはWebpackのキックだけでビルド周りの情報は薄めさせる。もっと大きな前処理/後処理(ファイル構成いじったり)とかはGulpに任せる。

では、やっていきましょう。

まずは被テストコードを用意

まずはサクッと動くコードを用意しましょう。
JSコードはモジュールテストの回で扱ったネコサービスをES6に書き直します。
ここの内容が良く解らなければwebpack入門を併せてお読みください。
ネコサービスとテストの初期実装はこちらです。
ES6についてはまとめてませんが。。

プロジェクト構成は下記となります。

プロジェクト構成
├── node_modules
├── src
│   ├── Cat.js
│   ├── CatService.js
│   ├── Food.js
│   └── entry.js
├── package.json
└── webpack.conf.js

まずはwebpackとloaderのインストールから

コンソール
npm i -D webpack babel-loader 

続いてwebpack.conf.jsです。

webpack.conf.js
module.exports = {
  entry: { app: ["./src/entry.js"] },
  output: { filename: "build/[name].js" },
  devtool: "#source-map",
  module: { loaders: [ {test: /\.js$/, loader: "babel"} ] }
};

では被テストコードを用意します。

src/Food.js
export default class Food {

  constructor(name) {
    this.name = name;
    this.weight = 0;
    switch(name) {
      case 'chikuwa':
        this.weight = 1;
        break;
      case 'fish':
        this.weight = 2;
        break;
      case 'beef':
        this.weight = 3;
    }
  }
}
src/Cat.js
export default class Cat {
  constructor(name) {
    this.name = name ? name : 'Tama';
    this.weight = 5;
  }

  eat(food) {
    this.weight += food.weight;
  }
}
src/CatService.js
import Cat from './Cat'
import Food from './Food'

export default class catService {
  constructor() {
    this.cats = {}
  }

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

  checkout(name) {
    const cat = this.cats[name];
    delete this.cats[name];
    return cat;
  }

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

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

これでコードが揃いました。
webpackビルドすればちゃんと動くことが確認できると思います。

コンソール
 webpack --config webpack.conf.js    

さあテスト!

上で作成したコード群をテストしていきましょう。
ここでの内容が良く解らない方は
karmaを使ったUIテストを併せてお読みいただければ幸いでございます。

最終的なプロジェクト構成は下記となります。

プロジェクト構成
├── node_modules
├── src
│   ├── Cat.js
│   ├── CatService.js
│   ├── Food.js
│   └── entry.js
├── spec
│   ├── CatServiceSpec.js
│   ├── CatSpec.js
│   └── FoodSpec.js
├── package.json
├── karma.conf.js
└── webpack.conf.js

パッケージインストール

パッケージを追加インストールします。

コンソール
# karmaの回で取り上げたパッケージ
npm i -D karma karma-jasmine karma-chrome-launcher karma-firefox-launcher karma-spec-reporter 

# 今回初登場のパッケージ
npm i -D karma-webpack babel-plugin-rewire babel-runtime

今回、キモとなるパッケージは初登場のkarma-webpackです。これがkarmaに独自に行うwebpack設定を有効にし、karma startコマンドにwebpack連動させる役割を果たします。

karma.conf.js

では一番大事なkarma.conf.jsです。

karma.conf.js
module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine'],
    files: ['spec/*.js'],
    exclude: [],
    preprocessors: {'spec/*.js': ['webpack']},
    reporters: ['spec'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome', 'Firefox'],
    singleRun: false,
    webpack: {
      module: { loaders: [
        {test: /\.js$/, exclude: /node_modules/, loader: 'babel?plugins=rewire&optional=runtime'}
      ]}
    },
    plugins: [
      'karma-webpack', 'karma-jasmine',
      'karma-chrome-launcher', 'karma-firefox-launcher', 'karma-spec-reporter'
    ]
  });
};
  • 新しく出てきた要素のピックアップ
要素 やってること
files ['spec/*.js'] karma回ではsrcも呼び出して相互参照できるようにしていましたが今回はspecのみとします。
preprocessors {'spec/*.js': ['webpack']} テストディレクトリspecのそれぞれのファイルに対してwebpackをカマします。
webpack: {...} ルートに作ったwebpack.conf.jsとは別にテストのためのwebpack設定を記述します。entryやoutput要素は入れません。
webpack.module.loaders:[...] ローダー設定を記述しますが今回注目はbabel?plugins=rewire&optional=runtimeの部分です。これはbabel・webpack環境で使用できるrewireを使えるようにコンパイルに組み込んでいます。今回環境ではJasmine回で紹介したrewireは使えません。
plugins:[...] karmaで使用するプラグインを指定します。karma-webpackを使う場合はこの指定が必要になります。

テストコード

Food.jsとCat.jsは特筆すべき点ないと思います。

spec/Food.js
import Food from '../src/Food.js'

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

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

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

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

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

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

CatServiceはrewire周りを下記のように変更しています。これはwebpack・babelに対応できるようにするためbabel-plugin-rewireパッケージを使うようにしたためです。
- require(rewire)的なことをしない。
- モックを設定する__set__メソッドを__Rewire__メソッドに変更する。

spec/
import CatService from '../src/CatService.js'

describe('CatService', () => {
  let service = new CatService();

  // ネコモック
  class Cat {
    constructor(name) {
      this.name = name;
      this.weight = 5;
      this.eat = food => {}
    }
  }

  // 食い物モック
  class Food {
    constructor(weight) {
      this.weight = weight;
    }
  }

  // モック注入!
  CatService.__Rewire__('Cat', Cat);
  CatService.__Rewire__('Food', Food);

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

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

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

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

    it('指定したネコを返却すべし', () => {
      let 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()', () => {
    beforeEach(() => {
      service = new CatService();
    });

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

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

テスト実行

最後にpackage.jsonにテスト実行コードを登録しましょう

package.json
{
  
  "scripts": {
    "test": "karma start karma.conf.js"
  },
  
}

さあ、テスト実行です。

コンソール
npm run

※うまく動かない時

babel-plugin-rewireとbabel-loaderはそれぞれがbabel-coreを参照しておりコンフリクトを起こす場合があります。
ほそ道の環境では下記を叩いて一括で入れ直したらうまく動くようになりました。

コンソール
npm i -D babel-plugin-rewire babel-loader babel-core

  • 実行結果
    スクリーンショット 2015-05-31 23.41.55.png
    スクリーンショット 2015-05-31 23.42.15.png

コンソールの内容から**Spec.jsごとにwebpackがモジュールを取り込みビルドしていることがわかります。

Gulpから実行する

最後にGulpも絡めておきましょう。やる事はwebpackビルドとkarmaテストの実行です。
とは言っても、ここまででベースはできているので叩くところだけ任せれば良いです。
以前のビルドのクリアなどの前処理、ミニファイなどの後処理はこっちでやったほうがいいと思います。
ではさらっとインストールから。

コンソール
npm i -D gulp gulp-webpack-build

ルートにgulpfileを作りましょう。
今回はソリッドな構成にしましたし、こいつに固有の設定を色々持たせないほうがシンプルでいられると思います。

gulpfile.js
var gulp = require('gulp'),
    webpack = require('gulp-webpack-build'),
    karma = require('karma').server,
    WEBPACK_CONFIG = __dirname + '/webpack.conf.js',
    KARMA_CONFIG = __dirname + '/karma.conf.js';

gulp.task('webpack-build', function () {
  return gulp.src(WEBPACK_CONFIG)
      .pipe(webpack.run())
      .pipe(gulp.dest(''));
});

gulp.task('karma-test', function () {
  karma.start({configFile: KARMA_CONFIG});
});

実行コマンドは以下です。

コンソール
# ビルド実行
gulp webpack-build

# テスト実行
gulp karma-test

まとめ

今回新しく登場したパッケージのまとめです。

パッケージ名 概要
karma-webpack karmaの実行に併せてテストコードに必要なモジュールのバンドル化とコードコンパイルをwebpackを使って実行できる
babel-plugin-rewire babelコンパイル環境下でrewireを使えるようにする
babel-runtime ES6構文のPolyfillのために入れました。今回の場合はbabel-plugin-rewireでObject.assignメソッドを使えるようにするため
gulp-webpack-build gulp経由からwebpackを実行します。

さて、今回でだいぶ実践的な環境に近づいてきました。テストコードの世界、奥深いですねぇ。
またSPAフレームワークや遷移が絡むテストについても随時ピックアップしていきたいと思います。

今回は以上です。

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