はじめに
みなさまはじめまして、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 を適用させます。
参考:
- https://github.com/deepsweet/istanbul-instrumenter-loader#testindexjs
- https://github.com/webpack/karma-webpack#alternative-usage
テストコードのエントリポイントを作成
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上で確認可能にする方法をご紹介します。
- Java, JavaScript のカバレッジ情報のいずれも Coveralls 形式の JSON に変換する
- 両者をマージする
- マージ結果を 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 にてゲストの方にもランチをお楽しみいただけます。ご興味がございましたら、お気軽にお声がけいただければと思います。