読者です 読者をやめる 読者になる 読者になる
無料で使えるシステムトレードフレームワーク「Jiji」 をリリースしました!

・OANDA Trade APIを利用した、オープンソースのシステムトレードフレームワークです。
・自分だけの取引アルゴリズムで、誰でも、いますぐ、かんたんに、自動取引を開始できます。

Knockout + ContainerJS でテスタブルにToDoリストを作るチュートリアル

JavaScript

Knockout + ContainerJS + Require.js で テスタブル にToDoリストを作るチュートリアルです。

ポイント


  • オブジェクトの生成と依存関係を、DIコンテナで一元管理
    • DIコンテナを利用して、ViewModel、Modelの生成と関連付けを自動化。
    • コンポーネント間の結合を疎にでき、テスト時のモックへの差し替えも簡単にできるようになります。
  • JavaScriptソースはクラスごとに分割管理
    • 1ファイル200行超えたらメンテナンスとか無理ですよね! ということで、ソースファイルはクラスごとに分割管理します。
    • ソース間の依存関係解決と読み込みはrequire.jsで。
    • リリース時には、必要なソースをminify && 1ファイルに結合して読み込みを最適化します。

目次

  1. Modelを作る
  2. Modelをテストする
  3. ViewModelを作る
  4. ViewModelをテストする
  5. View(HTML/CSS)を作る
  6. minify && 結合してリリース

ソースはこちらで公開しています。細かいところは省略していたりするので、ソースも合わせて参照ください。

Todoリストの仕様

作成するTodoリストの機能は以下。

  • タイトルを入力して、Todoを登録できる。
    • Todoにはタイトルと完了したかどうか、作成日、最終更新日の属性がある。
  • 終了したTodoは「完了」にできる。
    • 完了したものを未完了に戻すこともできる。
  • Todoを個別に削除できる。
  • 完了したTodoはまとめて削除できる。

TodoMVC で作られているものと大体同じはず。UIは以下のような感じで。

なお、本来は登録したTodoをサーバーに送って保存とか必要ですが、今回はJavaScriptプロジェクトのサンプルということでサーバーとの連携部分は省略します。サーバーで永続化する場合は、Model側のAPI呼び出し時にREST APIなりなんなりを呼び出すようにすればOK。

1. Modelを作る

まずは、Modelを作ります。

  • クラスは、TodoとTodoリスト(登録されたTodoの集まりを表すもの)の二つ。
  • モデルはPlainなJavaScriptクラスとして定義します。
    • フレームワーク(or ライブラリ)依存にはしない派。
    • 理由は、このあたり を参照。
    • テスタブルで変化に強いモデルにするためには、PlainなJavaScriptクラスにしておくべきと思うのです。
Todo
  • タイトル(title),完了したかどうか(completed)、作成日(createdAt)、最終更新日(lastModified)の属性を持ちます。
    • タイトルは100文字まで。空文字も不可。
    • 各種属性の変更を通知する仕組みを持ちます。(Observerパターン)
  • 完了にする(complete)/未完了に戻す(activate)操作ができます。
  • 現在時刻はTimeSourceから取得するようにしています。
    • 現在時刻に依存しないテストにするためのワンポイント。
    • テスト時にはTimeSourceをモックに差し替えて、テスト内での時間をコントロールします。
define([
    "models/events",
    "utils/observable"
], function(Events, Observable){
    
    "use strict";
    
    var sequence = 1;
    
    /**
     * @class
     */
    var Todo = function(timeSource) {
        Observable.apply(this, arguments);
        
        var now = timeSource.now();
        
        this.id           = sequence++;
        this.title        = "";
        this.completed    = false;
        this.createdAt    = now;
        this.lastModified = now;
        
        this.timeSource = timeSource;
        this.todoList = null;
        
        Object.seal(this);
    };
    
    Todo.prototype = new Observable();
    
    /**
     * @public
     * @return {string}
     */
    Todo.prototype.setTitle = function( title ) {
        validateTitle(title);
        update(this, "title", title);
        update(this, "lastModified", this.timeSource.now());
    };
    
    /**
     * @public
     */
    Todo.prototype.complete = function() {
        update(this, "completed", true);
        update(this, "lastModified", this.timeSource.now());
    };
    
    /**
     * @public
     */
    Todo.prototype.activate = function() {
        update(this, "completed", false);
        update(this, "lastModified", this.timeSource.now());
    };
    
    /**
     * @public
     */
    Todo.prototype.remove = function() {
        if (!this.todoList) throw new Error("illegal state. todoList is not set.");
        this.todoList.removeById( this.id );
    };
    
    /** @private */
    Todo.prototype.attachTo = function( todoList ) {
        this.todoList = todoList;
    };
    
    /**
     * @public
     * @param {TimeSource} timeSource
     * @param {string} title
     * @param {boolean?} completed
     * @param {Date?} createdAt
     * @param {Date?} lastModified
     */
    Todo.create = function( timeSource, title, 
            completed, createdAt, lastModified ) {
        
        validateTitle(title);
        
        var now  = timeSource.now();
        var todo = new Todo(timeSource);
        
        todo.title        = title;
        todo.completed    = completed || false;
        todo.createdAt    = createdAt || now;
        todo.lastModified = lastModified || now;
        
        return todo;
    };
    
    /** @private */
    var update = function(that, propertyName, newValue) {
        var oldValue = that[propertyName];
        that[propertyName] = newValue;
        that.fire(Events.UPDATED, {
            propertyName : propertyName,
            newValue     : newValue,
            oldValue     : oldValue
        });
    };
    /** @private */
    var validateTitle = function(title) {
        if (!title) throw new Error("title is not set.");
        if (title.length > 100) throw new Error("title is too long.");
    };
    
    return Object.freeze(Todo);
});
TodoList
  • Todoの配列(items)と、それを操作するメソッド(Todoの追加や削除)を持ちます。
  • Todoと同じく、こちらもObserverパターンでTodoの追加/削除を通知する仕組みを用意。
define([
  "container",
  "models/events",
  "models/todo",
  "utils/observable"
], function(ContainerJS, Events, Todo, Observable){
    
    "use strict";
    
    /**
     * @class
     */
    var TodoList = function() {
        Observable.apply(this, arguments);
        
        this.timeSource = ContainerJS.Inject;
        
        this.items = [];
        
        Object.seal(this);
    };
    
    TodoList.prototype = new Observable();
    
    /**
     * @public
     */
    TodoList.prototype.load = function( ) {
        this.fire( Events.LOADED, {
           items: this.items
        });
    };
    
    /**
     * @public
     * @param {string} title
     * @return {Todo}
     */
    TodoList.prototype.add = function( title ) {
        
        var todo = Todo.create(this.timeSource, title);
        todo.attachTo(this);
        this.items.push(todo);
        
        this.fire( Events.ADDED, {
           added  : [todo],
           items  : this.items
        });
        
        return todo;
    };
    
    /**
     * @public
     */
    TodoList.prototype.removeCompleted = function() {
        removeItems( this, function(item){
            return item.completed;
        });
    };
    
    /**
     * @public
     * @param {number} id
     */
    TodoList.prototype.removeById = function(id) {
        removeItems( this, function(item){
            return item.id === id;
        });
    };
    
    /** @private */
    var removeItems = function( that, f ) {
        var removed = [];
        that.items = that.items.filter(function(item){
            if (f(item)) {
                removed.push(item);
                return false;
            } else {
                return true;
            }
        });
        if (removed.length > 0) {
            that.fire( Events.REMOVED, {
                items  : that.items,
                removed: removed
            });
        }
    };
    
    return Object.freeze(TodoList);
});

2. Modelをテストする

Modelができたので、テストを書きます。

Todo spec
define([
    "models/todo",
    "models/events",
    "test/mock/models/time-source"
], function( Todo, Events, TimeSource ) {
    
    describe('Todo', function() {
        
        var timeSource = new TimeSource();
        timeSource.set(2013, 1, 1);
        
        it( "'new' creates a new instance.", function() {
            
            var todo = new Todo(timeSource);
            
            expect( todo.title ).toEqual( "" );
            expect( todo.completed ).toEqual( false );
            expect( todo.createdAt ).toEqual( new Date(2013,0,1) );
            expect( todo.lastModified ).toEqual( new Date(2013,0,1) );
        });
        
        it( "'Todo.create' can creates a new instance with specified properties.", function() {
            
            var todo = Todo.create(timeSource, "title", 
                    true, new Date(1000), new Date(2000) );
            
            expect( todo.title ).toEqual( "title" );
            expect( todo.completed ).toEqual( true );
            expect( todo.createdAt ).toEqual( new Date(1000) );
            expect( todo.lastModified ).toEqual( new Date(2000) );
            
            todo = Todo.create(timeSource, "title2", 
                    false, new Date(2000), new Date(3000) );
            
            expect( todo.title ).toEqual( "title2" );
            expect( todo.completed ).toEqual( false );
            expect( todo.createdAt ).toEqual( new Date(2000) );
            expect( todo.lastModified ).toEqual( new Date(3000) );
        });
        
        it( "'setTitle' can updates a title and fire updated events.", function() {
            
            var todo = Todo.create(timeSource, "title", 
                    false, new Date(1000), new Date(2000) );
            
            var events = [];
            todo.addObserver( Events.UPDATED, function(ev) {
                events.push( ev );
            });
            
            todo.setTitle("title2");
            
            expect( todo.title ).toEqual( "title2" );
            expect( todo.completed ).toEqual( false );
            expect( todo.createdAt ).toEqual( new Date(1000) );
            expect( todo.lastModified ).not.toEqual( new Date(2000) );
            
            
            expect( events.length ).toEqual( 2 );
            
            expect( events[0].eventId ).toEqual( Events.UPDATED );
            expect( events[0].propertyName ).toEqual( "title" );
            expect( events[0].newValue ).toEqual( "title2" );
            expect( events[0].oldValue ).toEqual( "title" );
            
            expect( events[1].eventId ).toEqual( Events.UPDATED );
            expect( events[1].propertyName ).toEqual( "lastModified" );
            expect( events[1].newValue ).toEqual( todo.lastModified );
            expect( events[1].oldValue ).toEqual( new Date(2000) );
        });
        
        it( "'complete' can updates a completed state and fire updated events.", function() {
            
            var todo = Todo.create(timeSource, "title", 
                    false, new Date(1000), new Date(2000) );
            
            var events = [];
            todo.addObserver( Events.UPDATED, function(ev) {
                events.push( ev );
            });
            
            todo.complete();
            
            expect( todo.title ).toEqual( "title" );
            expect( todo.completed ).toEqual( true );
            expect( todo.createdAt ).toEqual( new Date(1000) );
            expect( todo.lastModified ).not.toEqual( new Date(2000) );
            
            
            expect( events.length ).toEqual( 2 );
            
            expect( events[0].eventId ).toEqual( Events.UPDATED );
            expect( events[0].propertyName ).toEqual( "completed" );
            expect( events[0].newValue ).toEqual( true );
            expect( events[0].oldValue ).toEqual( false );
            
            expect( events[1].eventId ).toEqual( Events.UPDATED );
            expect( events[1].propertyName ).toEqual( "lastModified" );
            expect( events[1].newValue ).toEqual( todo.lastModified );
            expect( events[1].oldValue ).toEqual( new Date(2000) );
        });
        
        it( "'activate' can updates a completed state and fire updated events.", function() {
            
            var todo = Todo.create(timeSource, "title", 
                    true, new Date(1000), new Date(2000) );
            
            var events = [];
            todo.addObserver( Events.UPDATED, function(ev) {
                events.push( ev );
            });
            
            todo.activate();
            
            expect( todo.title ).toEqual( "title" );
            expect( todo.completed ).toEqual( false );
            expect( todo.createdAt ).toEqual( new Date(1000) );
            expect( todo.lastModified ).not.toEqual( new Date(2000) );
            
            
            expect( events.length ).toEqual( 2 );
            
            expect( events[0].eventId ).toEqual( Events.UPDATED );
            expect( events[0].propertyName ).toEqual( "completed" );
            expect( events[0].newValue ).toEqual( false );
            expect( events[0].oldValue ).toEqual( true );
            
            expect( events[1].eventId ).toEqual( Events.UPDATED );
            expect( events[1].propertyName ).toEqual( "lastModified" );
            expect( events[1].newValue ).toEqual( todo.lastModified );
            expect( events[1].oldValue ).toEqual( new Date(2000) );
        });
    });
    
});
TodoList spec
define([
    "models/todo-list",
    "models/events",
    "test/mock/models/time-source"
], function( TodoList, Events, TimeSource ) {
    
    describe('TodoList', function() {
        
        var todoList;
        var events = [];
        beforeEach(function() {
            todoList = new TodoList();
            todoList.timeSource = new TimeSource();
            [Events.LOADED,Events.ADDED,Events.REMOVED].forEach(function(i) {
                todoList.addObserver( i, function(ev) {
                    events.push( ev );
                });
            });
        });
        afterEach(function() {
            events = [];
        });
        
        it( "'add' creates and add a new todo.", function() {
            
            todoList.add("test1");
            
            expect( events.length ).toEqual( 1 );
            
            expect( events[0].eventId ).toEqual( Events.ADDED );
            expect( events[0].added.length ).toEqual( 1 );
            expect( events[0].added[0].title ).toEqual( "test1" );
            expect( events[0].items.length ).toEqual( 1 );
            expect( events[0].items[0].title ).toEqual( "test1" );
            
            
            todoList.add("test2");
            
            expect( events.length ).toEqual( 2 );
            
            expect( events[1].eventId ).toEqual( Events.ADDED );
            expect( events[1].added.length ).toEqual( 1 );
            expect( events[1].added[0].title ).toEqual( "test2" );
            expect( events[1].items.length ).toEqual( 2 );
            expect( events[1].items[0].title ).toEqual( "test1" );
            expect( events[1].items[1].title ).toEqual( "test2" );
        });
        
        it( "'removeById' removes todos by id.", function() {
            
            var items = [];
            items.push(todoList.add("test1"));
            items.push(todoList.add("test2"));
            items.push(todoList.add("test3"));
            
            expect( todoList.items.length ).toEqual( 3 );
            events = [];
            
            todoList.removeById( items[1].id );
            {
                expect( todoList.items.length ).toEqual( 2 );
                
                expect( events.length ).toEqual( 1 );
                expect( events[0].eventId ).toEqual( Events.REMOVED );
                expect( events[0].removed.length ).toEqual( 1 );
                expect( events[0].removed[0].title ).toEqual( "test2" );
                expect( events[0].items.length ).toEqual( 2 );
                expect( events[0].items[0].title ).toEqual( "test1" );
                expect( events[0].items[1].title ).toEqual( "test3" );
            }
            
            todoList.removeById( items[2].id );
            {
                expect( todoList.items.length ).toEqual( 1 );
                
                expect( events.length ).toEqual( 2 );
                expect( events[1].eventId ).toEqual( Events.REMOVED );
                expect( events[1].removed.length ).toEqual( 1 );
                expect( events[1].removed[0].title ).toEqual( "test3" );
                expect( events[1].items.length ).toEqual( 1 );
                expect( events[1].items[0].title ).toEqual( "test1" );
            }
            
            todoList.removeById( 9999 );
            {
                expect( todoList.items.length ).toEqual( 1 );
                expect( events.length ).toEqual( 2 );
            }
            
            todoList.removeById( items[0].id );
            {
                expect( todoList.items.length ).toEqual( 0 );
                
                expect( events.length ).toEqual( 3 );
                expect( events[2].eventId ).toEqual( Events.REMOVED );
                expect( events[2].removed.length ).toEqual( 1 );
                expect( events[2].removed[0].title ).toEqual( "test1" );
                expect( events[2].items.length ).toEqual( 0 );
            }
        });
        
        it( "'removeCompleted' removes completed todos.", function() {
            
            var items = [];
            items.push(todoList.add("test1"));
            items.push(todoList.add("test2"));
            items.push(todoList.add("test3"));
            
            expect( todoList.items.length ).toEqual( 3 );
            events = [];
            
            todoList.removeCompleted( );
            {
                expect( todoList.items.length ).toEqual( 3 );
                expect( events.length ).toEqual( 0 );
            }
            
            
            items[1].complete();
            items[2].complete();
            
            todoList.removeCompleted( );
            {
                expect( todoList.items.length ).toEqual( 1 );
                
                expect( events.length ).toEqual( 1 );
                expect( events[0].eventId ).toEqual( Events.REMOVED );
                expect( events[0].removed.length ).toEqual( 2 );
                expect( events[0].removed[0].title ).toEqual( "test2" );
                expect( events[0].removed[1].title ).toEqual( "test3" );
                expect( events[0].items.length ).toEqual( 1 );
                expect( events[0].items[0].title ).toEqual( "test1" );
            }
            
            
            items[0].complete();
            
            todoList.removeCompleted( );
            {
                expect( todoList.items.length ).toEqual( 0 );
                
                expect( events.length ).toEqual( 2 );
                expect( events[1].eventId ).toEqual( Events.REMOVED );
                expect( events[1].removed.length ).toEqual( 1 );
                expect( events[1].removed[0].title ).toEqual( "test1" );
                expect( events[1].items.length ).toEqual( 0 );
            }
        });
    });
    
});

テストが書けたら、実行環境を整備していきます。

  • HTMLをブラウザに読ませて実行 (ブラウザでの確認用)
  • コマンドラインからRhinoで実行 (定期ビルドでの確認用)

の2つを用意。

HTML版

Jasminのテストランナーをベースに作成します。ファイル構成は以下。

├src/
│└models/
│ ├todo.js
│ └todo-list.js
└ test/
 ├specs/  .. テストを格納するディレクトリ
 │└models/
 │ ├todo.js
 │ └todo-list.js
 ├all-specs.js
 ├config.js  .. テスト用のrequire.js設定ファイル
 └test.html .. テストをブラウザ上で実行する時に読み込ませるHTMLファイル

test.htmlは以下の内容。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Jasmine Test Runner</title>
    <link rel="stylesheet" type="text/css" href="../../../lib-test/jasmine-1.0.2/jasmine.css">
    <script type="text/javascript" src="../../../lib/require.js"></script>
    <script type="text/javascript" src="config.js"></script>
  </head>
  <body>
    <script type="text/javascript">
      require(["jasmine/jasmine"], function() {
            require(["jasmine/jasmine-html","larrymyers-jasmine-reporters/jasmine.console_reporter"], function() {
              require(["test/all-specs"], function() {
                  jasmine.getEnv().addReporter(new jasmine.TrivialReporter());
                  jasmine.getEnv().execute();
              });
          });
      });
    </script>
  </body>
</html>

config.jsにrequire.jsの設定を書きます。pathsに依存モジュールのパスを設定。

require.config({
    baseUrl: "../src/js",
    paths: {
        "container" : "../../../../minified/container",
        "knockout"  : "./knockout-2.3.0",
        "test"      : "../../test",
        "jasmine"   : "../../../../lib-test/jasmine-1.0.2",
        "larrymyers-jasmine-reporters" : "../../../../lib-test/larrymyers-jasmine-reporters-adf6227/src"
    },
    waitSeconds: 1
});

all-specs.js でテストを読み込みます。

define([
    "test/specs/models/todo",
    "test/specs/models/todo-list"
]);

あとは、test.htmlをブラウザにドラッグ&ドロップすれば、テストが実行されるはず。


コマンドライン

Antを使います。 javaタスクでRhinoからテストを実行します。

<?xml version="1.0" encoding="UTF-8"?>
<project name="todo lists" default="release" basedir=".">

    <property name="test.dir" value="test" />
	<property name="tools.dir" value="../../tools" />
    
    <property name="rhino.home" value="${tools.dir}/rhino1_7R4" />
    <property name="jasmine.home" value="../../lib-test/jasmine-1.0.2" />
    <property name="jasmine-reporters.home" value="../../lib-test/larrymyers-jasmine-reporters-adf6227" />
    <property name="envjs.home" value="../../lib-test/thatcher-env-js-cb738b9" />
    <property name="closure-compiler.home" value="${tools.dir}/compiler-20130603" />

    <target name="test">
        <java jar="${rhino.home}/js.jar" fork='true' dir="test">
            <arg line="-encoding utf-8 -opt -1 cui.js"/>
            <sysproperty key="envjs.home" value="../${envjs.home}"/>
        </java>
    </target>
</project>

cui.jsの内容は以下。

  • Rhinoでテストを実行するための設定をごにょごにょ書いていきます。
  • config.js,all-specs.jsはhtml版と共有。
load("../../../tools/r.js");
load("../../../lib/require.js");
load(environment["envjs.home"]+"/dist/env.rhino.js");
load(environment["config"] || "config.js");

console = {};
console.log = function(str) {
  print(str);
};

var consoleReporter = null;
require(["jasmine/jasmine"], function() {
    require(["jasmine/jasmine-html",
             "larrymyers-jasmine-reporters/jasmine.console_reporter"], function() {
        require(["test/all-specs"], function() {
            consoleReporter = new jasmine.ConsoleReporter();
            jasmine.getEnv().addReporter( consoleReporter );
            jasmine.getEnv().execute();
        });
    });
});

Envjs.wait();

var filedCount = consoleReporter.executed_specs - consoleReporter.passed_specs;
quit(filedCount <= 0 ? 0 : 1);

あとは、

$ ant test

で実行。テストが走ります。

Buildfile: D:\git\container-js\samples\todo-list-example\build.xml
test:
     [java] See https://github.com/jrburke/r.js for usage.
     [java] [  Envjs/1.6 (Rhino; U; Windows 7 amd64 6.1; en-US; rv:1.7.0.rc2) Resig/20070309 PilotFish/1.2.13  ]
     [java] Runner Started.
     [java] Todo : 'new' creates a new instance. ... 
     [java] Passed.
     [java] Todo : 'Todo.create' can creates a new instance with specified properties. ... 
     [java] Passed.
     [java] Todo : 'setTitle' can updates a title and fire updated events. ... 
     [java] Passed.
     [java] Todo : 'complete' can updates a completed state and fire updated events. ... 
     [java] Passed.
     [java] Todo : 'activate' can updates a completed state and fire updated events. ... 
     [java] Passed.
     [java] Todo: 51 of 51 passed.
     [java] TodoList : 'add' creates and add a new todo. ... 
     [java] Passed.
     [java] TodoList : 'removeById' removes todos by id. ... 
     [java] Passed.
     [java] TodoList : 'removeCompleted' removes completed todos. ... 
     [java] Passed.
     [java] TodoList: 54 of 54 passed.
     [java] Runner Finished.
     [java] 8 specs, 0 failures in 0.289s.
BUILD SUCCESSFUL
Total time: 5 seconds

3.ViewModelを作る

Modelの次はViewModelを作っていきます。

  • ViewModelは「UI独自の状態や処理を管理するオブジェクト」です。
    • Modelをラップする形で、ModelとViewの仲立ちをします。
  • Todoリストであれば、以下のような処理がViewModelの担当になります。
    • 最終更新日時をUIで表示する文字列に変換する。
    • Todoの追加ボタンや、完了したTodoを消すボタン、などの処理を実行するメソッドを提供する。
    • 完了したTodoを消すボタンの利用可/不可状態を制御する。(完了したTodoが1つもなければ利用不可)
    • Todoを追加する際にタイトルを設定する口を提供する。
  • UI依存のロジックをViewModelとして分離することで、Modelはより純粋なビジネスロジックだけを提供する層にできます。
    • UIの変更があってもViewModelだけでの変更で済み、Modelには波及しにくくなります。

「UIを抽象化してオブジェクトにしたもの」考えるとわかりやすいかも。イメージ的には以下のような感じ。

UIを抽象化したものなので、 実際のUI操作シナリオに沿った形でテストケースが書けるのもポイントです。

作成するViewModelの一覧

作成するViewModelは以下の3つです。

  • TodoListView
    • Todoの一覧部分に対応するViewModelです。
  • TodoView
    • Todo一覧の中の各Todoに対応するViewModelです。
  • TodoInputForm
    • 新しいTodoを作成する入力フォーム部分に対応するViewModelです。

ViewModelではKnockoutの機能を利用します。

TodoListView
  • items でTodoの一覧を持ちます。
    • itemsはモデルの変更通知を受けて随時更新。
  • UI上のボタンを押下した際の処理をメソッドで提供します。
    • Todoの追加(newTodo),完了したTodoの削除(removeCompleted)
  • enableToRemoveCompleted で完了したTodoの削除ボタンが利用可かどうかを管理します。
define([
  "container",
  "knockout",
  "models/events",
  "viewmodels/todo-view"
], function(ContainerJS, ko, Events, TodoView){
    
    "use strict";
    
    /**
     * @class
     */
    var TodoListView = function() {
        
        this.todoList = ContainerJS.Inject;
        this.todoInputForm = ContainerJS.Inject;
        
        this.items = ko.observableArray();
        
        this.numberOfCompleted = ko.observable(0);
        this.enableToRemoveCompleted = ko.computed(function(){
            return this.numberOfCompleted() > 0;
        }.bind(this));
        
        Object.seal(this);
    };
    
    TodoListView.prototype.initialize = function(){
        this.todoList.addObserver( Events.LOADED,  this.onLoad.bind(this) );
        this.todoList.addObserver( Events.ADDED,   this.onAdded.bind(this) );
        this.todoList.addObserver( Events.REMOVED, this.onRemoved.bind(this) );
    };
    
    
    TodoListView.prototype.newTodo = function() {
        this.todoInputForm.newTodo();
    };
    TodoListView.prototype.removeCompleted = function(event) {
        this.todoList.removeCompleted();
    };
    
    
    TodoListView.prototype.onLoad = function(event) {
        this.items(event.items.map(function(todo){
            this.addObserverToTodo(todo);
            return new TodoView(todo);
        }.bind(this)));
    };
    
    TodoListView.prototype.onAdded = function(event) {
        event.added.forEach( function( todo ) {
            this.addObserverToTodo(todo);
            this.updateNumberOfCompleted( todo.completed ? 1 : 0 );
            this.items.push(new TodoView(todo));
        }.bind(this)); 
    };
    
    TodoListView.prototype.onRemoved = function(event) {
        var removedIds = {};
        event.removed.forEach( function( todo ) {
            this.updateNumberOfCompleted( todo.completed ? -1 : 0 );
            removedIds[todo.id] = true;
        }.bind(this));
        this.items.remove( function(item) {
            return removedIds[item.model.id];
        });
    };
    
    /** @private */
    TodoListView.prototype.addObserverToTodo = function(todo){
        todo.addObserver( Events.UPDATED, this.onTodoUpdated.bind(this));
    };
    
    /** @private */
    TodoListView.prototype.onTodoUpdated = function(event){
       if (event.propertyName === "completed") {
           if (event.newValue !== event.oldValue) {
               this.updateNumberOfCompleted( event.newValue ? 1 : -1 );
           }
       }
    };
    
    /** @private */
    TodoListView.prototype.updateNumberOfCompleted = function(count){
       this.numberOfCompleted( this.numberOfCompleted()+count );
    };
    
    return Object.freeze(TodoListView);
});
TodoView

一覧の各Todoに対応するViewModelです。

  • タイトル(title)や最終更新時刻(lastModified)などを持ちます。
  • Todoの完了操作や、(個別の)削除はTodoごとの操作なので、こちらに定義します。
define([
  "container",
  "knockout",
  "models/events",
  "viewmodels/todo-view"
], function(ContainerJS, ko, Events, TodoView){
    
    "use strict";
    
    /**
     * @class
     */
    var TodoView = function(todo) {
        
        this.model = todo;
        
        this.title        = ko.observable(todo.title);
        this.completed    = ko.observable(todo.completed);
        this.createdAt    = ko.observable(todo.createdAt);
        this.lastModified = ko.observable(todo.lastModified);
        
        this.createdAtForDisplay = ko.computed(function(){
            return formatDate(this.createdAt());
        }.bind(this));
        this.lastModifiedForDisplay = ko.computed(function(){
            return formatDate(this.lastModified());
        }.bind(this));
        
        this.addObservers();
        
        Object.seal(this);
    };
    
    TodoView.prototype.addObservers = function(){
        this.model.addObserver( Events.UPDATED, this.onUpdated.bind(this) );
    };
    
    
    TodoView.prototype.remove = function() {
        this.model.remove();
    };
    TodoView.prototype.complete = function(event) {
        this.model.complete();
    };
    TodoView.prototype.activate = function(event) {
        this.model.activate();
    }
    
    
    TodoView.prototype.onUpdated = function(event) {
        this[event.propertyName](event.newValue);
    };
    
    var formatDate = function(d){
        if (!d) return "";
        return d.getFullYear() 
                + "-" + (d.getMonth() + 1) 
                + "-" + d.getDate()
                + " " + d.getHours()
                + ":" + d.getMinutes()
                + ":" + d.getSeconds();
    };
    
    return Object.freeze(TodoView);
});
TodoInputForm

最後はTodoInputForm。Todoの新規作成フォーム部分に対応するViewModelです。

  • タイトル(title)を受け取る口とエラー情報(error)を持ちます。
    • Todo追加時にタイトルが未入力だったりした場合、エラーメッセージがerrorに入りデータバインディングでUIに表示される仕組み。
  • Todo追加の本処理もこちらに定義しています。
define([
  "container",
  "knockout"
], function(ContainerJS, ko){
    
    "use strict";
    
    /**
     * @class
     */
    var TodoInputForm = function() {
        
        this.todoList = ContainerJS.Inject;
        
        this.title = ko.observable("");
        this.titleLength = ko.computed(function(){
            return this.title().length;
        }.bind(this));
        this.error = ko.observable("");
        
        Object.seal(this);
    };
    
    TodoInputForm.prototype.newTodo = function() {
        try {
            this.todoList.add( this.title() );
            this.title("");
            this.error("");
        } catch ( exception ) {
            this.error(exception.message);
        }
    };

    return Object.freeze(TodoInputForm);
});

ViewModelとModelの生成と関連付けはContainerJSで管理します。ContainerJS用のモジュール定義(composing/modules.js)も用意しておきます。

define(["container"], function( ContainerJS ) {
    
    "use strict";
    
    var models = function( binder ){
        binder.bind("todoList").to("models.TodoList");
        binder.bind("timeSource").to("models.TimeSource");
    };
    
    var viewmodels = function( binder ){
        binder.bind("todoListView").to("viewmodels.TodoListView").onInitialize("initialize");
        binder.bind("todoInputForm").to("viewmodels.TodoInputForm");
    };
    
    var modules = function( binder ){
        models(binder);
        viewmodels(binder);
    };
    
    return modules;
});

4.ViewModelをテストする

ViewModelもテストしていきます。

  • 今回はModelのモックは用意せず、ViewModel+Modelの動きをまとめてテストするようにしました。
    • TimeSourceのみmockを使います。
  • テストでもViewModel,Modelの作成はContainerJSを使って行います。
TodoListView spec
  • Todoの追加・削除が動作することを確認。
  • 追加・削除に応じて一覧のデータが増減することもチェック。
define([
    "container",
    "models/events",
    "test/mock/modules",
    "test/utils/wait"
], function( ContainerJS, Events, modules, Wait ) {
    
    describe('TodoListView', function() {
        
        var container;
        var deferred;
        beforeEach(function() {
            container = new ContainerJS.Container( modules );
            deferred = container.get( "todoListView" );
        });
        
        it( "first, items is empty .", function() {
            
            Wait.forFix(deferred);
            
            runs( function(){
                var view = ContainerJS.utils.Deferred.unpack( deferred );
                view.todoList.load();
                
                expect( view.items().length ).toBe( 0 );
            });
        });
            
        it( "can adds a new todo.", function() {
            
            Wait.forFix(deferred);
            
            runs( function(){
                var view = ContainerJS.utils.Deferred.unpack( deferred );
                view.todoList.load();
                
                view.todoInputForm.title("test");
                view.newTodo();
                view.todoInputForm.title("test2");
                view.newTodo();
                view.todoInputForm.title("test3");
                view.newTodo();
                
                expect( view.items().length ).toBe( 3 );
                expect( view.items()[0].title() ).toBe( "test" );
                expect( view.items()[1].title() ).toBe( "test2" );
                expect( view.items()[2].title() ).toBe( "test3" );
            });
        });
        
        it( "can removes a todo.", function() {
            
            Wait.forFix(deferred);
            
            runs( function(){
                var view = ContainerJS.utils.Deferred.unpack( deferred );
                view.todoList.load();
                
                view.todoInputForm.title("test");
                view.newTodo();
                view.todoInputForm.title("test2");
                view.newTodo();
                view.todoInputForm.title("test3");
                view.newTodo();
                
                expect( view.items().length ).toBe( 3 );
                expect( view.items()[0].title() ).toBe( "test" );
                expect( view.items()[1].title() ).toBe( "test2" );
                expect( view.items()[2].title() ).toBe( "test3" );
                
                view.items()[1].remove();
                
                expect( view.items().length ).toBe( 2 );
                expect( view.items()[0].title() ).toBe( "test" );
                expect( view.items()[1].title() ).toBe( "test3" );
            });
        });

        
        it( "can removes completed todos.", function() {
            
            Wait.forFix(deferred);
            
            runs( function(){
                var view = ContainerJS.utils.Deferred.unpack( deferred );
                view.todoList.load();
                
                expect( view.enableToRemoveCompleted() ).toBe( false );
                
                view.todoInputForm.title("test");
                view.newTodo();
                view.todoInputForm.title("test2");
                view.newTodo();
                view.todoInputForm.title("test3");
                view.newTodo();
                
                expect( view.items().length ).toBe( 3 );
                expect( view.items()[0].title() ).toBe( "test" );
                expect( view.items()[1].title() ).toBe( "test2" );
                expect( view.items()[2].title() ).toBe( "test3" );
                
                expect( view.enableToRemoveCompleted() ).toBe( false );
                
                view.items()[0].complete();
                expect( view.enableToRemoveCompleted() ).toBe( true );
                view.items()[2].complete();
                expect( view.enableToRemoveCompleted() ).toBe( true );
                view.removeCompleted();
                
                expect( view.items().length ).toBe( 1 );
                expect( view.items()[0].title() ).toBe( "test2" );
                
                expect( view.enableToRemoveCompleted() ).toBe( false );
                
                view.items()[0].complete();
                expect( view.enableToRemoveCompleted() ).toBe( true );
                view.items()[0].activate();
                expect( view.enableToRemoveCompleted() ).toBe( false );
            });
        });
        
    });
    
});
TodoView spec
  • Todoモデルのプロパティが正しく反映されていることを確認。
define([
    "models/todo",
    "models/events",
    "viewmodels/todo-view",
    "test/mock/models/time-source"
], function( Todo, Events, TodoView, TimeSource ) {
    
    describe('TodoView', function() {
        
        var timeSource = new TimeSource();
        
        beforeEach(function() {
            timeSource.set(2013, 1, 1);
        });
        
        it( "reflects a model's value.", function() {
            
            var todo = new Todo(timeSource);
            todo.setTitle("test.");
            
            var view = new TodoView(todo);
            {
                expect( view.title() ).toBe( "test." ); 
                expect( view.completed() ).toBe( false );
                expect( view.createdAt() ).toEqual( new Date( 2013, 0, 1 ) );
                expect( view.lastModified() ).toEqual( new Date( 2013, 0, 1 ));
                expect( view.createdAtForDisplay() ).toEqual( "2013-1-1 0:0:0" );
                expect( view.lastModifiedForDisplay() ).toEqual( "2013-1-1 0:0:0" ); 
            }
            
            timeSource.set(2013, 2, 10);
            todo.setTitle("test2.");
            {
                expect( view.title() ).toBe( "test2." ); 
                expect( view.completed() ).toBe( false );
                expect( view.createdAt() ).toEqual( new Date( 2013, 0, 1 ) );
                expect( view.lastModified() ).toEqual( new Date( 2013, 1, 10 ));
                expect( view.createdAtForDisplay() ).toEqual( "2013-1-1 0:0:0" );
                expect( view.lastModifiedForDisplay() ).toEqual( "2013-2-10 0:0:0" ); 
            }
            
            timeSource.set(2013, 2, 12);
            todo.complete();
            {
                expect( view.title() ).toBe( "test2." ); 
                expect( view.completed() ).toBe( true );
                expect( view.createdAt() ).toEqual( new Date( 2013, 0, 1 ) );
                expect( view.lastModified() ).toEqual( new Date( 2013, 1, 12 ));
                expect( view.createdAtForDisplay() ).toEqual( "2013-1-1 0:0:0" );
                expect( view.lastModifiedForDisplay() ).toEqual( "2013-2-12 0:0:0" ); 
            }
            
            timeSource.set(2013, 3, 7);
            todo.activate();
            {
                expect( view.title() ).toBe( "test2." ); 
                expect( view.completed() ).toBe( false );
                expect( view.createdAt() ).toEqual( new Date( 2013, 0, 1 ) );
                expect( view.lastModified() ).toEqual( new Date( 2013, 2, 7 ));
                expect( view.createdAtForDisplay() ).toEqual( "2013-1-1 0:0:0" );
                expect( view.lastModifiedForDisplay() ).toEqual( "2013-3-7 0:0:0" ); 
            }
        });
    });
    
});
TodoInputForm spec
  • タイトルを入れてTodoを追加できる、タイトルが未入力だと追加できずエラーが表示される、といったあたりを確認。
define([
    "container",
    "models/events",
    "test/mock/modules",
    "test/utils/wait"
], function( ContainerJS, Events, modules, Wait ) {
    
    describe('TodoInputForm', function() {
        
        var container;
        beforeEach(function() {
            container = new ContainerJS.Container( modules );
        });
        
        it( "'newTodo' can create a new Todo.", function() {
            
            var deferred = container.get( "todoInputForm" );
            Wait.forFix(deferred);
            
            runs( function(){
                var form = ContainerJS.utils.Deferred.unpack( deferred );
                {
                    expect( form.title() ).toBe( "" ); 
                    expect( form.titleLength() ).toBe( 0 );
                    expect( form.error() ).toBe( "" ); 
                    
                    expect( form.todoList.items.length ).toBe( 0 ); 
                }
                
                form.title("test.");
                {
                    expect( form.title() ).toBe( "test." ); 
                    expect( form.titleLength() ).toBe( 5 ); 
                    expect( form.error() ).toBe( "" ); 
                }
                
                form.newTodo();
                {
                    expect( form.title() ).toBe( "" ); 
                    expect( form.titleLength() ).toBe( 0 );
                    expect( form.error() ).toBe( "" ); 
                    
                    expect( form.todoList.items.length ).toBe( 1 ); 
                    expect( form.todoList.items[0].title ).toBe( "test." ); 
                }
            }); 
        });
        
        it( "'newTodo' fails when the title is invalid.", function() {
            
            var deferred = container.get( "todoInputForm" );
            Wait.forFix(deferred);
            
            runs( function(){
                var form = ContainerJS.utils.Deferred.unpack( deferred );

                form.newTodo();
                {
                    expect( form.title() ).toBe( "" ); 
                    expect( form.titleLength() ).toBe( 0 );
                    expect( form.error() ).toBe( "title is not set." ); 
                    
                    expect( form.todoList.items.length ).toBe( 0 ); 
                }
                
                form.title( createStringOfLength(101) );
                form.newTodo();
                {
                    expect( form.title() ).toBe( createStringOfLength(101) ); 
                    expect( form.titleLength() ).toBe( 101 );
                    expect( form.error() ).toBe( "title is too long." ); 
                    
                    expect( form.todoList.items.length ).toBe( 0 ); 
                }
                
                form.title( createStringOfLength(100) );
                form.newTodo();
                {
                    expect( form.title() ).toBe( "" ); 
                    expect( form.titleLength() ).toBe( 0 );
                    expect( form.error() ).toBe( "" ); 
                    
                    expect( form.todoList.items.length ).toBe( 1 ); 
                    expect( form.todoList.items[0].title ).toBe( createStringOfLength(100) ); 
                }
            }); 
        });
        
        var createStringOfLength = function(length) {
            var str = "";
            for ( var i=length;i>0;i-- ) str += "a";
            return str;
        };
    });
    
});
5.View(HTML/CSS)を作る

ModelとViewModelができたら、Viewを作成して、View/ViewModel/Modelを生成する部分のコードを書いていきます。

まずはHTML。

  • 「data-bind」でViewとViewModelを繋いでいきます。
    • ここにViewModelの○○で保持されるデータを流し込む
    • このボタンが押されたら、ViewModelの▲▲メソッドを実行、といった感じ。
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Todo List</title>
    <script type="text/javascript"
            data-main="../js/main.js" 
            src="../../../../lib/require.js">
    </script>
    <link rel="stylesheet" type="text/css" href="../css/todo-list.css">
  </head>
  <body>
    <div class="todo-input-form">
      title: <input type="text" data-bind="value:todoInputForm.title" class="title-field"></input>
      <button data-bind="click:newTodo" class="add-button">
        add
      </button>
      <div class="error" data-bind="text:todoInputForm.error"></div>
    </div >
    <div class="todo-list" data-bind="foreach:items, visible:true" style="display:none;">
      <div class="todo">
        <img src="../images/normal.png" alt="" data-bind="visible:!completed()" />
        <img src="../images/check.png" alt="checked" data-bind="visible:completed" />
        <span class="title" data-bind="text:title"></span>
        <span class="last-modified" data-bind="text:lastModifiedForDisplay"></span>
        <span class="commands">
          <a href="javascript:void(0)" data-bind="click:activate, visible: completed()">
            activate
          </a>
          <a href="javascript:void(0)" data-bind="click:complete, visible: !completed()">
            complete
          </a>
          <a href="javascript:void(0)" data-bind="click:remove">remove</a>
        </span>
      </div>
    </div>
    <div class="buttons">
      <button data-bind="click: removeCompleted, enable:enableToRemoveCompleted">
         remove completed
      </button>
    </div>
  </body>
</html>

CSSも書きます

@charset "UTF-8";

body {
  padding:10px;
  color: #333;
}
button {
  padding: 3px 15px;
}

.todo-input-form {
  padding: 10px;
  border: 1px solid #ddd;
}
.todo-input-form .title-field {
  width: 200px;
}
.todo-input-form .error {
  color: #fa1188;
}

.todo-list {
  margin-left: 10px;
}

.todo {
  margin-top: 10px;
}
.todo span{
  padding-right: 5px;
}
.todo .title {
  font-size: 110%;
}
.todo .last-modified {
  font-size: 80%;
  color: #999;
}
.todo .commands a {
  padding-right: 5px;
}

.buttons {
  margin-top: 10px;
  padding: 10px;
  border: 1px solid #ddd;
}

最後はmain.js。
main.jsはhtmlで最初に読まれるスクリプトで、以下の処理を行います。

  • DIコンテナを使ってViewModel/Modelを作る
  • KnockoutのAPIを呼び出して、ViewModelとViewの関連付け(データバインド)を実行します。
require.config({
    baseUrl: "../js",
    paths: {
        "container" : "../../../../minified/container",
        "knockout"  : "knockout-2.3.0"
    }
});
require([
  "container",
  "knockout",
  "composing/modules",
  
  "models/time-source",
  "models/todo-list",
  "models/todo",
  "viewmodels/todo-input-form",
  "viewmodels/todo-list-view",
  "viewmodels/todo-view"
], function( ContainerJS, ko, modules ) {
    
    var container = new ContainerJS.Container( modules );
    container.get("todoListView").then(function( viewModel ){
        ko.applyBindings(viewModel);
    }, function( error ) {
        alert( error.toString() ); 
    });
    
});

必要なソースの作成はこれで完了。動いているところはこちらでみられます。

6.minify && 結合してリリース

最後に、ソースをminify && 結合してリリースするAntタスクを定義していきます。

  • minify && 結合はRequire.jsからリリースされているr.jsを使用。
    • Require.jsの依存関係をたどって必要なファイルを集め、1ファイルに統合します。
    • closure等を使用してminifyを行う機能もあります。
  • サンプルでは、main.jsにすべてのファイルを統合する形にしています。
    • リリース版ではmain.jsが統合版に置き換わり、require.jsとmain.jsのみの読み込みとなります。
    • 最初に読むファイルは変わらないので、HTMLの変更は不要。

Antタスクは以下。

<?xml version="1.0" encoding="UTF-8"?>
<project name="todo lists" default="release" basedir=".">

    <property name="test.dir" value="test" />
    <property name="tools.dir" value="../../tools" />
    
    <property name="rhino.home" value="${tools.dir}/rhino1_7R4" />
    <property name="jasmine.home" value="../../lib-test/jasmine-1.0.2" />
    <property name="jasmine-reporters.home" value="../../lib-test/larrymyers-jasmine-reporters-adf6227" />
    <property name="envjs.home" value="../../lib-test/thatcher-env-js-cb738b9" />
    <property name="closure-compiler.home" value="${tools.dir}/compiler-20130603" />
  
    .. 	略

    <target name="minify">
        <java fork='true' dir="${basedir}" classname="org.mozilla.javascript.tools.shell.Main">
            <classpath>
                <path path="${rhino.home}/js.jar" />
                <path path="${closure-compiler.home}/compiler.jar" />
            </classpath>
            <jvmarg line="-Xss1m"/>
            <arg line="-encoding utf-8 ${tools.dir}/r.js -o dist/build-config.json"/>
        </java>
    </target>
    
</project>

build-config.jsonに minify && 結合 の設定を書きます。

({
    appDir: "../src",
    baseUrl: "./js",
    paths: {
        "container" : "../../../../minified/container",
        "knockout"  : "knockout-2.3.0"
    },

    dir: "../dst",
    optimize: "closure",

    closure: {
        CompilerOptions: {},
        CompilationLevel: 'SIMPLE_OPTIMIZATIONS',
        loggingLevel: 'WARNING'
    },

    optimizeCss: "standard.keepLines",

    modules: [{
        name: "main"
    }]
})

タスクを実行すると

$ ant minify

「dst」ディレクトリ以下に最適化されたソースが出力されます。あとはこれをリリースすればOK。
リリース版はこちら最適化前と比べてjsの読み込みが最小化されています。

まとめ

チュートリアルは以上。MVVMでViewとそれ以外を分離すればクライアントアプリもかなりの部分がテストできる、というところが伝われば幸いです。
今回のチュートリアルには入れなかったところで、ダイアログを出すとかはMVVMで作るとちょっと面倒くさい仕掛けが必要だったりしますが、ちゃんとテスタブルに作ることは可能です。(このあたりは機会があればまた今度。)

ちなみに、某プロジェクトで採用したアーキテクチャで、jsは数万行程度の規模ですが、今のところ破綻なく運用できていますよ。