はじめに

みなさまはじめまして、SmartNews の井口(いのくち; @kainoque)と申します。主にサーバサイドおよび各種管理コンソールの開発を行っています。弊社における開発案件はネイティブアプリおよびそのバックエンドである各種 API サーバに関するものが大半です。一方で、社内・社外向けの各種管理画面については、Web フロントエンドに関する開発も行っています。今回は、フロントエンド開発の話題として、掲題のとおり、webpackプロジェクトのテストにまつわるあれこれをお話します。

TL;DR

  • SmartNewsでは Web フロントエンド開発においても品質向上に取り組んでいます
    • Karma を用い webpackプロジェクトをテストしています
    • コードのカバレッジもばっちり取得しています
    • Coverallsを使ってカバレッジを共有、管理しています

背景

筆者は、SmartNews Adminと呼ばれる社内向け管理コンソールの開発を行っております。SmartNews Admin は、記事配信状況の確認が可能なコンソールです。 加えて以下のような用途で、エンジニアのみならず、多くの社員に利用されています。

  • 記事収集、解析、編成アルゴリズムの各結果・状況の確認
  • 新規チャンネルの作成
  • A/B テストの設定
  • ユーザーからのフィードバックの確認

このように、弊社が、SmartNews というサービスを安定して届けるために、欠かすことのできないツールです。そのため、

  • サーバサイドの障害でダウン
  • フロントエンドの障害で UI が操作不可

といった問題は致命的であり、品質管理が非常に重要となっております。

本投稿の少し前に、弊社有志エンジニアによる、「テストオフサイト」(オフィスを離れて 1 日品質について話し合うイベント)が開催されました。各サービスレイヤにおいて、どのように品質を担保・改善するべきか、活発な議論がかわされました。

それを受け、筆者が開発を担当する SmartNews Admin においても品質の改善を進めております。

webpackの導入

SmartNews Admin のフロントエンドの開発に、webpackを導入しました。

レガシーコードの中には、DOM操作や バックエンドの API呼び出しなどがまとめ HTMLに直接記述されているものも多い状況でしたが、以下を実現するため部分的ではありますが webpack を導入しはじめています。

  • 開発の効率化
    • 依存性の解決
    • ロジックの統一化、再利用化
  • 品質の向上
    • モジュール化によるテスト可能性の向上

テストオフサイトの実施と前後して新規機能を追加することもあり、webpack 導入に合わせてフロントエンドのテスト環境についても大きく見直しを図りました。その中で得られた webpack のテストのノウハウを共有できればと思います。

webpack プロジェクトをテストする

前提

使用ツール

SmartNews Admin で使用している各種ツールは以下のとおりです。

種類 ツール
タスクランナー gulp
テストランナー Karma
テストフレームワーク Jasmine
テスト用ブラウザ PhantomJS

サンプルプロジェクト

SmartNews Admin は完全に社内向けのプロダクトのため、残念ながら直接コードをお見せできないので、今回はサンプルプロジェクトにて以降記述していきます。サンプルプロジェクトのリポジトリは以下となります。

https://github.com/kainoku/webpack-test-sample

今回例に挙げるテスト対象コード(プロダクションコード)、テストコードは以下のような構成となっております。

src
├── main
│   └── js
│       ├── components
│       │   └── util
│       │       ├── UtilA.js
│       │       └── UtilB.js
│       └── index.js
│
└── test
    └── js
        └── components
            └── util
                └── UtilASpec.coffee

index.js はエントリポイントとしてブラウザに読み込まれるファイルで、src/main/js/components ディレクトリ以下に各プロダクションコードが格納されています。今回は以下の UtilA に対してテストを書くことにします。

function UtilA () {
  this.getFoo = function() {
    return "foo";
  };
}

module.exports = UtilA;

各テストは src/test/js/components 以下に CoffeeScript で書かれます。

describe 'UtilA', ->
  describe 'getFoo', ->
    it 'returns foo', ->
      expect(new UtilA().getFoo()).toBe 'foo'

gulp の設定

gulpfile.coffeeにテスト用のタスク test を定義します。

path = require 'path'
KarmaServer = require('karma').Server
gulp.task 'test', (cb) ->
  server = new KarmaServer {
    configFile: path.join __dirname, 'karma.conf.coffee'
    singleRun: true
  }, cb
  do server.start

テストの実行は以下のとおりです。

$ node_modules/gulp/bin/gulp.js test

テストコードのwebpack化

テストコードをwebpack化することで、プロダクションコードの各モジュールを直接利用することができます。

describe ‘UtilA’, ->
  UtilA = require '../../../../main/js/components/util/UtilA' # 直接 require する
  describe 'getFoo', ->
    it 'returns foo', ->
      expect(new UtilA().getFoo()).toBe 'foo'

ポイント: Karma では各テストコードのみを読みこむようにします。各テストコードに対して preprocessorとしてwebpackを指定します。plugins プロパティに karma-webpack を追加します。webpack プロパティに webpack の設定を定義します。webpack プロパティには、プロダクションコード用の webpack の設定をそのまま使用します。addCoffeeLoader は、webpack の設定に CoffeeScript によるテストコードをロードする設定を追加する独自関数です。以下の karma.conf.coffee 全体はこちらの サンプル を御覧ください。

module.exports = (config) ->
  config.set
    # snip...

    # テストコードのみを読み込み
    files: [
      'src/test/js/**/*Spec.coffee'
    ]
    # preprosessor として webpack を設定
    preprocessors:
      'src/test/js/**/*Spec.coffee': ['webpack']

    # 'karma-webpack' を追加
    plugins: [
      'karma-phantomjs-launcher'
      'karma-jasmine'
      'karma-webpack'
    ]

    # webpack の設定を追加
    webpack: addCoffeeLoader require('./webpack.config')

    # snip...

webpack の CoffeeScript 用 loader である coffee-loader を npm install で追加しておきます。

これにより、無事テストが通ります!

PhantomJS 1.9.8 (Mac OS X 0.0.0): Executed 1 of 1 SUCCESS (0.001 secs / 0.001 secs)

webpack プロジェクトのコードカバレッジを取得する

webpack プロジェクトのテストを行うだけであれば、上記までの手順で OK です。弊社では、プロダクションコードに対するカバレッジを品質の一つの基準としており、フロントエンドのプロダクションコードも例外ではありません。

Karma でのカバレッジ取得

Karma では、カバレッジ取得用のプラグインとして karma-coverage が存在します。karma-coverage のサンプル を見るとわかるように、preprosessors と reporters に coverage が指定されています。この指定により、Karma はカバレッジ取得対象のソースを読み込み時にコードのメタ情報(行数、分岐数など)の解析を行い、テスト実行終了時にカバレッジをレポートします。しかし、webpack プロジェクトの場合、プロダクションコードを直接 Karmaに読み込ませることはできません。次章では、テストコードからテスト対象がrequireされた際にメタ情報を解析可能とする設定をご紹介します。

カバレッジ取得対象のソースコードのメタ情報を動的に解析する

karma-coverageは、ソースコードのメタ情報を解析するIstanbulを内部的に使用しています。webpackプロジェクトにおいて、 requireの際にIstanbulによるメタ情報解析を実現するために、webpackのpreloaderのひとつであるistanbul-instrumenter-loader を活用します。

webpack preloaderを設定

以下の関数を karma.conf.coffee内に定義します。 webpack の設定オブジェクトの preLoaders に istanbul-instrumenter を設定する関数です。

addIstanbulPreLoader = (input) ->
  path = require 'path'
  extend = (require 'util')._extend
  output = extend {}, input
  output.module ||= {}
  output.module.preLoaders ||= []
  output.module.preLoaders.every((l) -> l.loader isnt 'istanbul-instrumenter') and output.module.preLoaders.push
    loader: 'istanbul-instrumenter'
    test: /\.js$/
    include: path.resolve('src/main/js/components') # テスト対象のみ解析対象とする

  return output

Karma の設定

Karma 内ではこの関数を適用したwebpackの設定オブジェクトを用います。以下の karma.conf.coffee 全体はこちらのサンプルを御覧ください。

module.exports = (config) ->
    # snip...

    # 'karma-coverage' を追加
    plugins: [
      'karma-phantomjs-launcher'
      'karma-jasmine'
      'karma-webpack'
      'karma-coverage'
    ]
    # 'coverage' を追加
    reporters: ['progress', 'coverage']

    # preloader で Istanbul の解析を有効にする webpack 設定
    webpack: addCoffeeLoader addIstanbulPreLoader require('./webpack.config')

    # カバレッジ情報出力の設定
    coverageReporter:
      type: 'lcov'
      dir: 'coverage'
      subdir: '.'

    # snip...

上記設定を行うことにより、テスト終了後に coverage/lcov-report 内にカバレッジレポートが出力されます。

coverage
├── lcov-report
│   ├── base.css
│   ├── index.html
│   ├── prettify.css
│   ├── prettify.js
│   ├── sort-arrow-sprite.png
│   ├── sorter.js
│   └── util
│       ├── UtilA.js.html
│       └── index.html
└── lcov.info

lcov-report/index.html を開くと綺麗なカバレッジレポートが確認できます。

ソースコード一覧

カバレッジ詳細

プロダクションコード全体をカバレッジ取得対象とする

上記のカバレッジレポートをご覧になってお気づきのかたもいらっしゃるかと思われますが、レポートには実際にテストされた UtilA のカバレッジのみが表示されています。テストが書かれていないUtilBについてはソース一覧にすら表示されていません。

UtilBについてはカバレッジ 0% と表示されて欲しいので、その設定を行います。上記方法では、Istanbul によりメタ情報が解析されるためには require の呼び出しが必要となります。そのため、テスト開始時にカバレッジ取得対象全てのソースコードを webpack に読み込み、それらに対して preloader にて Istanbul を適用させます。

参考:

テストコードのエントリポイントを作成

specContext = require.context '.', true, /Spec\.coffee$/
srcContext = require.context '../../main/js/components', true, /\.js$/

[specContext, srcContext].forEach (ctx) ->
  ctx.keys().forEach ctx

全てのプロダクションコード、テストコードが require.context によりrequireされます。プロダクションコードには Istanbul の preloader が適用されます。

Karma のテスト対象をエントリポイントを作成

上記のエントリポイントをテスト対象とするようKarmaの設定を変更します。以下の karma.conf.coffee 全体はこちらの サンプル を御覧ください。

module.exports = (config) ->
  config.set
    # snip...

    # エントリポイントのみを読み込み
    files: [
      'src/test/js/entry.coffee'
    ]
    # エントリポイントに対して webpack を適用
    preprocessors:
      'src/test/js/entry.coffee': ['webpack']

    # snip...

上記設定を行うことにより、UtilB に対するカバレッジも表示されます。

Coverallsを利用する

上述のようにカバレッジレポートはテスト実行マシンのローカルに生成されます。弊社では、チーム全体でカバレッジの状況を共有するため、Coveralls という SaaS を利用しています。Coveralls にリポジトリを登録し、API 経由でカバレッジ情報を送信することで以下が可能となります。

  • カバレッジの管理
    • カバレッジの状況確認
    • コミットごとのカバレッジ変化の確認
    • カバレッジ低下時のアラート送信

サンプルプロジェクトの Coveralls 上のカバレッジは以下からご確認いただけます。 https://coveralls.io/github/kainoku/webpack-test-sample

gulp の Coveralls 連携タスク

gulp には gulp-coveralls という、Coveralls へのカバレッジ情報送信用プラグインが存在します。以下のとおりgulpのタスクとして組み込むことで、簡単にCoverallsと連携が可能です。

gulp.task 'coveralls', ->
  gulpCoveralls = require 'gulp-coveralls'
  gulp.src 'coverage/lcov.info'
    .pipe do gulpCoveralls
COVERALLS_REPO_TOKEN={token} gulp coveralls

これによりカバレッジ情報が Coverallsへ送信されます。

  • 送信には、リポジトリごとに払い出されるCoverallsのtokenが必要となります
  • token は COVERALLSi_REPO_TOKEN環境変数から読み出されます

coverage/lcov.info はKarmaが生成する LCOV 形式のカバレッジレポートです。

Java プロジェクトのコードカバレッジと合わせて管理する

Java コードのカバレッジ

SmartNews Admin を始め、弊社の各管理画面のリポジトリには、開発やデプロイの効率のため、以下の構成のように、フロントエンド・サーバサイド用コードが同梱されています。

src
├── main
│   ├── java
│   │   └── com
│   │       └── kainoku
│   │           ├── Main.java
│   │           └── util
│   │               └── JavaUtilA.java
│   ├── js
│   │   ├── components
│   │   │   └── util
│   │   │       ├── UtilA.js
│   │   │       └── UtilB.js
│   │   └── index.js
└── test
    ├── java
    │   └── com
    │       └── kainoku
    │           └── util
    │               └── JavaUtilATest.java
    └── js
        ├── components
        │   └── util
        │       └── UtilASpec.coffee
        └── entry.coffee

サーバサイド用プロダクション・テストコードの多くは Java で書かれています。こちらのカバレッジも、 Coveralls で管理しています。

Mavenの設定

Java のテストコードは JUnit で記述されています。Maven プロジェクトでは、カバレッジを JaCoCo で収集し、coveralls-maven-plugin により Coveralls へ送信しています。

    <plugins>
      <plugin>
        <groupId>org.eluder.coveralls</groupId>
        <artifactId>coveralls-maven-plugin</artifactId>
        <version>4.0.0</version>
      </plugin>
      <plugin>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
        <version>0.7.5.201505241946</version>
        <executions>
          <execution>
            <id>prepare-agent</id>
            <goals>
              <goal>prepare-agent</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>

以下コマンドで カバレッジの収集、送信が可能です。

$ COVERALLS_REPO_TOKEN={token} mvn clean test jacoco:report coveralls:report

カバレッジの共存

上記コマンドにより送信されたカバレッジは、Java のソースコードに基づくものです。一方で、gulp coverage により送信されるカバレッジは JavaScript のものです。

そのため、両者のカバレッジを同時に Coveralls 上で確認しようと、以下の用に連続してカバレッジを送信しても上手くいきません。どちらもカバレッジの分母が限定されるため、下図のように最後に送信されたもの以外を Coveralls 上で確認することができません。

$ export COVERALLS_REPO_TOKEN={token}
$ mvn clean test jacoco:report coveralls:report
$ gulp coverage # こちらだけが適用される

最後に、JaCoCoとKarmaが生成するカバレッジ情報をマージし、いずれのカバレッジも同時にCoveralls上で確認可能にする方法をご紹介します。

  1. Java, JavaScript のカバレッジ情報のいずれも Coveralls 形式の JSON に変換する
  2. 両者をマージする
  3. マージ結果を Coveralls へ送信する

Java のカバレッジ情報を JSON で取得する

coveralls-maven-plugin の以下オプションを利用して、カバレッジ情報を Coveralls 形式の JSON で取得します。

  • dryRun オプション
    • このオプションが指定されている場合は Coveralls へ送信するカバレッジ情報が JSON 形式でファイルへ出力されます
    • Coveralls の API へは送信されません
  • coverallsFile オプション
    • JSON ファイルのファイル名を指定可能です

実行は以下のとおりです。coverage/java.json が出力されます。

$ mvn test jacoco:report coveralls:report -DdryRun -DcoverallsFile=coverage/java.json

JS のカバレッジ情報をJSONで取得する

JS のカバレッジ情報は上述の通り LCOV形式でファイルへ出力されます。これを Java のカバレッジ情報と同等の Coveralls形式へ変換できれば、マージを行い API を叩けば OK です。node 環境用の Coveralls 用パッケージであるcoverallsを利用して変換を行います。

coveralls = require 'coveralls'
lcov = # LCOV 形式のテキスト
coveralls.convertLcovToCoveralls lcov, {}, (err, json) ->
  # json に Coveralls 形式の JSON が格納されている

カバレッジ情報のJSONをマージしてCoverallsへ送信するタスク

Coveralls 形式の JSONは以下のとおりです。source_filesにはファイル名ごとのカバレッジ情報が配列で格納されます。

{
  "source_files": []
}

前述で取得した Java および JavaScript の Coveralls 形式 JSON をマージし、Coveralls の API へ送信します。

  • 上記一連の処理を gulp タスクとしてまとめてみました
  • 変換とマージの箇所を以下のようにインラインプラグインとして実装してあります
  • タスク全体は こちら を御覧ください
# LCOV 形式のテキストを Coveralls 形式の JSON に変換
mapper = (lcov, fileName) ->
  return through.obj (file, enc, cb) ->
    if file.isNull()
      @push file
      return do cb

    done = (str) =>
      newFile = new gutil.File
        cwd: file.cwd,
        base: file.base,
        path: file.base + fileName

      newFile.contents = new Buffer str
      @push newFile
      return do cb

    output = file.contents.toString enc
    if lcov
      coveralls.convertLcovToCoveralls output, {}, (err, json) ->
        if err
          error.call @, cb, err
        done JSON.stringify json
    else
      done output

# Coveralls 形式の JSON をマージ
merger = () ->
  json =
    source_files: []

  transformer = (file, enc, cb) ->
    data = JSON.parse(file.contents)
    data.source_files.forEach (source) ->
      json.source_files.push source

    @push file
    do cb

  flush = (cb) ->
    coveralls.getBaseOptions (err, options) =>
      if err
        error.call @, cb, err
      options.filepath = '.'
      util._extend(json, options)
      coveralls.sendToCoveralls json, (err, response, body) =>
        if err
          error.call @, cb, err
        console.log body
        do cb

    return through.obj transformer, flush

以下コマンドを実行すると、両者のカバレッジがマージされ、一度に Coveralls で確認可能となります。

$ COVERALLS_REPO_TOKEN={token} node_modules/gulp/bin/gulp.js coveralls:merge

CircleCI と連携した自動テスト・カバレッジ収集

弊社の各プロジェクトでは、CircleCIと連携した自動テストを実施しています。ソースコードをリポジトリにpushするタイミングでテストが自動実行され、カバレッジ Coverallsへ送信されます。

コミットごとのカバレッジ変化の確認や低下時のアラート送信が、リポジトリにpushするだけで実現でき、非常に重宝しております。

CircleCI の設定

サンプルプロジェクトの CircleCI 設定は以下のようになっています。 ※ CircleCI の設定で Coveralls の token を COVERALLS_REPO_TOKEN として環境変数化しておきます。

test:
  override:
    - "mvn clean test jacoco:report coveralls:report -DdryRun -DcoverallsFile=coverage/java.json"
    - node_modules/gulp/bin/gulp.js test
    - node_modules/gulp/bin/gulp.js coverage:merge

おわりに

このように、少しずつではありますが、弊社の Web フロントエンドでも品質を高める努力を進めています。

  • webpack の導入により、ロジックの再利用性を始めとした開発効率が高まっています
  • テストについても、色々とつまづきながら、現在はカバレッジ取得まで含めて上手く回せています
  • Coveralls の導入により、品質の一つの基準がチーム内で共有できています

We’re hiring!

最後に、弊社スマートニュースでは Web フロントエンドも含めて、エンジニアを絶賛募集中です。ご興味がございましたら、以下の採用ページをチェックしていただければと思います。

SmartNews: 募集職種 http://about.smartnews.com/ja/careers/

また、弊社社食 SmartKitchen にてゲストの方にもランチをお楽しみいただけます。ご興味がございましたら、お気軽にお声がけいただければと思います。