TDD(テストによる開発)アプローチを研究しましたが、Red-Green-Refactoringアプローチがなくても安心できます。 私たちはまだAngularテストで何が起こっているのかを把握していたので、チームはテスト後のアプローチに頼らなければならないことがありました。 緊張し始めたので、テストに集中することにしました。 私はそれに数週間を費やし、すぐにテストカバレッジが40%から86%に上昇しました(これをまだ行っていない場合は、JSアプリケーションでコードカバレッジを確認するためにIstabulを試すことができます)。
はじめに
今日、私は学んだことのいくつかを共有したいと思います。 Angularドキュメントと同様に、戦闘アプリケーションのテストは、以下に示す例ほど簡単なことはめったにありません。 何かを機能させるために、私が経験しなければならない落とし穴がたくさんあります。 私は何度も役立ついくつかの回避策を見つけました。 この記事では、それらのいくつかを見ていきます。
- エンドツーエンド(e2e)テストでのページの再利用
- Promiseを返す関数を使用する
- コントローラーの依存関係とディレクティブのモック
- 子および分離スコープへのアクセス
この記事は、AngularJSを使用して戦闘アプリケーションを作成する中級および上級の開発者を対象としています。これにより、テストの苦痛を軽減できます。 テストワークフローのセキュリティ感覚が、読者がTDDアプローチの実践とより堅牢なアプリケーションの開発を開始できることを願っています。
テストツール
開発者向けにAngularで利用できる多くのフレームワークとテストツールがあり、おそらくあなたはすでにあなたの好みを持っています。 以下は、私たちが選択し、記事全体で使用するツールのリストです。
- Karma :AngularJSチームのテストランチャー。 これを使用して、Chrome、Firefox、およびPhantomJSを起動します。
- AngularMocks : 単体テストでのインジェクションおよびMock Angularサービスのサポートを提供します。
- 分度器 :ブラウザでアプリケーションを実行し、Seleniumを介してアプリケーションと対話するAngularJSの機能テストツール。
- Mocha :テスト用にnode.js用に記述されたフレームワーク。
describe
ブロックをdescribe
し、チェックすることができます。 - Chai :Mochaと統合し、BDDアプローチへのアクセスと、
expect
、should
、およびassert
を記述する機能を提供するアサーションライブラリ。 例では、expect
を使用expect
ます。 - Chai-as-promised :Chai用のプラグインは、promiseを返す関数を操作するときに非常に便利です。 次のように書く機会を与えます:
expect(foo).to.be.fulfilled
またはexpect(foo).to.eventually.equal(bar)
。 - Sinon :スタブおよびモックライブラリ。 これを使用して、ディレクティブとコントローラーに依存関係スタブを作成し、正しい引数で関数呼び出しが行われたことを確認します。
- Browserify :プロジェクト内のファイル間でモジュールを簡単に接続できます。
- 部分化 :HTMLテンプレートをAngularJSディレクティブに直接含めることができます。
- Lodash :標準のJavaScript機能を拡張するパンと砂糖を含むライブラリ。
テスト用のヘルパーのセットアップ
まず、必要な依存関係を接続するヘルパーを作成します。 ここでは、Angular Mocks、Chai、Chai-as-promised、Sinonを使用します
// test/test-helper.js // require('widgetProject'); // require('angular-mocks'); var chai = require('chai'); chai.use('sinon-chai'); chai.use('chai-as-promised'); var sinon = require('sinon'); beforeEach(function() { // this.sinon = sinon.sandbox.create(); }); afterEach(function() { // , this.sinon.restore(); }); module.exports = { rootUrl: 'http://localhost:9000', expect: chai.expect }
はじめに:トップダウンテスト
私は、トップダウンテストスタイルの大提唱者です。 すべては、作成したい機能から始まり、機能を説明する擬似スクリプトを記述して、機能テストを作成します。 このテストを実行すると、エラーで失敗します。 これで、機能テストが機能するために必要なシステムのすべての部分の設計を開始でき、途中でガイドとなる単体テストを使用できます。
たとえば、架空のアプリケーション「ウィジェット」を作成します。このアプリケーションでは、ウィジェットのリストを表示したり、新しいウィジェットを作成したり、現在のウィジェットを編集したりできます。 ここに表示されるコードは、本格的なアプリケーションを構築するには十分ではありませんが、サンプルテストを理解するには十分です。 まず、新しいウィジェットを作成する動作を説明するe2eテストを作成します。
e2eテストでのページの再利用
1ページのアプリケーションで作業する場合、多くのe2eテストに接続できる再利用可能な「ページ」を記述することにより、DRY原則を遵守することは理にかなっています。
Angularプロジェクトでテストを構成する方法は多数あります。 今日は、次の構造を使用します。
widgets-project |-test | | | |-e2e | | |-pages | | | |-unit
pages
フォルダー内で、e2eテストに接続できる
WidgetsPage
関数を作成します。 5つのテストがそれを参照します。
-
widgetRepeater
:ng-repeat
含まれるウィジェットのリスト -
firstWidget
:リスト内の最初のウィジェット -
widgetCreateForm
:ウィジェットを作成するためのフォーム -
widgetCreateNameField
:ウィジェットの名前を入力するためのフィールド -
widgetCreateSubmit
:フォーム送信ボタン
最終的に、次のようなものが得られます。
// test/e2e/pages/widgets-page.js var helpers = require('../../test-helper'); function WidgetsPage() { this.get = function() { browser.get(helpers.rootUrl + '/widgets'); } this.widgetRepeater = by.repeater('widget in widgets'); this.firstWidget = element(this.widgetRepeater.row(0)); this.widgetCreateForm = element(by.css('.widget-create-form')); this.widgetCreateNameField = this.widgetCreateForm.element(by.model('widget.name'); this.widgetCreateSubmit = this.widgetCreateForm.element(by.buttonText('Create'); } module.exports = WidgetsPage
e2eテスト内から、このページに接続して、その要素と対話できるようになりました。 使用方法は次のとおりです。
// e2e/widgets_test.js var helpers = require('../test-helper'); var expect = helpers.expect; var WidgetsPage = require('./pages/widgets-page'); describe('creating widgets', function() { beforeEach(function() { this.page = new WidgetsPage(); this.page.get(); }); it('should create a new widget', function() { expect(this.page.firstWidget).to.be.undefined; expect(this.page.widgetCreateForm.isDisplayed()).to.eventually.be.true; this.page.widgetCreateNameField.sendKeys('New Widget'); this.page.widgetCreateSubmit.click(); expect(this.page.firstWidget.getText()).to.eventually.equal('Name: New Widget'); }); });
ここで何が起こるか見てみましょう。 まず、ヘルパーテストを接続してから、
expect
と
WidgetsPage
します。
beforeEach
、ブラウザーページに読み込みます。 次に、この例では、
WidgetsPage
定義されている要素を使用してページと対話します。 ウィジェットがないことを確認し、フォームに入力して、ウィジェットの1つを「新規ウィジェット」という値で作成し、ページに表示されることを確認します。
ここで、フォームのロジックを再利用可能な「ページ」に分割し、それを繰り返し使用して、たとえばフォーム検証をテストしたり、後で他のディレクティブでテストしたりできます。
Promiseを返す関数を使用する
上記のテストで分度器から取得したアサートメソッドはPromiseを返すので、Chai-as-promisedを使用して、
getText
と
getText
期待どおりの結果を返すことを確認します。
単体テスト内でpromiseオブジェクトを使用することもできます。 既存のウィジェットの編集に使用できるモーダルウィンドウをテストしている例を見てみましょう。 UI Bootstrapの
$modal
サービスを使用します。 ユーザーがモーダルウィンドウを開くと、サービスはpromiseを返します。 ウィンドウをキャンセルまたは保存すると、promiseは解決または拒否されます。
Chai-as-promisedを使用して
save
メソッドと
cancel
メソッドが正しく接続されていることをテストしてみましょう。
// widget-editor-service.js var angular = require('angular'); var _ = require('lodash'); angular.module('widgetProject.widgetEditor').service('widgetEditor', ['$modal', '$q', '$templateCache', function ( $modal, $q, $templateCache ) { return function(widgetObject) { var deferred = $q.defer(); var templateId = _.uniqueId('widgetEditorTemplate'); $templateCache.put(templateId, require('./widget-editor-template.html')); var dialog = $modal({ template: templateId }); dialog.$scope.widget = widgetObject; dialog.$scope.save = function() { // - deferred.resolve(); dialog.destroy(); }); dialog.$scope.cancel = function() { deferred.reject(); dialog.destroy(); }); return deferred.promise; }; }]);
サービスは、ウィジェット編集テンプレートをテンプレートキャッシュ、ウィジェット自体にロードし、ユーザーが編集フォームを拒否または保存するかどうかに応じて、許可または拒否される遅延オブジェクトを作成します。
このようなものをテストする方法は次のとおりです。
// test/unit/widget-editor-directive_test.js var angular = require('angular'); var helpers = require('../test_helper'); var expect = helpers.expect; describe('widget storage service', function() { beforeEach(function() { var self = this; self.modal = function() { return { $scope: {}, destroy: self.sinon.stub() } } angular.mock.module('widgetProject.widgetEditor', { $modal: self.modal }); }); it('should persist changes when the user saves', function(done) { var self = this; angular.mock.inject(['widgetModal', '$rootScope', function(widgetModal, $rootScope) { var widget = { name: 'Widget' }; var promise = widgetModal(widget); self.modal.$scope.save(); // expect(self.modal.destroy).to.have.been.called; expect(promise).to.be.fulfilled.and.notify(done); st $rootScope.$digest(); }]); }); it('should not save when the user cancels', function(done) { var self = this; angular.mock.inject(['widgetModal', '$rootScope', function(widgetModal, $rootScope) { var widget = { name: 'Widget' }; var promise = widgetModal(widget); self.modal.$scope.cancel(); expect(self.modal.destroy).to.have.been.called; expect(promise).to.be.rejected.and.notify(done); $rootScope.$digest(); }]); }); });
ウィジェット編集テストでモーダルウィンドウを返すプロミスの複雑さに対処するために、いくつかのことができます。
beforeEach
関数の
$modal
サービスからモックを作成し、関数の出力を空の
$scope
オブジェクトに置き換えて、
destroy
呼び出しをスタブ化します。
angular.mock.module
では、Angular Mocksが実際の
$modal
サービスの代わりにモーダルウィンドウを使用できるように、モーダルウィンドウのコピーを渡します。 このアプローチは、スタブの依存関係に非常に役立ちます。
2つの例がありますが、編集ウィジェットから返されるpromiseの結果が完了するまで誰もが待つ必要があります。 この点で、
done
をパラメータとしてサンプルに渡し、テストが完了したら
done
を渡す必要があります。
テストでは、Angular Mocksを再度使用して、ウィジェットをモーダルウィンドウと、AngularJSの
$rootScope
サービスに挿入します。
$rootScope
を使用すると、
$digest
ループを呼び出すことができます。 各テストでは、モーダルウィンドウを読み込み、キャンセルまたは有効にし、Chai-as-expectedを使用して、promiseが
rejected
または
resolved
として返されたかどうかを確認します。 promiseと
destroy
の実際の呼び出しでは、
$digest
を開始する必要があるため、各assertブロックの終わりに呼び出されます。
次のアサート呼び出しを使用して、e2eと単体テストの両方のケースでpromiseを使用する方法を検討しました。
-
expect(foo).to.eventually.equal(bar)
-
expect(foo).to.be.fulfilled
-
expect(foo).to.be.rejected
ディレクティブとコントローラーのモック依存関係
最後の例では、$モーダルサービスに依存するサービスがあり、それを使用して
destroy
が実際に呼び出されることを確認しました。 使用した手法は非常に便利で、Angularで単体テストをより正確に動作させることができます。
入場は次のとおりです。
-
beforeEach
ブロックでvar self = this
割り当てます。 - コピーおよびマウントメソッドを作成し、それらを
self
オブジェクトプロパティにします。
self.dependency = { dependencyMethod: self.sinon.stub() }
- テスト対象のモジュールにコピーを転送します。
angular.mock.module('mymodule', { dependency: self.dependecy, otherDependency: self.otherDependency });
- テストケースのロック方法を確認します。 より良いカバレッジのために、
expect(foo).to.have.been.called.withArgs
使用して、expect(foo).to.have.been.called.withArgs
する引数を渡すことができます。
ディレクティブまたはコントローラーは、多くの内部および外部の依存関係に依存する場合があり、それらをすべてロックする必要があります。
より複雑な例を見てみましょう。この例では、ディレクティブが
widgetStorage
サービスを監視し、コレクションが変更されたときに環境内のウィジェットを更新します。 また、
widgetEditor
に作成した
widgetEditor
を開く
edit
メソッドもあり
edit
。
// widget-viewer-directive.js var angular = require('angular'); angular.module('widgetProject.widgetViewer').directive('widgetViewer', ['widgetStorage', 'widgetEditor', function( widgetStorage, widgetEditor ) { return { restrict: 'E', template: require('./widget-viewer-template.html'), link: function($scope, $element, $attributes) { $scope.$watch(function() { return widgetStorage.notify; }, function(widgets) { $scope.widgets = widgets; }); $scope.edit = function(widget) { widgetEditor(widget); }); } }; }]);
以下は、
widgetStorage
および
widgetEditor
ロックして、このようなものをテストする方法です。
// test/unit/widget-viewer-directive_test.js var angular = require('angular'); var helpers = require('../test_helper'); var expect = helpers.expect; describe('widget viewer directive', function() { beforeEach(function() { var self = this; self.widgetStorage = { notify: self.sinon.stub() }; self.widgetEditor = self.sinon.stub(); angular.mock.module('widgetProject.widgetViewer', { widgetStorage: self.widgetStorage, widgetEditor: self.widgetEditor }); }); // ... });
子会社および分離スコープへのアクセス
場合によっては、内部に分離スコープまたは子スコープを持つディレクティブを作成する必要があります。 たとえば、 Angular Strapの
$dropdown
サービスを使用すると、分離されたスコープが作成されます。 このスコープにアクセスするのは非常に骨の折れる作業です。 ただし、
self.element.isolateScope()
知って
self.element.isolateScope()
これを修正できます。 以下は、孤立したスコープを作成する
$dropdown
の使用例です。
// nested-widget-directive.js var angular = require('angular'); angular.module('widgetSidebar.nestedWidget').directive('nestedSidebar', ['$dropdown', 'widgetStorage', 'widgetEditor', function( $dropdown, widgetStorage, widgetEditor ) { return { restrict: 'E', template: require('./widget-sidebar-template.html'), scope: { widget: '=' }, link: function($scope, $element, $attributes) { $scope.actions = [{ text: 'Edit', click: 'edit()' }, { text: 'Delete', click: 'delete()' }] $scope.edit = function() { widgetEditor($scope.widget); }); $scope.delete = function() { widgetStorage.destroy($scope.widget); }); } }; }]);
ディレクティブがウィジェットのコレクションを持つ親ディレクティブからウィジェットを継承すると仮定すると、子スコープへのアクセスは、プロパティが期待どおりに変更されたかどうかを確認するのが非常に困難です。 しかし、それはできます。 見てみましょう:
// test/unit/nested-widget-directive_test.js var angular = require('angular'); var helpers = require('../test_helper'); var expect = helpers.expect; describe('nested widget directive', function() { beforeEach(function() { var self = this; self.widgetStorage = { destroy: self.sinon.stub() }; self.widgetEditor = self.sinon.stub(); angular.mock.module('widgetProject.widgetViewer', { widgetStorage: self.widgetStorage, widgetEditor: self.widgetEditor }); angular.mock.inject(['$rootScope', '$compile', '$controller', function($rootScope, $compile, $controller) { self.parentScope = $rootScope.new(); self.childScope = $rootScope.new(); self.compile = function() { self.childScope.widget = { id: 1, name: 'widget1' }; self.parentElement = $compile('<widget-organizer></widget-organizer>')(self.parentScope); self.parentScope.$digest(); self.childElement = angular.element('<nested-widget widget="widget"></nested-widget>'); self.parentElement.append(self.childElement); self.element = $compile(self.childElement)(self.childScope); self.childScope.$digest(); }]); }); self.compile(); self.isolateScope = self.element.isolateScope(); }); it('edits the widget', function() { var self = this; self.isolateScope.edit(); self.rootScope.$digest(); expect(self.widgetEditor).to.have.been.calledWith(self.childScope.widget); });
狂気ですね。 まず、
widgetStorage
と
widgetEditor
を再びウェットにしてから、
compile
関数の
compile
を開始します。 この関数は、スコープの2つのインスタンス
parentScope
と
childScope
を作成し、ウィジェットをスナップして子スコープに配置します。 次に、
compile
はスコープ設定と複雑なテンプレートを実行します。最初に、親スコープが渡される
widget-organizer
親要素をコンパイルし
widget-organizer
。 これがすべて完了したら、それに
nested-widget
子要素を追加し、子スコープを渡して、最後に
$digest
実行します。
結論として、
compile
関数を呼び出し、
self.element.isolateScope()
介してテンプレートのコンパイルされた分離スコープ(
$dropdown
self.element.isolateScope()
からのスコープ)にポップできます。 テストの最後に、隔離されたスコープに入って
edit
を呼び出し、最後にwidgetEditorがウィジェットとともに呼び出されることを確認します。
おわりに
テストには苦痛が伴います。 私たちのプロジェクトですべての方法を理解するのが非常に苦痛で、コードの作成に戻り、「クリックテスト」を実行してテストする誘惑だったいくつかのケースを覚えています。 残念ながら、このプロセスを終了すると、不確実性の感覚は増大します。
複雑なケースの処理方法を理解するために時間をかけた後、そのようなケースが再び発生するタイミングを理解するのがはるかに簡単になりました。 この記事で説明した手法を使用して、TDDプロセスに参加し、自信を持って前進することができました。
今日私たちが見たテクニックがあなたの毎日の練習に役立つことを願っています。 AngularJSはまだ若くて成長しているフレームワークです。 どのようなテクニックを使用していますか?