モンモンブログ

技術的な話など

5分で分かる jasmine テストフレームワーク

JavaScript のテストフレームワーク jasmine がゴキゲンだぜ。書きやすいしテスト結果も見やすいしかわいいよちゅっちゅ。

そんな jasmine ちゃんをまだ触ったことない人のために、出来るだけ取っ付き易くなるように解説してみたいと思います。拙作の jQuery プラグイン jquery.narrows.js のテストでも使っておりますので、これをサンプルとして説明していきます。

使用するバージョンは jasmine 2.0.0 RC5 スタンドアローン版。2系はまだRC版ですが、使っていて特に不具合に出くわしたことはないです。2系になって大きく改善してる部分もあるので、アグレッシブに使っていきましょう。(Rubyフレームワーク用の gem 版(jasmine-gem)というのもありますがここでは触れません)

まずは見てみよう

とりあえず実際に動くものを見てみましょう。jquery.narrows.js のサンプルページ兼テストページがこちら。 → sample.html

画面の一番下までスクロールすると、こんな↓感じに jasmine のテスト結果が表示されてるのが見えるかと思います。

f:id:ymdsmn:20131210065327p:plain

  • food は初期状態で disabled であるべき
  • category で "meat" を選択したら food は肉に絞り込まれるべき
  • category で value="" を選択したら food は disabled であるべき
  • などなど…

といったテストを実行して、全て成功しています。

ちなみにテストに失敗した場合の表示はこんな感じ。

f:id:ymdsmn:20131210074642p:plain

jasmine 導入方法

導入の仕方について。

公式のダウンロードページには現在バージョン1系しか置いてなくて、2系は master ブランチの "dist" ディレクトリからダウンロードできます。 現時点での最新版は jasmine-standalone-2.0.0-rc5.zip です。

ダウンロードして解凍すると、ファイル構成はこんな感じ。

├── MIT.LICENSE
├── SpecRunner.html
├── lib
│   └── jasmine-2.0.0-rc5
│       ├── boot.js                 *
│       ├── console.js
│       ├── jasmine-html.js         *
│       ├── jasmine.js              *
│       ├── jasmine.css             *
│       └── jasmine_favicon.png
├── spec
│   ├── PlayerSpec.js
│   └── SpecHelper.js
└── src
    ├── Player.js
    └── Song.js

このうち、ブラウザ上でのテスト実行に必要なのは を付けたものだけです。これを自分のプロジェクト以下に置いて、HTML に読み込ませます。

<link rel="stylesheet" href="lib/jasmine-2.0.0-rc5/jasmine.css">
<script src="lib/jasmine-2.0.0-rc5/jasmine.js"></script>
<script src="lib/jasmine-2.0.0-rc5/jasmine-html.js"></script>
<script src="lib/jasmine-2.0.0-rc5/boot.js"></script>
<script src="spec/narrowsSpec.js"></script>   <!-- テストファイル -->

js ファイルは読み込む順番が決まっており、上に書いたとおり

  • jasmine.js
  • jasmine-html.js
  • boot.js
  • テストファイル(ここでは narrowsSpec.js

の順にして下さい。

これだけで、さっき見たようなテスト結果が画面下に表示されるようになります。

テストの書き方

じゃあ実際のテストはどんな風に書くのか?

サンプルで実行しているテストファイルはこちら。 → narrowsSpec.js

jasmine は RubyRSpec などの記法を踏襲しているので、RSpec などを使ったことのある人には馴染みやすいと思います。

抜粋して解説します。

describe('例1', function() {
    // select 要素を jQuery オブジェクトとして取得しておく
    var $categorySelect = $('#ex1-food-category');
    var $foodSelect = $('#ex1-food');

    // 各テストの最初に全ての select の選択状態を初期化
    beforeEach(function() {
        $categorySelect.val('').trigger('change');
        $foodSelect.val('').trigger('change');
    });

    it('food select は初期状態で disabled', function () {
        expect($categorySelect.is(':disabled')).toBe(false);
        expect($foodSelect.is(':disabled')).toBe(true);
    });

    it('category select で "meat" を選択したら food select は肉に絞り込まれる', function () {
        // category select で "meat" を選択
        $categorySelect.val('meat').trigger('change');
        // food select の disabled が解除されたことを確認
        expect($categorySelect.is(':disabled')).toBe(false);
        expect($foodSelect.is(':disabled')).toBe(false);
        // food select が絞り込まれた結果、value="" でない option が最低1つ以上あることを確認
        expect($foodSelect.find('option[value!=""]').size()).toBeGreaterThan(0);
        // food select の option の data-ex1-food-category 属性が全て "meat" であることを確認
        $foodSelect.find('option[value!=""]').each(function () {
            expect($(this).data('ex1-food-category')).toBe('meat');
        });
    });
});

describe は、複数のテストをまとめる働きをします。

describe('例1', function() {
    //...
});

階層構造にすることも出来ます。

describe('例1', function() {
    describe('例1-1', function() {
        //...
    });
    //...
});

この describe の中に、テスト本体である it や、前処理の beforeEach、後処理の afterEach を書いていきます。

beforeEach は名前の通り、各テスト毎に実行させたい前処理を書きます。

    beforeEach(function() {
        // 前処理
    });

afterEach は各テスト毎の後処理。

    afterEach(function() {
        // 後処理
    });

it がテスト本体です。テストの中身を記述します。

    it('food select は初期状態で disabled', function () {
        expect($categorySelect.is(':disabled')).toBe(false);
        expect($foodSelect.is(':disabled')).toBe(true);
    });

it 中にいっぱい出てくる expect がキモです。

        expect($categorySelect.is(':disabled')).toBe(false);

この場合、 $categorySelect.is(':disabled') の結果が false であることを期待する(expect)、という意味。

it 中の expect が1つでも失敗すれば、そのテストは失敗扱いとなります。

expect にチェーンしているメソッド toBeMatcher といいまして、expect に渡した中身が条件にマッチするかを判定します。

様々なバリエーションがあります。

// foo === 1 であるか
expect(foo).toBe(1);

// foo == 1 であるか
expect(foo).toEqual(1);

// 文字列が正規表現にマッチするか
expect(foo).toMatch(/^[a-z]+$/);

// 変数が定義済みか
expect(foo.func).toBeDefined();

// 変数が未定義か
expect(foo.func).toBeUndefined();

// 変数がnullか
expect(foo).toBeNull();

// 変数が true 相当の値か(true, 1, "a" など)
expect(foo).toBeTruthy();

// 変数が false 相当の値か(false, 0, 空文字列など)
expect(foo).toBeFalsy();

// 配列が値を含んでいるか
expect([1, 2, 3]).toContain(3);

// foo < 2 であるか
expect(foo).toBeLessThan(2);

// foo > 0 であるか
expect(foo).toBeGreaterThan(0);

// 小数が有効数字 0 ケタで 3 に等しいか
expect(3.141592).toBeCloseTo(3, 0);

// 関数 func が何らかの例外を投げることを期待
expect(func).toThrow();

とりあえず今回はここまで。

Symfony2 + Monolog でのロギングについて(その1)

Symfony2 のロギングでデフォルトで使われてる Monolog について調べたので忘れないうちにまとめておく。 何回かに分けて書きます。今回は Monolog イットセルフについて。

Monolog のバージョンは 1.7.0。

Monolog の構造

Monolog は4つのコンポーネントからなります。

  • ロガー
    コントローラで $logger = $this->get('logger') って取得するおなじみのあいつ。Logger クラス。 プロセッサとハンドラをいずれも複数登録できます。

  • ハンドラ
    メイン処理担当。ロガーからログ情報を受け取って、プロセッサで前処理して、フォーマッタで成形して、出力する。 プロセッサを複数とフォーマッタを1つ、登録できます。 また、以降のハンドラに処理を続行させるかどうかのフラグ (bubble フラグ) も持ちます。 代表的な StreamHandler はファイルにログを書き込むハンドラです。

  • プロセッサ
    ログ情報の前処理を担当。 例えば IntrospectionProcessor は、ログメソッドの呼ばれた箇所のファイル名、行番号、クラス名、メソッド名を、ログ情報の extra フィールドに付加します。

  • フォーマッタ
    ログ情報の成形を担当。 デフォルトの LineFormatter は、ログ情報を文字通り1行のログに成形します。

プロセッサはロガーとハンドラどっちにも登録出来て、
ロガーに登録すれば、共通の前処理をハンドラに渡す前にさせることができるし、
ハンドラに登録すれば、そのハンドラ専用の前処理をさせられます。

コンポーネント同士の登録関係を図にすると例えばこんな感じ?

ロガー
 └ プロセッサ1
 │
 └ ハンドラ1
 │ └ プロセッサ1
 │ └ プロセッサ2
 │ └ フォーマッタ
 │
 └ ハンドラ2(プロセッサなし)
   └ フォーマッタ

ログ情報 = record

ログ情報は "record" という名前の連想配列でやり取りされます。 record 1つがログ1行に相当します。

こんな形式です。

$record = array(
  'message' => "xxxx",            // ログメッセージ本文
  'level' => 400,                 // ログレベルを表す数値。DEBUG なら 100, ERROR なら 400 など
  'level_name' => "ERROR",        // ログレベルを表す文字列
  'channel' => "main",            // Logger 自身の名前を表す文字列
  'datetime' => new \DateTime(),  // タイムスタンプ
  'context' => array(),           // 追加情報その1。ロガーを呼ぶ側から利用出来る。
  'extra' => array(),             // 追加情報その2。プロセッサなどが使用。
  'formatted' => "xxxx",          // フォーマッタによる整形済みの文字列
);

「追加情報」が contextextra の2つありますけど

  • context は、 $logger->debug("ログメッセージ", array(1, 2, 3)) のようにログメソッドの第2引数として外から渡すことのできる情報
  • extra は、プロセッサなどが内部的に追加する情報

です。ちなみに LineFormatter の場合、どちらも単純に json_encode で文字列化して出力してくれます。

おおざっぱな動作

ログメソッドを呼んだ際、各コンポーネントがどんな風に連携して動作するのか、ざくっと解説します。 ハンドラによって動作は微妙に異なるので、ここでは代表的な組み合わせとして StreamHandler + LineFormatter を想定します。

スタート地点は $logger->debug("ログメッセージ") ですね。

  1. (Logger) ログメッセージを元に record 連想配列を作る
  2. (Logger) record をロガーのプロセッサで前処理
  3. (Logger) ハンドラを順に実行して record を処理していく
    1. (Handler) record のログレベルを確認、処理対象でなければ何もせず終了
    2. (Handler) record をハンドラのプロセッサで前処理
    3. (Handler) record をフォーマッタで文字列に変換
    4. (Handler) 文字列をファイルに出力
    5. (Handler) ハンドラの bubble フラグが false なら、これ以降のハンドラでの処理を停止する

今回はここまで。次回は Monolog を Symfony2 で使う際の設定方法について書く予定。

ローカルでの git push 時にサーバ上で自動で git pull

ローカルで git push した時にサーバ上で自動で git pull してくれるスクリプト gitpull_server.py を公開しました。

とりあえず Github, Backlog に対応しています。

使い方、注意点などは README に書きましたのでそちらをご覧下さい。



みんな大好き Python 製だよ。

うふふ、Python

_(:3」∠)_

_(    )_ コロリン

_(:3」∠)_ コロリン

Symfony2 のログを見やすく設定する

symfony2 のログってデフォルトだと余計なものが多くて見難いです。

例えばこんなです。

$ tail -f app/logs/dev.log
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.request" to listener "Symfony\Component\Security\Http\Firewall::onKernelRequest". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.request" to listener "Symfony\Bundle\AsseticBundle\EventListener\RequestListener::onKernelRequest". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.request" to listener "Hoe\HoeBundle\Hoe\EventListener\tvSeoRequestListener::onKernelRequest". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.controller" to listener "Symfony\Bundle\FrameworkBundle\DataCollector\RouterDataCollector::onKernelController". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.controller" to listener "Sensio\Bundle\FrameworkExtraBundle\EventListener\ControllerListener::onKernelController". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.controller" to listener "Sensio\Bundle\FrameworkExtraBundle\EventListener\ParamConverterListener::onKernelController". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.controller" to listener "Sensio\Bundle\FrameworkExtraBundle\EventListener\TemplateListener::onKernelController". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.controller" to listener "Symfony\Component\HttpKernel\DataCollector\RequestDataCollector::onKernelController". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.view" to listener "Sensio\Bundle\FrameworkExtraBundle\EventListener\TemplateListener::onKernelView". [] []
[2013-08-08 21:53:41] event.DEBUG: Listener "Sensio\Bundle\FrameworkExtraBundle\EventListener\TemplateListener::onKernelView" stopped propagation of the event "kernel.view". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Component\Security\Http\Firewall\ContextListener::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Component\Security\Http\RememberMe\ResponseListener::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Bridge\Monolog\Handler\FirePHPHandler::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Sensio\Bundle\FrameworkExtraBundle\EventListener\CacheListener::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Hoe\HoeBundle\Hoe\EventListener\tvAnalyticsTrackerListener::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Component\HttpKernel\EventListener\ResponseListener::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Component\HttpKernel\EventListener\LocaleListener::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Component\HttpKernel\EventListener\ProfilerListener::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Bundle\WebProfilerBundle\EventListener\WebDebugToolbarListener::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Component\HttpKernel\EventListener\StreamedResponseListener::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Listener "Sensio\Bundle\FrameworkExtraBundle\EventListener\TemplateListener::onKernelView" stopped propagation of the event "kernel.view". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Component\Security\Http\Firewall\ContextListener::onKernelResponse". [] []
[2013-08-08 21:53:41] security.DEBUG: Write SecurityContext in the session [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Component\Security\Http\RememberMe\ResponseListener::onKernelResponse". [] []
[2013-08-08 21:53:41] event.DEBUG: Notified event "kernel.response" to listener "Symfony\Bridge\Monolog\Handler\FirePHPHandler::onKernelResponse". [] []
...
  • ほぼ不要な "Notified event" 行だらけで、重要な行が埋もれてしまう
  • ログの末尾に必ずついてる空カッコ "[] []" がウザい

です。

見やすくなるよう設定をいじってみました。

"Notified event" をログしない

symfony2 のロギングには標準で Monolog を使ってます。 で、Monolog によるロギングはいくつかのチャネルからなり、そのうち event チャネルが "Notified event" ログを出力してるようです。

設定ファイルを修正して、この "event" チャネルをログしないように(event チャネル以外をログするように)設定します。

$ vim app/config/config_dev.yml
monolog:
    handlers:
        main:
            type:  stream
            path:  "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            # この行を追加。ビックリマーク "!" で「event 以外」って意味になる
            channels: !event

末尾の "[] []" を出力しない

ログのフォーマットは Monolog のフォーマッタ、LineFormatter が担当しています。 LineFormatter はデフォルトで↓のようなフォーマットで出力します(https://github.com/Seldaek/monolog/blob/master/src/Monolog/Formatter/LineFormatter.php#L24)。

"[%datetime%] %channel%.%level_name%: %message% %context% %extra%\n"

この末尾の %context%, %extra% に相当するデータが空な場合に、ログに "[] []"(空配列の JSON 表現)と吐き出されちゃうようです。じゃあ設定変更して余分(?)な %context% %extra% を省いちゃえばいーじゃん?

$ vim app/config/config_dev.yml
monolog:
    handlers:
        main:
            type:  stream
            path:  "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            channels: !event
            # main ハンドラで使うフォーマッタを指定
            formatter: my_formatter

# フォーマッタ設定を追加
services:
    my_formatter:
        class: Monolog\Formatter\LineFormatter
        arguments:
            # フォーマットを定義
            - "[%%datetime%%] %%channel%%.%%level_name%%: %%message%%\n"

ただ、僕は %context% や %extra% が何なのかよく分からんままやってるので注意です。 もし %context% %extra% をログに全く出さなくしちゃうのに抵抗があるなら、こいつらの位置を末尾から %message% の前あたりに移動すれば、空カッコ "[] []" も目立たなくなっていいのでは。例えばこんな感じ?

            # フォーマットを定義
            - "[%%datetime%%] %%channel%%.%%level_name%%: %%context%% %%extra%%: %%message%%\n"

どうでしょうか。

参考:

php5.3 以前で mixin 的なものを実装

php5.4 以降であれば Trait を使って mixin 的なことは実現できますが(※)、 php5.3 以前で mixin したい場合はどうするか。

いろいろ考えましたが、一番シンプルで使いやすいかなーという方法を紹介します。

※mixin と trait の違いとかよう分かってへんけどこまけぇこたぁいいんだよ!(AA略

ここが素敵

  • インスタンスメソッドも静的メソッドも mixin 出来ます(ただし静的メソッドの mixin は php5.3.0 以降でのみ可能)。
  • もちろん mixin したメソッドに引数を渡すことも出来ます。
  • mixin したインスタンスメソッドの中から、対象クラスの public なメンバ変数にアクセス出来ます。
  • 複数の mixin クラスを同時に mixin 出来ます。

まずはコード

サンプルコードです。

Trait が導入される php5.4 より前の php で mixin 的なことを実装してみた。

これを実行するとこう。

$ php mixin.php
// $foo->mixin_instance_method
これは MixinClass のインスタンスメソッドです yahoooooo! 2013-10-11
Foo の public メンバ変数にアクセスすることも出来ます。 999

// $foo->mixin2_instance_method
これは MixinClass2 のインスタンスメソッドです

// $foo->instance_method
これは Foo 自身のインスタンスメソッドです

// $foo->parent_instance_method
これは親クラス Bar のインスタンスメソッドです

// Foo::mixin_static_method
これは MixinClass の静的メソッドです yahoooooo! 2013-10-11

// Foo::mixin2_static_method
これは MixinClass2 の静的メソッドです

// Foo::static_method
これは Foo 自身の静的メソッドです

// Foo::parent_static_method
これは親クラス Bar の静的メソッドです

     *      *
  *     +
     n ∧_∧ n  IINE!
 + (ヨ(* ´∀`)E)
      Y     Y    *

いいねー。

解説

では解説です。インスタンスメソッド版 mixin と静的メソッド版 mixin の2つに分けて説明します。

1. インスタンスメソッド版 mixin

さっきのコードから、インスタンスメソッドの mixin に関わるとこだけ切り出すとこう。

インスタンスメソッドの mixin

1-1. mixin クラスを作成

まずは mixin クラスを作りましょう。

ほぼ、普通にクラス定義するだけですけど一点注意。 インスタンスメソッドの第1引数は $that とか $self とかの名前にして下さい。 ここに mixin 対象クラスのインスタンスが渡されてきます。これを通じて mixin 対象クラスの public なメンバ変数にアクセス出来るようになります。

// このように呼ぶメソッドであれば、
$foo->message("hello.");

// 定義はこうではなく、
public function message($message) { /* (´・ω・`) */ }

// こうです。
public function message($that, $message) { /* (・∀・)イイネ!! */ }

// 引数なしで呼ぶメソッドであっても、
$foo->bar();

// 定義はこうではなく、
public function bar() { /* (´・ω・`) */ }

// こうです。
public function bar($that) { /* (・∀・)イイネ!! */ }

1-2. mixin 対象クラスを修正

mixin 対象クラスをいじって __construct と __call をオーバーライドします。

__construct で MixinClass のインスタンスを生成してメンバ変数に持っておきます。

    public function __construct()
    {
        $this->mixin1 = new MixinClass();
    }

__call では、渡されてきたメソッド名 $method_name が MixinClass のインスタンスメソッドとして実行可能か調べて、そうあれば実行。 実行する際、第1引数に $this を渡します。これがさっき mixin クラス側でメソッドの第1引数とした $that (or $self) です。

    public function __call($method_name, $args)
    {
        // Foo 自身を表す $this を引数リストの先頭に追加します
        array_unshift($args, $this);
        // $this->mixin1->$method_name() が実行可能か調べる
        if (is_callable(array(&$this->mixin1, $method_name))) {
            // MixinClass#$method_name を実行
            return call_user_func_array(array(&$this->mixin1, $method_name), $args);
        } else {
            // そんなインスタンスメソッドはどこにも見つかりませんでした。。。
            throw new BadMethodCallException();
        }
    }

2. 静的メソッド版 mixin

静的メソッドの mixin に関わるとこだけ切り出すとこう。

静的メソッドの mixin

2-1. mixin クラスを作成

静的メソッドを持つクラスをふつーに定義するだけです。

2-2. mixin 対象クラスを修正

mixin 対象クラスでは __callStatic をオーバーライドします。MixinClass::$method_name が実行可能かどうか調べて、あれば実行。

    public static function __callStatic($method_name, $args)
    {
        if (is_callable("MixinClass::$method_name")) {
            // MixinClass::$method_name を実行
            return call_user_func_array("MixinClass::$method_name", $args);
        } else {
            // そんな静的メソッドはどこにも見つかりませんでした。。。
            throw new BadMethodCallException();
        }
    }

おしまい。

お解り頂けたかしら_(:3」∠)_