Sencha Architect でコンポーネントを実装して Sencha Test で自動テストする

Sencha Architect でコンポーネントを実装して Sencha Test で自動テストするチュートリアルをメモっとく。

この記事では、下記のような本の一覧を管理するアプリケーションを Sencha Architect で実装して Sencha Test で自動テストしてみる。アプリケーションを構成するコンポーネントごとにテストを書いていくので、コンポーネントの独立性を感じられると思う。

<img width="640" alt="Sample Application on Safari" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67627/e145a691-f993-264d-a5d9-2bf8db857703.png">

# 前提条件

この記事では下記バージョンの Sencha ツール群を使用している。

| Tool             | Version   |
|------------------|-----------|
| Sencha Architect | 4.2.7     |
| Sencha Ext JS    | 7.1.0.48  |
| Sencha Cmd       | 7.1.0.16  |
| Sencha Test      | 2.3.0.328 |

下記記事の手順でプロジェクトをすでに作成済みのものとする。

1. [Sencha Architect と Sencha Test で開発環境を構築する](https://qiita.com/takuya0301/items/ca578ae6dae9d625ee8f)
2. [Sencha Architect と Sencha Test の開発環境に REST API モックサーバーを追加する](https://qiita.com/takuya0301/items/0185fe29123bf4ebd66b)

なお各記事で確認用に追加しているアプリケーションとテストのコードは下記のコマンドで Add REST API mock server の時点に戻している。

```shell-session
$ git log --oneline
2a481bd (HEAD -> master) Add REST API mock server
d67e403 Generate test project
e7fd912 Generate main project
e94e6a0 Generate workspace
$ git add .
$ git reset --hard HEAD
HEAD is now at 2a481bd Add REST API mock server
```
# アプリケーションをコンポーネントに分割する

アプリケーションを適切にコンポーネント分割しよう。複雑な UI では、コンポーネント分割が困難な場合がある。その場合、ワイヤーフレームなどの UI 設計に立ち返ってみるとよりユーザーフレンドリーでコンポーネント分割しやすい UI 設計が見えてくることが多い。

今回のアプリケーションは、`books.Main`、`books.Toolbar`、`books.Grid` という3つのコンポーネントに分割している。

![Component Division](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67627/329b979c-b173-f9e2-7f00-ca1f9c0471b8.png)

# ツールバー books.Toolbar を開発する

依存がなくシンプルなツールバー `books.Toolbar` から開発を始めていこう。

## ツールバーの公開インターフェイスを設計する

ツールバーは追加ボタン (Add) と削除ボタン (Delete) があるだけのシンプルなツールバーだ。ただ、それだけだとチュートリアルとして物足りないので、削除ボタンの有効／無効を切り替えられるようにしてみる。グリッドで本が選択されているときだけ、削除ボタンをクリックできるようにするためだ。

それらを踏まえて公開インターフェイスとしてのコンフィグ・メソッド・イベントを設計すると下記のようになる。

### books.Toolbar

| Category | Name                         | Description               |
|----------|------------------------------|---------------------------|
| Config   | deleteButtonDisabled = false | 削除ボタンの有効／無効の初期値 |
| Method   | enableDeleteButton           | 削除ボタンを有効にする       |
|          | disableDeleteButton          | 削除ボタンを無効にする       |
| Event    | addButtonClicked             | 追加ボタンがクリックされた    |
|          | deleteButtonClicked          | 削除ボタンがクリックされた    |

## ツールバーを Sencha Architect で実装する

下記の手順でアプリケーションを実装する。

1. ツールバーになる `Ext.Toolbar` を Views に追加する
2. ツールバーに設定を追加する（`userAlias` のドットを削除するのを忘れるとテストで失敗するので注意すること）
    * userClassName: books.Toolbar
    * userAlias: bookstoolbar
3. ツールバーにコンフィグを追加する
    * deleteButtonDisabled をコンフィグの検索フィールドに入力して Add ボタンをクリックする
    * カスタムコンフィグ左端のアイコンをクリックしてコンフィグのタイプを Boolean に設定する
    * デフォルト値として false を設定する
4. ツールバーのコンフィグを ViewModel に反映させるために `initialize` メソッドを追加する（コンフィグパネルに initialize という項目がある）

    ```javascript
    initialize: function() {
        this.callParent();
        this.getViewModel().set('deleteButtonDisabled', this.getDeleteButtonDisabled());
    }
    ```

5. ツールバーの公開インターフェイスとなるメソッドとイベントを追加する（コンフィグパネルに Functions という項目がある）

    ```javascript
    /* Methods */
    enableDeleteButton: function() {
        this.getViewModel().set('deleteButtonDisabled', false);
    },

    disableDeleteButton: function() {
        this.getViewModel().set('deleteButtonDisabled', true);
    },

    /* Events */
    addButtonClicked: function() {
        this.fireEvent('addButtonClicked');
    },

    deleteButtonClicked: function() {
        this.fireEvent('deleteButtonClicked');
    }
    ```

6. 追加ボタンになる `Ext.Button` を `books.Toolbar` に追加する
7. 追加ボタンに設定を追加する
    * iconCls: x-fa fa-plus
    * text: Add
8. 追加ボタンに `tap` イベントを捕捉するイベントバインディングを `onAddButtonTap` というメソッド名で追加する（コンフィグパネルに View Controller Event Bindings という項目がある）

    ```javascript
    onAddButtonTap: function(button, e, eOpts) {
        this.getView().addButtonClicked();
    }
    ```

9. 削除ボタンになる `Ext.Button` を `books.Toolbar` に追加する
10. 削除ボタンに設定を追加する
    * iconCls: x-fa fa-minus
    * text: Delete
    * disabled: {deleteButtonDisabled} (data binding)
11. 削除ボタンに `tap` イベントを捕捉するイベントバインディングを `onDeleteButtonTap` というメソッド名で追加する（コンフィグパネルに View Controller Event Bindings という項目がある）

    ```javascript
    onDeleteButtonTap: function(button, e, eOpts) {
        this.getView().deleteButtonClicked();
    }
    ```

12. プロジェクトを保存する

## ツールバーを Sencha Test でテストする

下記の手順でテストを実装する。

1. Component シナリオの上位にある ExampleTest の Tests プロジェクトの設定を修正して保存する（ページオブジェクトの紐付けの関係）
    * Location (URL): http://localhost:1841/test/#books.Toolbar
2. Component シナリオに Jasmine テストスイートを books/ToolbarTest.js という名前で追加する
3. テスト実行時にコンポーネントを追加するために `component` 変数と `beforeEach` メソッドと `afterEach` メソッドを追加する

    ```javascript
    let component;

    beforeEach(function () {
        component = Ext.create("Example.view.books.Toolbar");
        Ext.getApplication().viewport.add(component);
    });

    afterEach(function () {
        component.destroy();
    });
    ```

4. Save ボタンをクリックして保存する
5. デフォルトのテストケース内 `expect(1).toBe(1);` の末尾にカーソルを置いて Inspect ボタンをクリックする
6. Inspect ダイアログで Chrome をクリックする
7. Page Object のチェックボックスをチェックして BooksToolbar という名前を入力する
8. Chrome ブラウザーに表示されたコンポーネントをクリックしてページオブジェクトに追加する
    * ツールバー本体
    * 追加ボタン
    * 削除ボタン
9. 下記のように設定して Save Page Object をクリックして保存する

    | Component    | Future Type | Locator               | Name         |
    |--------------|-------------|-----------------------|--------------|
    | bookstoolbar | component   | bookstoolbar          | booksToolbar |
    | button       | button      | button[text="Add"]    | addButton    |
    | button       | button      | button[text="Delete"] | deleteButton |


10. Exit ボタンをクリックして終了する
11. 生成したページオブジェクトを使用するテストを books/ToolbarTest.js に追記する

    ```javascript:books/ToolbarTest.js
    describe("books.Toolbar", function () {
        let component;
    
        describe("when \"Delete\" button is enabled", function () {
            beforeEach(function () {
                component = Ext.create("Example.view.books.Toolbar");
                Ext.getApplication().viewport.add(component);
            });
    
            afterEach(function () {
                component.destroy();
            });
    
            it("has disabled \"Delete\" button after calling \"disableDeleteButtton\" method", function () {
                stpo.BooksToolbar
                    .booksToolbar()
                        .and(toolbar => toolbar.disableDeleteButton());
                stpo.BooksToolbar
                    .deleteButton()
                        .disabled();
            });
    
            it("receives \"addButtonClicked\" event after clicking \"Add\" button", function (done) {
                stpo.BooksToolbar
                    .booksToolbar()
                        .and(toolbar => toolbar.on("addButtonClicked", () => done()));
                stpo.BooksToolbar
                    .addButton()
                        .click();
            });
    
            it("receives \"deleteButtonClicked\" event after clicking \"Delete\" button", function (done) {
                stpo.BooksToolbar
                    .booksToolbar()
                        .and(toolbar => toolbar.on("deleteButtonClicked", () => done()));
                stpo.BooksToolbar
                    .deleteButton()
                        .click();
            });
        });
    
        describe("when \"Delete\" button is disabled", function () {
            beforeEach(function () {
                component = Ext.create("Example.view.books.Toolbar", {
                    deleteButtonDisabled: true
                });
                Ext.getApplication().viewport.add(component);
            });
    
            afterEach(function () {
                component.destroy();
            });
    
            it("has enabled \"Delete\" button after calling \"enableDeleteButtton\" method", function () {
                stpo.BooksToolbar
                    .booksToolbar()
                        .and(toolbar => toolbar.enableDeleteButton());
                stpo.BooksToolbar
                    .deleteButton()
                        .enabled();
            });
    
            it("receives \"addButtonClicked\" event after clicking \"Add\" button", function (done) {
                stpo.BooksToolbar
                    .booksToolbar()
                        .and(toolbar => toolbar.on("addButtonClicked", () => done()));
                stpo.BooksToolbar
                    .addButton()
                        .click();
            });
    
            it("does not receives \"deleteButtonClicked\" event after clicking \"Delete\" button", function () {
                stpo.BooksToolbar
                    .booksToolbar()
                        .and(toolbar => toolbar.on("deleteButtonClicked", () => fail()));
                stpo.BooksToolbar
                    .deleteButton()
                        .click();
            });
        });
    });
    ```

12. Component シナリオで Chrome ブラウザーにチェックを入れ Run ボタンをクリックしてテストを実行する
13. テストがすべて成功したときは Coverage ボタンをクリックしてテストカバレッジが100%になっていることを確認する

# グリッド books.Grid を開発する

さらに、REST API 通信を伴うグリッド `books.Grid` を開発しよう。

## グリッドを設計する

グリッドは本の一覧を管理できるグリッドになる。Sencha Ext JS の一般的なグリッドに行編集プラグインを追加することで要件は満たせる。ただ、このアプリケーションを前提にしたコンポーネントなので、明確に許可されている操作を公開インターフェイスで示すことが望ましい。

それらを踏まえて公開インターフェイスとしてのコンフィグ・メソッド・イベントを設計すると下記のようになる。

### books.Grid

| Category | Name               | Description         |
|----------|--------------------|---------------------|
| Method   | createNewBook      | 新しい本を作成する     |
|          | deleteSelectedBook | 選択された本を削除する |
| Event    | bookSelected       | 本が選択された        |
|          | bookDeselected     | 本の選択が解除された   |

## グリッドを実装する

下記の手順でアプリケーションを実装する。

1. グリッドになる `Ext.grid.Grid` を Views に追加する
2. グリッドに設定を追加する（`userAlias` のドットを削除するのを忘れるとテストで失敗するので注意すること）
    * userClassName: books.Grid
    * userAlias: booksgrid
3. グリッドに行編集プラグインを追加する
4. グリッドの ViewModel に本ストアになる `Ext.data.Store` を追加する
5. 本ストアのモデルになる `Ext.data.Model` を Models に追加する
6. 本モデルに設定を追加する
    * userClassName: Book
7. 本モデルにプロキシになる `Ext.data.proxy.Rest` を追加する
    * url: /books
8. 本モデルに `id` フィールドになる `Ext.data.field.Number` を追加する
    * name: id
9. 本モデルに `title` フィールドになる `Ext.data.field.String` を追加する
    * name: title
10. 本モデルに `author` フィールドになる `Ext.data.field.String` を追加する
    * name: author
11. 本ストアに設定を追加する
    * name: books
    * autoLoad: true
    * autoSync: true
    * model: Example.model.Book
    * data:

        ```javascript
        [
            {
                id: 1,
                title: 'Learning Ext JS - Fourth Edition',
                author: 'Carlos A. Mendez, Crysfel Villa, Armando Gonzalez'
            },
            {
                id: 2,
                title: 'Ext JS Application Development Blueprints',
                author: 'Colin Ramsay'
            },
            {
                id: 3,
                title: 'Ext JS Essentials',
                author: 'Stuart Ashworth, Andrew Duncan'
            },
            {
                id: 4,
                title: 'Mastering Ext JS - Second Edition',
                author: 'Loiane Groner'
            }
        ]
        ```

12. グリッドにストアを設定する
    * store: {books} (data binding)
13. グリッドの公開インターフェイスとなるメソッドとイベントを追加する（コンフィグパネルに Functions という項目がある）

    ```javascript
    /* Methods */
    createNewBook: function() {
        var books = this.getViewModel().getStore('books'),
            book = new Example.model.Book();
        books.insert(0, book);
        this.findPlugin('rowedit').startEdit(book);
    },

    deleteSelectedBook: function() {
        var books = this.getViewModel().getStore('books'),
            book = this.getSelection();
        books.remove(book);
    },

    /* Events */
    bookSelected: function() {
        this.fireEvent('bookSelected');
    },

    bookDeselected: function() {
        this.fireEvent('bookDeselected');
    }
    ```

14. グリッドに `select` イベントを捕捉するイベントバインディングを `onGridSelect` というメソッド名で追加する（コンフィグパネルに View Controller Event Bindings という項目がある）

    ```javascript
    onGridSelect: function(dataview, selected, eOpts) {
        this.getView().bookSelected();
    }
    ```

15. グリッドに `deselect` イベントを捕捉するイベントバインディングを `onGridDeselect` というメソッド名で追加する

    ```javascript
    onGridDeselect: function(dataview, selected, eOpts) {
        this.getView().bookDeselected();
    }
    ```

16. グリッドに Id 列になる `Ext.grid.column.Number` を追加する
    * flex: auto
    * width: (none)
    * dataIndex: id
    * editable: false
    * text: #
    * format: 0
17. グリッドに Title 列になる `Ext.grid.column.Text` を追加する
    * flex: 1
    * width: (none)
    * dataIndex: title
    * editor:

    ```javascript
    {
        xtype: 'textfield',
        name: 'title'
    }
    ```
    * text: Title

18. グリッドに Author 列になる `Ext.grid.column.Text` を追加する
    * flex: 1
    * width: (none)
    * dataIndex: author
    * editor:

    ```javascript
    {
        xtype: 'textfield',
        name: 'author'
    }
    ```
    * text: Author

19. グリッドの不要な列を削除する
20. プロジェクトを保存する

## グリッドをテストする

下記の手順でテストを実装する。

1. Component シナリオの上位にある ExampleTest の Tests プロジェクトの設定を修正して保存する（ページオブジェクトの紐付けの関係）
    * Location (URL): http://localhost:1841/test/#books.Grid
2. Component シナリオに Jasmine テストスイートを books/GridTest.js という名前で追加する
3. テスト実行時にコンポーネントを追加するために `component` 変数と `beforeEach` メソッドと `afterEach` メソッドを追加する

    ```javascript
    let component;

    beforeEach(function () {
        Server.start();
        component = Ext.create("Example.view.books.Grid");
        Ext.getApplication().viewport.add(component);
    });

    afterEach(function () {
        component.destroy();
        Server.stop();
    });
    ```

4. 行編集プラグインが表示する編集パネルもページオブジェクトに含めたいのでデフォルトのテストケース内に処理を追記する

    ```javascript
    it("should pass", function () {
        ST.grid('booksgrid')
            .and(grid => grid.createNewBook());
        expect(1).toBe(1);
    });
    ```

5. Save ボタンをクリックして保存する
6. デフォルトのテストケース内 `expect(1).toBe(1);` の末尾にカーソルを置いて Inspect ボタンをクリックする
7. Inspect ダイアログで Chrome をクリックする
8. Page Object のチェックボックスをチェックして BooksGrid という名前を入力する
9. Chrome ブラウザーに表示されたコンポーネントをクリックしてページオブジェクトに追加する
    * グリッド本体
    * 書名テキストフィールド
    * 著者テキストフィールド
    * 適用ボタン 
10. 下記のように設定して Save Page Object をクリックして保存する

    | Component | Future Type | Locator                  | Name            |
    |-----------|-------------|--------------------------|-----------------|
    | booksgrid | grid        | booksgrid                | booksGrid       |
    | textfield | textField   | textfield[name="title"]  | titleTextField  |
    | textfield | textField   | textfield[name="author"] | authorTextField |
    | button    | button      | button[itemId="ok"]      | okButton        |


11. Exit ボタンをクリックして終了する
12. 生成したページオブジェクトを使用するテストを books/GridTest.js に追記する

    ```javascript:books/GridTest.js
    describe("books.Grid", function () {
        let component;
    
        describe("when \"Books\" grid has no books", function () {
            beforeEach(function () {
                Server.start();
                component = Ext.create('Example.view.books.Grid');
                Ext.getApplication().viewport.add(component);
            });
    
            afterEach(function () {
                component.destroy();
                Server.stop();
            });
    
            it("has a book after calling \"createNewBook\" method and editing its book", function () {
                stpo.BooksGrid
                    .booksGrid()
                        .and(grid => grid.createNewBook());
                stpo.BooksGrid
                    .titleTextField()
                        .setValue("Book 1");
                stpo.BooksGrid
                    .authorTextField()
                        .setValue("Alice");
                stpo.BooksGrid
                    .okButton()
                        .click();
                stpo.BooksGrid
                    .booksGrid()
                        .rowAt(0)
                        .getRecord()
                        .and(row => {
                            const book = row.data.record;
                            expect(book.id).toBe(1);
                            expect(book.title).toBe("Book 1");
                            expect(book.author).toBe("Alice");
                        });
            });
        });
    
        describe("when \"Books\" grid has a book", function () {
            beforeEach(function () {
                Server.start({
                    books: [
                        { id: 1, title: "Book 1", author: "Alice" }
                    ]
                });
                component = Ext.create('Example.view.books.Grid');
                Ext.getApplication().viewport.add(component);
            });
    
            afterEach(function () {
                component.destroy();
                Server.stop();
            });
    
            it("has no books after calling \"deleteSelectedBook\" method with selection", function () {
                stpo.BooksGrid
                    .booksGrid()
                        .rowAt(0)
                        .select();
                stpo.BooksGrid
                    .booksGrid()
                        .and(grid => grid.deleteSelectedBook())
                        .and(grid => {
                            const books = grid.getStore();
                            expect(books.count()).toBe(0);
                        });
            });
    
            it("remains a book after calling \"deleteSelectedBook\" method without selection", function () {
                stpo.BooksGrid
                    .booksGrid()
                        .and(grid => grid.deleteSelectedBook())
                        .and(grid => {
                            const books = grid.getStore();
                            expect(books.count()).toBe(1);
                        });
            });
    
            it("receives \"bookSelected\" event after selecting book", function (done) {
                stpo.BooksGrid
                    .booksGrid()
                        .and(grid => grid.on("bookSelected", () => done()));
                stpo.BooksGrid
                    .booksGrid()
                        .rowAt(0)
                        .select();
            });
    
            it("receives \"bookDeselected\" event after deselecting book", function (done) {
                stpo.BooksGrid
                    .booksGrid()
                        .and(grid => grid.on("bookDeselected", () => done()));
                stpo.BooksGrid
                    .booksGrid()
                        .rowAt(0)
                        .select()
                        .deselect();
            });
        });
    });
    ```

12. Component シナリオで Chrome ブラウザーにチェックを入れ Run ボタンをクリックしてテストを実行する
13. テストがすべて成功したときは Coverage ボタンをクリックしてテストカバレッジが100%になっていることを確認する

# メイン books.Main を開発する

ツールバーとグリッドをまとめるメインを開発しよう。

## メインを設計する

メインはツールバーとグリッドを包含して接続するコンテナーになる。イベントとメソッドをつなぐ部分も簡潔だ。また、結果的に公開インターフェイスとしてのコンフィグ・メソッド・イベントはメインには存在しない。

## メインを Sencha Architect で実装する

下記の手順でアプリケーションを実装する。

1. メインになる `Ext.Panel` を Views に追加する
2. メインに設定を追加する
    * initialView: true 
    * userClassName: books.Main
    * userAlias: booksmain
3. ツールバーになる `books.Toolbar` を `books.Main` にドラッグアンドドロップしてメインにリンクする形で追加する
4. リンクしたツールバーに設定を追加する
    * deleteButtonDisabled: true
    * reference: toolbar
5. グリッドになる `books.Grid` を `books.Main` にドラッグアンドドロップしてメインにリンクする形で追加する
6. リンクしたグリッドに設定を追加する
    * reference: grid
7. リンクしたツールバーに `addButtonClicked` イベントを捕捉するイベントバインディングを `onToolbarAddButtonClicked` というメソッド名で追加する（コンフィグパネルに View Controller Event Bindings という項目がある）

    ```javascript
    onToolbarAddButtonClicked: function(toolbar) {
        this.lookup('grid').createNewBook();
    }
    ```

8. リンクしたツールバーに `deleteButtonClicked` イベントを捕捉するイベントバインディングを `onToolbarDeleteButtonClicked` というメソッド名で追加する

    ```javascript
    onToolbarDeleteButtonClicked: function(toolbar) {
        this.lookup('grid').deleteSelectedBook();
    }
    ```

9. リンクしたグリッドに `bookSelected` イベントを捕捉するイベントバインディングを `onGridBookSelected` というメソッド名で追加する

    ```javascript
    onGridBookSelected: function(grid) {
        this.lookup('toolbar').enableDeleteButton();
    }
    ```

10. リンクしたグリッドに `bookDeselected` イベントを捕捉するイベントバインディングを `onGridBookDeselected` というメソッド名で追加する

    ```javascript
    onGridBookDeselected: function(grid) {
        this.lookup('toolbar').disableDeleteButton();
    }
    ```

11. プロジェクトを保存する

## メインをテストする

メインは公開インターフェイスが存在しない。テストするとなるとツールバーとグリッドを接続している部分になるが、このチュートリアルでは自動テストしないこととする。起動して手動テストしてみてほしい。

もしその部分をテストしたい場合は、テストコードの中で親コンポーネントに対するイベントを発火し、子コンポーネントのメソッド呼び出しをスパイするのがいいはず。なお、現時点でこの方法を検証はしていない。

---

この記事では Sencha Architect でコンポーネントを実装して Sencha Test で自動テストするチュートリアルを説明した。今回は小規模なアプリケーションだが大規模なエンタープライズアプリケーションの開発もできる環境なので、興味のある人はトライアル版で試してみてほしい。