AngularJSでの実際のユニットテスト

AngularJSは、最新のWeb開発に関しては若くてホットです。 HTMLのコンパイルと双方向のデータバインディングに対する独自のアプローチにより、クライアント側のWebアプリケーションを構築するための効果的なツールとなります。 Quick Left(作成者が働いているスタジオ。約Per。)を使用してクライアントの1つにアプリケーションを作成することがわかったとき、私は興奮し、できる限り角度について調べようとしました。 Googleで見つけることができるすべてのレッスンとガイドをインターネットで調べました。 ディレクティブ、テンプレート、コンパイル、およびイベントループ(ダイジェスト)がどのように機能するかを理解するのに非常に役立ちましたが、テストに関しては、このトピックが見落とされていることがわかりました。



TDD(テストによる開発)アプローチを研究しましたが、Red-Green-Refactoringアプローチがなくても安心できます。 私たちはまだAngularテストで何が起こっているのかを把握していたので、チームはテスト後のアプローチに頼らなければならないことがありました。 緊張し始めたので、テストに集中することにしました。 私はそれに数週間を費やし、すぐにテストカバレッジが40%から86%に上昇しました(これをまだ行っていない場合は、JSアプリケーションでコードカバレッジを確認するためにIstabulを試すことができます)。





はじめに



今日、私は学んだことのいくつかを共有したいと思います。 Angularドキュメントと同様に、戦闘アプリケーションのテストは、以下に示す例ほど簡単なことはめったにありません。 何かを機能させるために、私が経験しなければならない落とし穴がたくさんあります。 私は何度も役立ついくつかの回避策を見つけました。 この記事では、それらのいくつかを見ていきます。







この記事は、AngularJSを使用して戦闘アプリケーションを作成する中級および上級の開発者を対象としています。これにより、テストの苦痛を軽減できます。 テストワークフローのセキュリティ感覚が、読者がTDDアプローチの実践とより堅牢なアプリケーションの開発を開始できることを願っています。



テストツール



開発者向けにAngularで利用できる多くのフレームワークとテストツールがあり、おそらくあなたはすでにあなたの好みを持っています。 以下は、私たちが選択し、記事全体で使用するツールのリストです。







テスト用のヘルパーのセットアップ



まず、必要な依存関係を接続するヘルパーを作成します。 ここでは、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つのテストがそれを参照します。





最終的に、次のようなものが得られます。



 // 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を使用する方法を検討しました。







ディレクティブとコントローラーのモック依存関係



最後の例では、$モーダルサービスに依存するサービスがあり、それを使用してdestroy



が実際に呼び出されることを確認しました。 使用した手法は非常に便利で、Angularで単体テストをより正確に動作させることができます。



入場は次のとおりです。





ディレクティブまたはコントローラーは、多くの内部および外部の依存関係に依存する場合があり、それらをすべてロックする必要があります。

より複雑な例を見てみましょう。この例では、ディレクティブが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はまだ若くて成長しているフレームワークです。 どのようなテクニックを使用していますか?



All Articles