WebpackベースのアプリケーションをKarmaでテストする

banner

Webpackベースのアプリケーションをテストしたい

Webpackとは

Webpackはモジュールの依存関係を解決するプリプロセッサです。
依存関係の記述には、ES6、CommonJSやAMDのスタイルが使えます。
誤解を恐れずに言えば、node.js感覚で他のモジュールをrequireできるようにしてくれます。
webpackでソースコードをコンパイルすると、*.bundle.jsというような名前のファイルが出力されます。
これをアプリケーション側で事で、同期的・非同期的なモジュールの依存関係の解決が可能となります。

テストしやすい方法の検討

要件としては以下のとおりです:

  1. Webpackで書いたアプリケーションの各ユニットあるいは各ふるまいを、出来るだけ簡単にテストしたい
  2. 任意のCI(Continuous Integration)でも動かしたい

使用するツール群

前述の要件を満たすツールの組み合わせがたくさんあります。
今回は以下の構成でテストを走らせてみます:

  • Karma – テストランナー
  • PhantomJS – ヘッドレスブラウザ
  • Mocha – テストフレームワーク
  • Chai – アサーション
  • webpack – プリプロセッサ
  • bower – パッケージ管理

各ツールの詳しい説明は、それぞれの本家をご参照ください。

テストの実施準備手順

設定項目や手順の意味を理解するために、敢えてバラバラに分けて説明します。
筆者はMac OS Xの環境で行いました。

必要なパッケージのインストール

$ npm install --save-dev 
    karma 
    karma-chai 
    karma-mocha 
    karma-phantomjs-launcher 
    karma-webpack

Karmaの設定

karma.conf.jsで以下のファイルを作成します。

// Karma configuration
// Generated on Wed Nov 26 2014 23:12:23 GMT+0900 (JST)

module.exports = function(config) {
  config.set({

    // base path that will be used to resolve all patterns (eg. files, exclude)
    basePath: '',


    // frameworks to use
    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
    frameworks: ['mocha', 'chai'],


    // list of files / patterns to load in the browser
    files: [
      'test/**/*_test.js'
    ],


    // list of files to exclude
    exclude: [
    ],


    // preprocess matching files before serving them to the browser
    // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
    preprocessors: {
      // add webpack as preprocessor
      'test/**/*_test.js': ['webpack', 'sourcemap']
    },

    // test results reporter to use
    // possible values: 'dots', 'progress'
    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
    reporters: ['progress'],


    // web server port
    port: 9876,


    // enable / disable colors in the output (reporters and logs)
    colors: true,


    // level of logging
    // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
    logLevel: config.LOG_INFO,


    // enable / disable watching file and executing tests whenever any file changes
    autoWatch: true,


    // start these browsers
    // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
    browsers: ['PhantomJS'],


    // Continuous Integration mode
    // if true, Karma captures browsers, runs the tests and exits
    singleRun: false,

    // karma watches the test entry points
    // (you don't need to specify the entry option)
    // webpack watches dependencies
    // webpack configuration
    webpack: {
    }

  });
};

Bowerのモジュールを読み込めるようにする

設定ファイル冒頭に以下を追記します:

var webpack = require("webpack");
var path = require("path");
var bowerPath = path.join( __dirname, "bower_components" );

webpackの設定に以下を追記します(参考: usage with bower)

    webpack: {
      resolve: {
        // Tell webpack to look in node_modules, then bower_components when resolving dependencies
        // If your bower component has a package.json file, this is all you need.
        modulesDirectories: ["node_modules", bowerPath]
      },
      // Define a new plugin that tells webpack to look at the main property in bower.json files when resolving dependencies.
      // For marionette, we need it to load the CJS version, which we specify with as ["main", "1"] in the args below.
      plugins: [
        new webpack.ResolverPlugin([
          new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin( "bower.json", ["main", ["main", "1"]] )
        ], ["normal", "loader"])
      ]
    }

これで、テストのソースコードからbowerのモジュールをrequire("jquery");というように記述して読み込めるようになりました。

テスト対象のサンプルモジュール

簡単なjqueryを用いるサンプルモジュールです。
index.js で以下のようなファイルを用意してください。

var $ = require('jquery');

module.exports = function() {
  var div = $('<div />');
  $('body').append(div);
  return div;
};

依存関係にある jquery をbowerでインストールしておきます:

$ bower install --save jquery

テストの用意

test/main_test.js で以下のようなファイルを作成します:

var createElement = require('../index.js');

describe('jQuery sample', function() {

  it('should create div element', function() {
    expect(createElement).to.be.Function;
    var div = createElement();
    expect(div).to.be.Array;
  });

});

処理内容としては、先ほど作成したサンプル関数(createElement)を読み込みます。
次にその関数を呼び出し、結果を検査しています。

テストの実行

以下のコマンドを実行します:

$ karma start

結果:

$ karma start
INFO [karma]: Karma v0.12.28 server started at http://localhost:9876/
INFO [launcher]: Starting browser PhantomJS
Hash: 606dfda4c9d20a78987a
Version: webpack 1.4.13
Time: 8ms
webpack: bundle is now VALID.
webpack: bundle is now INVALID.
Hash: 1b62ba3e4c6b38edee7e
Version: webpack 1.4.13
Time: 944ms
                Asset    Size  Chunks             Chunk Names
_js/test/main_test.js  693312       0  [emitted]  test/main_test.js
chunk    {0} _js/test/main_test.js (test/main_test.js) 247722 [rendered]
    [0] ./test/main_test.js 245 {0} [built]
    [1] ./index.js 126 {0} [built]
    [2] ./bower_components/jquery/dist/jquery.js 247351 {0} [built]
webpack: bundle is now VALID.
INFO [PhantomJS 1.9.8 (Mac OS X)]: Connected on socket Mfny5tbQCtBeW9kEu0TR with id 80467111
PhantomJS 1.9.8 (Mac OS X): Executed 1 of 1 SUCCESS (0.011 secs / 0.002 secs)

動きましたね!

さらに改善する

ソースマップ

Webpackでコンパイルしたテストがコケた時、示される行番号が参考にできません。
ソースマップを有効にすることで、コンパイル前の行番号を取得できます。

$ npm install --save-dev karma-sourcemap-loader

preprocessorsに加えます:

preprocessors: {
    'test/test_index.js': ['webpack', 'sourcemap']
}

また、webpackにソースマップを生成するよう設定します:

webpack: {
  // ...
    devtool: 'inline-source-map'
}

実行すると以下のように元の位置が併せて示されます:

PhantomJS 1.9.8 (Mac OS X) Hello test Is it right? FAILED
        TypeError: '[object Object]' is not a function (evaluating 'should(T)')
            at /Users/nora/Development/tmp/karma-test2/test/main_test.js:53:0 <- webpack:///./test/main_test.js:7:0
PhantomJS 1.9.8 (Mac OS X): Executed 1 of 1 (1 FAILED) ERROR (0.006 secs / 0 secs)

chef-clientが処理の途中で403 Forbiddenエラーになる時の対処

Chef

認証に問題が無いのに404 Forbiddenになる

chef-clientを走らせていて、以下のようなエラーにたまに出くわします:

198.xx.xxx.xxx ================================================================================
198.xx.xxx.xxx Error executing action `create` on resource 'template[/var/www/mysite.com/shared/config/database.yml]'
198.xx.xxx.xxx ================================================================================
198.xx.xxx.xxx
198.xx.xxx.xxx Net::HTTPServerException
198.xx.xxx.xxx ------------------------
198.xx.xxx.xxx
198.xx.xxx.xxx 403 "Forbidden"
198.xx.xxx.xxx
198.xx.xxx.xxx
198.xx.xxx.xxx Resource Declaration:
198.xx.xxx.xxx
198.xx.xxx.xxx ---------------------
198.xx.xxx.xxx # In /var/chef/cache/cookbooks/rails/recipes/production.rb
198.xx.xxx.xxx
198.xx.xxx.xxx  40: template node[:rails][:app_root]+"/shared/config/database.yml" do
198.xx.xxx.xxx  41:     owner "root"
198.xx.xxx.xxx  42:     group "xx-dev"
198.xx.xxx.xxx  43:     mode 0775
198.xx.xxx.xxx  44: end
198.xx.xxx.xxx  45:
198.xx.xxx.xxx
198.xx.xxx.xxx Compiled Resource:
198.xx.xxx.xxx
198.xx.xxx.xxx ------------------
198.xx.xxx.xxx
198.xx.xxx.xxx # Declared in /var/chef/cache/cookbooks/rails/recipes/production.rb:40:in `from_file'
198.xx.xxx.xxx
198.xx.xxx.xxx template("/var/www/mysite.com/shared/config/database.yml") do
198.xx.xxx.xxx   provider Chef::Provider::Template
198.xx.xxx.xxx   action "create"
198.xx.xxx.xxx   retries 0
198.xx.xxx.xxx   retry_delay 2
198.xx.xxx.xxx   path "/var/www/mysite.com/shared/config/database.yml"
198.xx.xxx.xxx   backup 5
198.xx.xxx.xxx   source "database.yml.erb"
198.xx.xxx.xxx   cookbook_name "rails"
198.xx.xxx.xxx   recipe_name "production"
198.xx.xxx.xxx   mode 509
198.xx.xxx.xxx   owner "root"
198.xx.xxx.xxx   group "xx-dev"
198.xx.xxx.xxx end
198.xx.xxx.xxx
198.xx.xxx.xxx [2014-05-29T20:16:34+00:00] ERROR: Running exception handlers
198.xx.xxx.xxx [2014-05-29T20:16:34+00:00] FATAL: Saving node information to /var/chef/cache/failed-run-data.json
198.xx.xxx.xxx [2014-05-29T20:16:34+00:00] ERROR: Exception handlers complete
198.xx.xxx.xxx [2014-05-29T20:16:34+00:00] FATAL: Stacktrace dumped to /var/chef/cache/chef-stacktrace.out
198.xx.xxx.xxx [2014-05-29T20:16:34+00:00] FATAL: Net::HTTPServerException: template[/var/www/mysite.com/shared/config/database.yml] (rails::production line 40) had an error: Net::HTTPServerException: 403 "Forbidden"

しかし、最初はうまく走っていたので、認証周りで何かおかしいとは考え難い現象です。

no_lazy_loadを指定してみる

もしあなたのchef-clientが、十数分とか長い時間動作しているとしたら、以下のフラグをclient.rbに追記すると改善するかもしれません:

no_lazy_load true

chef-clientは必要に応じてcookbook_filetemplateを取得します(lazy-load)。
しかし、長い時間走らせていると、認証がタイムアウトするようです。
これを防ぐため、no_lazy_load trueを指定することでlazy-loadを無効にします。
これによって、はじめに必要なデータをすべて取得するようになります。

参考

jshintで”Redefinition of Promise (W079)”が出た時の対処方法

jshint v2.5.x 以降で、以下のようなコードを書くと、jshintさんに叱られます:

var Promise = require('bluebird');

そんな時は、.jshintrcファイルに以下の項目を追記しましょう:

{
  "predef": [ "-Promise" ]
}

参考

MongoDB v2.8.0-rc0 がリリースされた

昨日の深夜に、MongoDB v2.8.0-rc0(Release Candidate) がunstableでリリースされました。Stable releaseまでいよいよ秒読み開始です。
個人的にアツイのは「Improved Concurrency」なので少し説明します。

ドキュメントレベルのロック

待ちに待ったdocument-level lockingです。
MongoDBは書き込みが弱い事が自分の中で有名(笑)ですが、それが大幅に改善されます。

MongoDB 2.6(stable)では、データベースレベルの書き込みロックです。
つまり、あるデータベースのコレクションに対して書き込みを行っている間は、同じデータベースのどのコレクションに対する読み書きもできないということです。
これがドキュメントレベルになることによって、同一のドキュメント以外なら読み書きできるようになる、という事です。

下図は、カンファレンスでのデモンストレーションの様子のキャプチャです。
同時に複数のクライアントが書き込みオペレーションを実行しています。
赤いグラフで縦軸が書き込み数です。左側の小さい山がdb-level lockingで、右の大きな山がdoc-level lockingです。
書き込みパフォーマンスが大幅に改善している事がわかります。

mongodb2.8-document-level-locking

昔は書き込みロックはグローバルだったのを考えると、やっとまともに使えるようになったなぁという感じです。

そのほかのアップデート

プラッガブルなストレージエンジン

MongoDBのストレージエンジンはあんまり性能が良くない事で評判なんですが、プラッガブルになった事でMySQLみたいに付け替え可能になりました。
これで有志による性能がより高いストレージエンジンの開発が期待されます。

圧縮

On-dick compressionによってI/O効率が30〜80%よくなるそうです。

参考

さらに詳しい情報は以下をチェックしてください。

[JavaScript] Getter/Setterをオブジェクト初期化子で定義する方法

JS

koaのソースコード を読んで知ったのでメモ。

一般的な定義方法

JavaScriptではオブジェクトにSetter/Getterを定義できます。
Setter/Getterとは、プロパティの設定時・参照時に呼び出されるメソッドのことです。

一般的には以下のように定義します:

var o = function() {};
o.prototype.__defineGetter__("b", function() { return this.a + 1; });
o.prototype.__defineSetter__("c", function(x) { this.a = x / 2; });

var i = new o();
i.c = 10;
console.log(i.b);

オブジェクト初期化子を使った定義

このSetter/Getterは、オブジェクト初期化子を使っても定義できます。

var o = {
  a: 7,
  get b() { return this.a + 1; },
  set c(x) { this.a = x / 2; }
};

var i = Object.create(o);

このように、get, setプレフィックスをつけて関数を定義します。

ブラウザ側で使う際の注意

MDNのドキュメント によると、ブラウザ実装状況は以下の通りです:

機能 Firefox (Gecko) Chrome Internet Explorer Opera Safari
基本サポート 2.0 (1.8.1) 1 9 9.5 3

サポートされていない場合 (特にIE6-8において) 、スクリプトはシンタックスエラーを引き起こします。

参考資料

[nodejs] DynamoDB ODMのvogelsをJSON形式に対応させた

DynamoDB

DynamoDBがJSONデータをサポートした

今月(2014年10月10日)のことですが、Amazon DynamoDBがいろいろアップデートしました:

その中でもJSONデータサポートは注目のアップデートです。
これは具体的にどういうことか説明します。
DynamoDBはスキーマレスのNoSQLです。
これまで、カラム(属性)には単一データまたはセット(重複を許さない配列)が格納できました。
しかしながら、入れ子構造は格納できませんでした。
JSONデータのサポートとは、この入れ子構造が取り扱えるようになったということです。
つまり、MongoDBのように、JSON形式のデータをそのままデータベースに格納できるようになったのです。

既存ODMライブラリに手を加えて対応させた

node.js向けのODMライブラリにはいくつかあります。
vogels はその中でもいい感じのライブラリです。
しかし残念ながら、あまり精力的にメンテナンスされてるとは言いがたく、今回のアップデートに対してもまだ対応していない様子です。
そこで、自分で対応させてみることにしました。
フォークしたものがこちらにあります:

いちおうプルリクしてありますが、マージされるのを待てません笑

vogelsの使い方

まずは基本的な使い方をドキュメントから引用してざっくり説明します。
次に、入れ子構造のデータを扱うための方法を説明します。

設定

AWS SDKのキーをファイルから設定します。

var vogels = require('vogels');
vogels.AWS.config.loadFromPath('credentials.json');

もちろん直接値を渡して設定もできます。

var vogels = require('vogels');
vogels.AWS.config.update({accessKeyId: 'AKID', secretAccessKey: 'SECRET'});

基本

データモデルの定義方法は以下のとおりです:

var Account = vogels.define('Account', function (schema) {
  schema.String('email', {hashKey: true});
  schema.String('name').required(); // name attribute is required
  schema.Number('age'); // age is optional
  schema.Date('created', {default: Date.now});
});

ハッシュとレンジキーを指定しての定義:

var BlogPost = vogels.define('Account', function (schema) {
  schema.String('email', {hashKey: true});
  schema.String('title', {rangeKey: true});
  schema.String('content');
  schema.StringSet('tags');
});

書き込み:

Account.create({email: 'foo@example.com', name: 'Foo Bar', age: 21}, function (err, acc) {
  console.log('created account in DynamoDB', acc.get('email'));
});

読み込み:

Account.get('test@example.com', function (err, acc) {
  console.log('got account', acc.get('email'));
});

簡単ですね!
クエリやセカンダリインデックスなどの使い方はドキュメントを参照してください。

入れ子構造の取り扱い方

ここからが本題です。
DynamoDBでは、MapとListという新しいデータ型を追加することで入れ子構造の格納を実現しています。
先述の通り、vogelsではスキーマを定義して、それに合わせたデータを出し入れします。
vogelsで入れ子構造を取り扱うには、このスキーマをMapとListを使用して定義します。

Map

MapはJavaScriptでいうオブジェクトです。まずは以下の例をみてください。

var Photo = vogels.define('Photo', function (schema) {
  schema.String('userid', {hashKey: true});
  schema.Map('location');
});

Photo.create({userid:'john', location: {name: "東京タワー", latitude:35.65858, longitude: 139.745433}}, console.log);

この例では、Photoモデルのlocation属性がMap型として定義されています。
中身には、場所に関するデータが格納されています。
単純ですね!
もちろん、このlocationの中身もスキーマで定義できます。

var Photo = vogels.define('Photo', function (schema) {
  schema.String('userid', {hashKey: true});
  schema.Map('location', function(schema) {
    schema.String('name');
    schema.String('latitude');
    schema.String('longitude');
  });
});

Mapの中に更にMapを格納することもできます
Mapの中身のスキーマを定義しなかった場合、未定義となり制約は与えられず、自由なフォーマットのデータが格納できます。

List

ListはJavaScriptでいう配列です。使い方は以下のとおり。

var Photo = vogels.define('Photo', function (schema) {
  schema.String('userid', {hashKey: true});
  schema.List('likes');
});

Photo.create({userid:'john', likes: ['emily']}, console.log);

こちらも直感的ですね。
Listの各要素の形式が同じで尚且つMap型の場合は、以下のように要素のスキーマを定義できます。

var Photo = vogels.define('Photo', function (schema) {
  schema.String('userid', {hashKey: true});
  schema.List('likes', function(schema) {
    schema.String('userid');
    schema.Date('likedAt');
  });
});

MongoDBとの併用はかなりイケてる?

JSON形式のドキュメントがそのまま格納できるDBMSとして、有名なのはMongoDBです。
今回のJSONサポートによって、DynamoDBはMongoDBと同じフォーマットでデータを取り扱えるようになりました。
これは、「併用しやすい」という事です。
併用すると何が嬉しいか、考えてみます。

DynamoDBは運用コストが低くスケーラビリティが高いのがウリのデータベースです。
その代わり、検索の柔軟性を犠牲にしています。
それに対してMongoDBは検索の柔軟性が高いのが強みです。
しかしながら、DaaSは価格が高いし自分で運用するのは大変です。
DynamoDBとMongoDBを併用することによって、互いの弱みを補い合えるかもしれません。

MongoDBを既に使っていた人にとっては、すごい良いニュースではないでしょうか。

iOSのクラッシュログファイルをシンボリケートする

どこでクラッシュしたのか見通しを良くする

Appleから受け取ったクラッシュログは、クラッシュした状態のスタックトレースをバイナリ上のアドレスで示されています。
このままだと、内容を見てもソースコード上のどこが悪いのかさっぱり分かりません。
シンボリケートとは、主にバイナリ上のアドレスからソースコードの場所を突き止める事をいいます。

使用するコマンド

クラッシュログのシンボリケートにはsymbolicatecrashコマンドを使用すると便利です。
XCode 6では以下のパスに存在しています。

/Applications/Xcode.app/Contents/SharedFrameworks/DTDeviceKitBase.framework/Versions/A/Resources/symbolicatecrash

/usr/bin あたりにシンボリックリンクを貼っておくと吉。

必要なファイル

クラッシュログを吐いたバイナリと同じビルドのもので、以下が必要です:

  • appファイル
  • dSYMファイル

XCodeのOrganizerを開き、アーカイブ一覧から該当のビルドを探します。
次に、該当ビルドの項目を右クリックしてShow in Finderします。
開かれたディレクトリに、上記二つのファイルが存在します。

シンボリケートの実行

以下のように実行します。

export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"

symbolicatecrash -v crashファイル dSYMファイル appファイル

トラブルシューティング

DEVELOPER_DIRを予め設定しておかないと、以下のようなエラーが出ます。

Error: "DEVELOPER_DIR" is not defined at /Applications/Xcode.app/[snip]Resources/symbolicatecrash line 53.