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データのテスト事の処理をどうしようかと。 なにかいい方法を知っている方がいたら教えていただきたい感じ。

AngularJSでInterceptorでHeaderを付与するようなケースのテスト

例えば$httpのリクエストをインターセプトして、Authorizationヘッダーにトークンを付与するInterceptorを作成したとする。

この場合、このInterceptorのサービスをどのようにテストするのがよいのか?

で、調べたら大変素晴らしい記事があったのでそちらを参考にテストを書いた。

トークンを管理しているサービス、Authorizationヘッダーを付与するInterceptorサービス、モックの$httpBackend,あとはInterceptorが登録されているはずの $httpProviderを読み込んでる。

# auth_interceptor_spec.coffee
'use strict'

describe 'Service: AuthInterceptor', ->
  $httpProvider = {}
  beforeEach module 'gambaApp', (_$httpProvider_)->
    $httpProvider = _$httpProvider_
    return

  AuthInterceptor = {}
  AuthToken = {}
  $httpBackend = {}
  beforeEach inject (_AuthInterceptor_, _AuthToken_, _$httpBackend_) ->
    $httpBackend = _$httpBackend_
    AuthInterceptor = _AuthInterceptor_
    AuthToken = _AuthToken_
  
  afterEach inject (_AuthToken_) ->
    _AuthToken_.clear()

  token = 'SecureToken'
  tokenString = "Token token=\"#{token}\""

  it "AuthInterceptorが定義されていること", ->
    expect(AuthInterceptor).toBeDefined()

  it "AuthInterceptorがインターセプターに登録されていること", ->
    expect($httpProvider.interceptors).toContain "AuthInterceptor"

  it 'トークンがセットされていない場合Authorizationがセットされていないこと', ->
    config = AuthInterceptor.request({headers: {}})
    expect(config.headers.Authorization).toBeUndefined()

  it 'トークンが保持されている場合setting後Authorizationにセットされること', ->
    AuthToken.setToken(token)
    config = AuthInterceptor.request({})
    expect(config.headers.Authorization).toBe tokenString

  it 'トークンが保持されて通信されるときはAuthorizationにセットされていること', ->
    AuthToken.setToken(token)
    $httpBackend.whenGET('/api/users', (headers)->
      expect(headers.Authorization).toBe tokenString
    )

参考

UI-Bootstrapのmodalのテストで、$modalInstanceを使うControllerをテストしようとして、$modalInstanceをinjectしようとするところでUnknown providerとエラーが出る

公式のサンプルでいうと、ModalInstanceCtrl部分のテスト。

// modal-demo.js
var ModalDemoCtrl = function ($scope, $modal, $log) {

  $scope.items = ['item1', 'item2', 'item3'];

  $scope.open = function (size) {

    var modalInstance = $modal.open({
      templateUrl: 'myModalContent.html',
      controller: ModalInstanceCtrl,
      size: size,
      resolve: {
        items: function () {
          return $scope.items;
        }
      }
    });

    modalInstance.result.then(function (selectedItem) {
      $scope.selected = selectedItem;
    }, function () {
      $log.info('Modal dismissed at: ' + new Date());
    });
  };
};

// Please note that $modalInstance represents a modal window (instance) dependency.
// It is not the same as the $modal service used above.

var ModalInstanceCtrl = function ($scope, $modalInstance, items) {

  $scope.items = items;
  $scope.selected = {
    item: $scope.items[0]
  };

  $scope.ok = function () {
    $modalInstance.close($scope.selected.item);
  };

  $scope.cancel = function () {
    $modalInstance.dismiss('cancel');
  };
};

beforeEachで$modalInstanceをinjectしようとしているところで

#modal_instance_spec.coffee
"use strict"

describe "Controller: ModalInstanceCtrl", ->

  # load the controller"s module
  beforeEach module "myApp"

  ModalInstanceCtrl = {}
  scope = {}
  modalInstance = {}
  httpBackend = {}
  
  # Initialize the controller and a mock scope
  beforeEach inject ($controller, $rootScope, _$httpBackend_,_$modalInstance_) ->
    scope = $rootScope.$new()

こんなエラー

Error: [$injector:unpr] Unknown provider: $modalInstanceProvider <- $modalInstance

こんなときはmodalInstanceをmockにしてしまえばOK.

#modal_instance_spec.coffee
"use strict"

describe "Controller: ModalInstanceCtrl", ->

  # load the controller"s module
  beforeEach module "myApp"

  ModalInstanceCtrl = {}
  scope = {}
  modalInstance = {}
  httpBackend = {}
  
  # Initialize the controller and a mock scope
  beforeEach inject ($controller, $rootScope, _$httpBackend_) ->
    scope = $rootScope.$new()
    httpBackend = _$httpBackend_
    modalInstance =
      close: jasmine.createSpy("modalInstance.close")
      dismiss: jasmine.createSpy("modalInstance.dismiss")
      result: 
        then: jasmine.createSpy('modalInstance.result.then')

    ModalInstanceCtrl = $controller "ModalInstanceCtrl", {
      $scope: scope
      $modalInstance: modalInstance
    }

  it "cancelを呼ぶと、modalをdismissすること", ->
    scope.cancel()
    expect(modalInstance.dismiss).toHaveBeenCalled()
#........

参考というかそのまま

Rails4をJSON APIとして構築していてCreateのAPIに関連のID一覧をparameterとしてPOSTしてはまった

前提

1.Railsのwrap_parametersはJSONRailsへ送った時にルート要素を省いてくれる。というか、省いて送ってもよしなにwrapしてくれる。

Railsのwrap_parametersは何をしてくれるのか?

2.has_manyを定義するとrelation_ids,relation_ids=というメソッドが使えるようになる

#group.rb
Class Group
  has_many :groups_users
  has_many :users, through: :groups_users
end
#user.rb
class User
  has_many :groups_users
  has_many :groups, through: :groups_users
end
#irb
group = Group.first
group.user_ids
# =>[1,2,3]
group.user_ids=[2,3,4]

3.Railsのstrong_parametersは許可されたパラメータ以外を取り除く

#groups_controller.rb
class GroupsController < ApplicationController
  def create
    @group = Group.new(group_params)
    # 省略
  end

  private
  def group_params
    params.require(:group).permit(:name, user_ids: [])
  end
end

問題

このgroups#createに対して以下のようなJSONを送付したところ、groupモデルは作成されたが、userとの関連は作成されなかった。

// json
{
  name: "新規グループ",
  user_ids: [1,2,3]
}

user_idsがstrong_parametesに書いたにもかかわらず弾かれる。

実際のparamsを見るとこんな感じ

 Parameters: {"name"=>"新規グループ", "user_ids"=>[1,2,3], "group"=>{"name"=>"新規グループ"}}

調べるとどうもnested_attributes_forも引っかかるようだ。

解決

strong_paramtersのREADMEに書いてあった。

以下のようにrequire(:group)を取り除く

# groups_controller.rb
class GroupsController < ApplicationController
  # 省略

  private
  def group_params
    params.permit(:name, user_ids: [])
  end
end

これで、wrap_parametersもきき、groupとuserの関連も作成される。

grunt-wiredep使用の環境でjquery-uiのdatepickerのロケールとCSSを含みたい

grunt-wiredep使用の環境でAngularJSのui-dateロケール指定したい&jquery-uiのCSSを当てたいと。

bower.jsonに追加してbower install

bower.json

{
  "dependencies": {
    "angular-ui-date": "latest"
  }
}

ui-dateがjquery-uiに依存しているので、jquery-uiも入る。

jquery-uiの構成を確認すると、ロケールはui/i18n/datepicker-ja.jsに、CSSのテーマはthemes/にある。

なので、プロジェクトのbower.jsonでmainをoverrideして、ロケールファイルとCSSを含める。

下記はsmoothnessにしてみた例。

bower.json

 "jquery-ui": {
      "main": [
        "jquery-ui.js",
        "ui/i18n/datepicker-ja.js",
        "themes/smoothness/jquery-ui.css"
      ]
    }

grunt serveとか実行するとこうなる↓

<!-- index.html -->

  <!-- 省略 -->

  <!-- build:css(.) styles/vendor.css -->
  <!-- bower:css -->
  <link rel="stylesheet" href="bower_components/jquery-ui/themes/smoothness/jquery-ui.css" />
  <!-- endbower -->
  <!-- endbuild -->

  <!-- 省略 -->

  <!-- build:js(.) scripts/vendor.js -->
  <!-- bower:js -->

  <!-- 省略 -->
  <script src="bower_components/jquery-ui/jquery-ui.js"></script>
  <script src="bower_components/jquery-ui/ui/i18n/datepicker-ja.js"></script>
  <script src="bower_components/angular-ui-date/src/date.js"></script>
  <!-- endbower -->
  <!-- endbuild -->

あとはrunあたりで設定でOK

#app.coffee
myapp.run(->
  $.datepicker.setDefaults($.datepicker.regional[ "ja" ])
)

buildして製品用に吐き出す場合に、cssの読むimageも処理しなければならないが、grunt-contrib-copyなどでよしなにすればOK。

grunt-wiredep使用の環境でmomentの日本語ロケールファイルを含めたい

より正確にいうとgrunt-wiredep使用の環境でangular-momentロケール指定したい、ということ。

angular-momentをインストールする

% bower install angular-moment --save

これでmomentも依存関係でインストールされて、wiredepで差し込まれてめでたしめでたし…

index.html

  <!-- bower:js -->
  <!-- 略 -->
  <script src="bower_components/moment/moment.js"></script>
  <script src="bower_components/angular-moment/angular-moment.js"></script>
  <!-- endbower -->

いやいや、これだとロケールファイルが含まれていない。

bower_components/下のmomentを見ると、minディレクトリにmoment-with-locales.jsがある。

なので、プロジェクトのbower.jsonでmainをoverrideする。

bower.json

{
// 略
  "overrides": {
    "moment": {
      "main": "min/moment-with-locales.js"
    },
    "angular-i18n": {
      "main": "angular-locale_ja-jp.js"
    }
  }

ロケール入りになった。

index.html

  <!-- bower:js -->
  <!-- 略 -->
  <script src="bower_components/moment/min/moment-with-locales.js"></script>
  <script src="bower_components/angular-moment/angular-moment.js"></script>
  <!-- endbower -->

これで設定できる

app.coffee

myapp.run((amMoment) ->
    amMoment.changeLanguage('ja')
)