AngularJSでネストしたコントローラをテストする
前提
AngularJSでコントローラをネストして階層化すると、子コントローラ側から親コントローラのスコープを利用可能になる。
問題
子コントローラのユニットテストを書こうとした場合に、子コントローラ側で親コントローラのプロパティやメソッドを使用していると、そのままではテストが書けない。
解決
以下のように記述した。
ReportShowCtrlが親コントローラ、CommentNewCtrlが子コントローラ。
# comment_new_spec.coffee "use strict" describe "Controller: CommentNewCtrl", -> beforeEach module "myApp" CommentNewCtrl = {} scope = {} Comment = {} beforeEach inject ($controller, $rootScope) -> $controller("ReportShowCtrl", { $scope: $rootScope report: {id: 1} }) scope = $rootScope.$new() CommentNewCtrl = $controller "CommentNewCtrl", { $scope: scope } it "some test", -> # do something
そもそも論としてネストしたコントローラで、親のプロパティやメソッドが〜、というのは可読性やテスタビリティが下がるのでアレではある。
派生したスコープでのハマリもあるし。
AngularJSのフォームで投稿後に再利用する際手付かずの状態に戻したい
例えば記事へのコメント投稿後に、そのフォームを再利用する際に、データを初期化しただけだとエラー表示が出てしまう。
ので、$dirtyがfalse($pristineがtrue)の状態に戻したい。
いわゆるブログのコメント投稿とかのよくあるやつ。
form.FormControllerの$setPristine()を使う。
# comment_new.html <div ng-controller="CommentNewCtrl"> <form name="commentNewForm"> <div class="form-group" ng-class="{'has-error': commentNewForm.content.$dirty && commentNewForm.content.$invalid }"> <textarea class="form-control" ng-model="comment.content" placeholder="" tabindex="1" name="content" required></textarea> <span ng-show="commentNewForm.content.$dirty&&commentNewForm.content.$error.required"> 必須です </span> </div> <button ng-disabled="!commentNewForm.$valid" ng-click="save()">コメントを投稿</button> </form> </div>
# comment_new.coffee angular.module('myApp') .controller 'CommentNewCtrl', ($scope, Comment) -> # ......................... $scope.save = -> $scope.comment.$create((data)-> # ......................... $scope.comment = new Comment(report_id: reportId) #<- 初期化 $scope.commentNewForm.$setPristine() # <- コレ )
OK.
RailsのResourcesでNestしてshallow: trueしたresourcesをAngularJSの$resourceで扱う
例えばこういう感じのresourcesを定義したとする。
APIの例。newとeditは省いた。
# routes.rb namespace :api, defaults: { format: :json } do resources :reports, shallow: true do resources :comments, except: [:new, :edit] end end
定義されるルーティングはこうなる。reportsのは話に関係ないので省略
GET /api/reports/:report_id/comments(.:format) api/comments#index {:format=>:json} POST /api/reports/:report_id/comments(.:format) api/comments#create {:format=>:json} GET /api/comments/:id(.:format) api/comments#show {:format=>:json} PATCH /api/comments/:id(.:format) api/comments#update {:format=>:json} PUT /api/comments/:id(.:format) api/comments#update {:format=>:json} DELETE /api/comments/:id(.:format) api/comments#destroy {:format=>:json}
Railsではよくあるパターンだと思う。
このコメントのresourceをAngularJSの$resourceで使いたい。
$resourceでよく見かけるサンプルだとシンプルなものが多い.
こういったことは出来ないんだろうかと、AngularJSのリファレンスで$resourceの項目を見てみたら、ちゃんとやり方が書いてあった。
url – {string} – action specific url override. The url templating is supported just like for the resource-level urls.
$resourceの第3引数のactionsでurlをoverrideすればよいと。
やってみた。
下記はqueryとかsaveとか自動のやつは無視してRails的なactionを定義
# Comment.coffee 'use strict' angular.module('myApp') .factory 'Comment', ($resource) -> $resource("/api/comments/:commentId", {commentId: "@id", reportId: "@report_id"}, { index: {method: "GET", url: "/api/reports/:reportId/comments", isArray: true} create: {method: "POST", url: "/api/reports/:reportId/comments"} update: {method: "PATCH",params: {commentId: "@id"}} show: { method: "GET",params: {commentId: "@id"}} destroy: {method: "DELETE",params: {commentId: "@id"}} })
使う例
# sample.coffee angular.module('myApp') .controller "SampleCtrl", ($scope, $stateParams, Comment) -> $scope.comments = Comment.index(reportId: $stateParams.reportId) $scope.comment = new Comment(report_id: $stateParams.reportId) $scope.save = -> $scope.comment.$create((comment)-> # do something
上記の場合だと、report_idとreportIdでもにょってしまうので、サービス側でもう少しラップしてオブジェクトの引数ではなく、パラメータで渡した方が幸せになれるかもしれない。
AngularJSで$sceを使ってるフィルタなんかのテスト
$sceをDIしてるやつ、例えばこちらのようなののテストを書く場合。
# newlines_spec.coffee "use strict" describe "Filter: newlines", -> beforeEach module "myApp" newlines = {} beforeEach inject ($filter) -> newlines = $filter "newlines" it "\nを<br />に変換して返すこと", -> text = "angularjs\nnewlines" expect(newlines(text)).toBe ("angularjs<br />newlines")
単純にこんな感じに書くと、下記のようなメッセージで失敗する
Expected { $$unwrapTrustedValue : Function } to be 'angularjs<br />newlines'.
ので、$$unwrapTrustedValueでexpectしてみた。
# newlines_spec.coffee it "\nを<br />に変換して返すこと", -> text = "angularjs\nnewlines" expect(newlines(text).$$unwrapTrustedValue()).toBe ("angularjs<br />newlines")
テスト通ったけど、$sceProviderをテストの時は無効化するとか、他のやり方のほうがいいんだろうか。
beforeEach module 'myApp',($sceProvider)-> $sceProvider.enabled(false) return
RailsでつくるAPIサーバのドキュメントを自動生成してくれるAutodocを使っていて気をつけること
Autodocとはなんぞやという方はこちらの記事を参照。 公式はこちら
で、大変便利なAutodocだけど、注意点が2つ。
まず一点目はRspc3で動かない点。プルリクが上がってるが、取り込まれてない。
なので、forkして使ってる人が結構いる感じ。
2点目、通信が発生しているところでマークダウンが生成されるので、shared_exampleとか使って、そこで実際の通信が発生するようなものは、ドキュメントが全てshared_exampleがあるファイル名で生成されてしまう。
具体的に言うと、例えばこちらの記事のようなヘルパーメソッドを用意して、これでAUTODOC=1でAPIのテストを全て行った場合、shared_exmpleを呼び出している個別のspecファイルではなく、doc/support/api_helper.mdというドキュメントが生成されてしまう。
こうなるのは仕方がない気がするので、autodocを使ってドキュメントを生成する場合は、Autodocで記録する個別のexampleの中でgetやpostを投げる感じで。
AngularJSのProtractorでngMockE2Eの$httpBackentを使ってテストを書く際に気をつけること
まだ理解が浅い模様。
注意というかexpectとwhenの違いの話。
前提
- AngularJSにはngMockE2EというE2E用のモックが用意されており、こちらを使用することにより、サーバを用意しなくてもモックでテストが書ける。
* ngMockE2Eの$httpBackendにはngMockの$httpBackendと違って、expect系はなくwhen系のメソッドしかない
問題
よくある入力フォームのE2EテストをAPIの呼び出し部分にモックを使って書いていて、正常な登録と、エラー表示をまとめてテストしようとして、うまくいかなかった。
# sign_in_spec.coffee "use strict" describe "ユーザ登録画面", -> beforeEach -> browser.addMockModule("httpBackendMock", -> angular.module("httpBackendMock", ["ngMockE2E"]) .run(($httpBackend) -> $httpBackend.whenPOST("/api/sign_in", { user: email: "test@example.com" password: "password" password_confirmation: "password" } ).respond(201, { "user": "id": 480 }) $httpBackend.whenPOST("/api/sign_in", { user: email: "test@example.com" password: "password" password_confirmation: "12345678" } ).respond(422, { "status": 422, "messages": [ "パスワード再確認とパスワードの入力が一致しません。" ] }) $httpBackend.when("GET", /.*/).passThrough() ) ) beforeEach -> browser.get("/sign_in") it "エラー確認、登録後、別画面に遷移", -> email = element(By.model("registration.email")) password = element(By.model("registration.password")) password_confirmation = element(By.model("registration.password_confirmation")) button = element(By.tagName("button")) email.sendKeys("test") error = element(By.css(".form-group span:not([class=ng-hide])")).getText() expect(error).toBe "メールアドレスを入力してください" email.clear() error = element(By.css(".form-group span:not([class=ng-hide])")).getText() expect(error).toBe "メールアドレスは必須です" email.sendKeys("test@example.com") # .....省略....... password_confirmation.sendKeys("12345678") # この時点では、passwordとpassword_confirmationが違う事により失敗のメッセージのレスポンスを期待する button.click() expect(errorElement.getText())toBe "パスワード再確認とパスワードの入力が一致しません。" # .....省略....... # 正常系 button.click() expect(browser.getLocationAbsUrl()).toMatch "#/other_page"
api/sign_inへのPOSTのバックエンド定義を2つ書いている。
ユーザパラメータの違いにより、使われるモックが変わることを期待しているが、片方しか呼ばれない。
これは考えてみれば当たり前で、whenはexpectと違って、定義されたリクエストが行われた場合に用意されたレスポンスを返し、リクエストを厳密にアサートしないバックエンドを定義するものだから。
パラメータやヘッダーなど、このようにリクエストしてほしいというアサーションを期待するのはexpect系を使わないと書けない。
ちなみに $httpBackend.when("GET", /.*/).passThrough()で、モック以外の通信(この場合、静的なHTMLの取得)を全てサーバと通信するようにしている。
解決
ngMockE2Eの$httpBackendにはexpectがないので、
正常系と失敗系でdescribeを分けて、それぞれにmockを定義してテストを行った。
# sign_in_spec.coffee "use strict" describe "ユーザ登録画面", -> beforeEach -> browser.addMockModule("httpBackendMock", -> angular.module("httpBackendMock", ["ngMockE2E"]) .run(($httpBackend) -> $httpBackend.whenPOST("/api/sign_in", { user: email: "test@example.com" password: "password" password_confirmation: "password" } ).respond(201, { "user": "id": 480 }) $httpBackend.when("GET", /.*/).passThrough() ) ) beforeEach -> browser.get("/sign_up") it "登録後、別画面に遷移", -> # test code here describe "ユーザ登録画面error", -> beforeEach -> browser.addMockModule("httpBackendMock", -> angular.module("httpBackendMock", ["ngMockE2E"]) .run(($httpBackend) -> $httpBackend.whenPOST("/api/sign_in", { user: email: "test@example.com" password: "password" password_confirmation: "12345678" } ).respond(422, { "status": 422, "messages": [ "パスワードを再入力とパスワードの入力が一致しません。" ] }) $httpBackend.when("GET", /.*/).passThrough() ) ) beforeEach -> browser.get("/sign_up") it "エラー表示確認", -> # test code here
expect系が用意されていないのは、統合テストという性格上、実際にテスト用のAPIサーバを用意して実際の挙動でテストするのを推奨していて、モックは限定的に使えという意味合いなのかなと思った。
今回、APIサーバはRailsだが、RailsにAngularJSを載せるのではなく、AngularJSとRailsの役割を完全に切り離して開発しているので、E2Eテストでどうしようかなと模索中。 実際にrails serverをテスト環境で動かしてproxyでつなぐのがいいかなと思いつつ、DBデータのテスト事の処理をどうしようかと。 なにかいい方法を知っている方がいたら教えていただきたい感じ。