0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Railsチュートリアル 第7章 ユーザー登録 - ユーザー登録失敗

Posted at

正しいフォーム

前提条件

<form class="new_user" id="new_user" action="/users" accept-charset="UTF-8" method="post" kpxcform-initialized="true" kpxcusername="user_email" kpxcpassword="user_password">

Railsにより実際に構築されたHTMLのうち、form要素の開始タグのみを取り出したものです。タグの属性から、以下のことがわかります。

  • このフォームでsubmitすると、/usersに対してHTTPリクエストを送出する
  • このフォームが送出するHTTPリクエストの種類はPOSTである

Railsの仕様上、/usersに対するPOSTリクエストはcreateアクションによって処理されるようになっています。

このフォームでsubmitしてからの一連の処理

  1. createアクションで、フォームによって生成されたHTTPリクエストを受け取る
  2. User.newメソッドによって新しいユーザーオブジェクトが生成される
  3. ユーザーオブジェクトの情報をRDBMSに保存する処理を行う
  4. 再度ユーザー登録ページを表示する

「ユーザーオブジェクトの情報をRDBMSに保存する」という処理は、必ず成功するとは限らず、失敗することもあります。成功時と失敗時では違う処理を実装する必要があります。

ユーザー登録の失敗に対応できるcreateアクション

app/controllers/users_controller.rb
  class UsersController < ApplicationController

    ...略
+
+   def create
+     @user = User.new(params[:user])  # TODO:実装はまだ終わっていない
+     if @user.save
+       # TODO: 保存の成功をここに実装する
+     else
+       render 'new'
+     end
  end

ここで重要なポイントは以下です。

  • @user.saveは、保存が成功した場合にtrue、保存が失敗した場合にfalseを返す
  • renderメソッドは、コントローラ中でも正常に動作する

実際に無効なユーザー登録データを送信(submit)してみる

現時点で実際に無効なユーザー登録データを送信(submit)してみると、Webブラウザには以下のようなエラー画面が出力されます。

スクリーンショット 2019-10-19 14.21.08.png

rails serverのログには、以下のようなエラーメッセージが残されています。

Started POST "/users" for 172.17.0.1 at 2019-10-19 05:19:50 +0000
Cannot render console from 172.17.0.1! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by UsersController#create as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"wwiu2NvcdY59JxI/Eq96qLM88Oe8oAsBPMM7RQ1JT4xO47Pt+wSscw8fK40/S+DAa8h5ziATCnlV5gxUWDsiag==", "user"=>{"name"=>"", "email"=>"", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Create my account"}
Completed 500 Internal Server Error in 199ms (ActiveRecord: 41.4ms)


ActiveModel::ForbiddenAttributesError (ActiveModel::ForbiddenAttributesError):

app/controllers/users_controller.rb:12:in `create'

HTTPリクエスト中のuserパラメータハッシュと、それに対してRailsが行う処理

rails serverのログから、HTTPリクエストの内容のうちのuserパラメータハッシュだけを抜粋してみます(内容は見やすいように整形します)。

"user"=>{ "name"=>"",
          "email"=>"",
          "password"=>"[FILTERED]",
          "password_confirmation"=>"[FILTERED]"
        }

ここで重要なポイントは以下です。

  • HTTPリクエストの内容は、そのものがparamsというハッシュとしてUsersコントローラに渡される
  • ユーザー登録情報の送信においては、paramsハッシュの中にさらにハッシュが含まれる(hash-of-hashes)

ここで改めて、ユーザー登録フォームのinput要素の一つを見てみましょう。

<input type="email" name="user[email]" id="user_email" data-kpxc-id="user_email" kpxc-username-field="true">

今回着目するのはname属性です。実際の動作は以下のようになります。

  1. HTTPリクエストが組み立てられ、フォームがsubmitされる
  2. Railsは、受け取ったHTTPリクエストの内容からハッシュを構成する
    • 上述ログにおけるハッシュのキーが"user"などの文字列である
    • paramsハッシュの属性名は、HTTPリクエストに存在するクエリパラメータの名前を元に構成される
    • クエリパラメータの名前は、フォームの各構成要素のname属性を元に構成される
  3. コントローラは、paramsハッシュ内の:userハッシュを引数としてUser.newメソッドを実行する
    • ここまで「paramハッシュ」と言ってきたが、paramの型は、実はRailsで定義されたActionController::Parameters型である
    • ActionController::Parameters型(およびそのスーパークラスであるActiveSupport::HashWithIndifferentAccess型)は、キーが文字列("user")であるかシンボル(:user)であるかを区別しないように作られている
    • 結果として、上述のハッシュのキーの数および名前は、User.newの引数で必要となるデータと完全に一致する

長くなりました。特に重要そうなポイントは以下だと思います。

  • HTMLフォーム内各要素のname属性が、Railsでどう処理されるか
  • paramハッシュについて
  • ActionController::Parameters型とその特徴
  • (復習)User.newの引数で必要となるデータ

マスアサインメント

実は、以下2つのコードは、Railsにおいてほぼ同じ挙動をします。

params[:user] = { name: "Foo Bar",
                  email: "foo@invalid",
                  password: "foo",
                  password_confirmation: "bar"
                }

@user = User.new(params[:user])
@user = User.new(name: "Foo Bar", email: "foo@invalid",
                 password: "foo", password_confirmation: "bar")

バージョン3.xまでのRailsには、「1つのモデルに対し、@user = User.new(params[:user])のようにして当該モデルのフィールドをまとめて設定できる」という機能がありました。この機能をマスアサインメント機能といいます。

しかしながら、バージョン4.x以降のRailsでは、@user = User.new(params[:user])というようなコードを実行するとエラーになります。マスアサインメント機能には深刻な脆弱性が存在し、その対策が実装されたためです。次の項目「Strong Parameters」で解説していきます。

Strong Parameters

マスアサインメント機能に起因する脆弱性

しかしながら、このようなマスアサインメント系のメソッドは、無条件に全てのデータを上書きしてしまうため、「マスアサインメント機能に起因する脆弱性」というものが生まれることになってしまいました。

どのような脆弱性か

例えば、以下のような実装がなされたRailsアプリケーションがあるとしましょう。

  • Userモデルにadminという属性がある
  • admin属性がtrueであるユーザーは、Webサイトの管理者権限を持つ

このような実装でマスアサインメントが可能な場合、params[:user]admin='1'なるクエリパラメータを紛れ込ませて渡してしまえば、admin属性をtrueにできてしまいます。こうした属性を含むparamsハッシュが丸ごとUser.newに渡されてしまうと何が起こるでしょうか。「どのユーザーでもadmin='1'をHTTPリクエストに紛れ込ませることでWebサイトの管理者権限を奪うことができる」という事態が発生してしまうのです。

当該脆弱性への対策

Rails 4.0では、コントローラ層でStrong Parametersという機能を使うことにより、マスアサインメント脆弱性への対策としています。Strong Parameters機能で実現できることは以下のとおりです。

  • 必須のパラメータと許可されたパラメータを指定する
  • paramsハッシュを直接渡すとエラーが発生するようにする

現在のアプリケーションにおける、Strong Parameters機能の仕様

現在のアプリケーションでは、以下の仕様にしたいと考えています。

  • paramsハッシュにおいて、:user属性を必須とする
  • paramsハッシュの:user属性では、以下の属性を許可し、それ以外の属性は許可しない
    • 名前(user[:name])
    • メールアドレス(user[:email])
    • パスワード(user[:password])
    • パスワードの確認(user[:password_confirmation])

上述仕様に対応するメソッドチェーンは以下となります。

params.require(:user).permit(:name, :email, :password, :password_confirm)

このメソッドチェーンの戻り値は、許可された属性のみが含まれたparamsのハッシュです(:user属性がない場合はエラー)。

上記動作をまとめると以下の通りです。

  • params.requireの引数にシンボルを与えることにより、paramsハッシュに当該シンボルをキーとするハッシュが含まれていることが必須となる
  • params.require(:symbol).permitの引数にシンボルを与えることにより、params[:symbol]ハッシュに以下の条件が定義される
    • params.require(:symbol).permitの引数に与えられたシンボルをキーとするハッシュ値が含まれることを許可する
    • params.require(:symbol).permitの引数に与えられたシンボル以外をキーとするをハッシュ値が含まれることを許可しない

user_paramsメソッドと、Rubyのprivateキーワード

params.require(:user).permit(:name, :email, :password, :password_confirm)

これはいかにも長いメソッド呼び出しです。そのままでは使いにくいです。こうした長いメソッド呼び出しをより使いやすくするため、Railsではuser_params1という外部メソッドを使うのが慣習になっています。

user_paramsは、Strong Parameters機能が有効な環境で、params[:user]の代わりに使われる外部メソッドです。Strong Parameters機能で適切に初期化されたハッシュを返します。

Rubyのprivateキーワード

今回実装するuser_paramsメソッドは、もっぱらUsersコントローラの内部でのみ使われ、Web経由で外部ユーザにさらされる必要はありません。そのため、Railsのprivateキーワードによって外部から使えないように制限します。

createアクションで実際にStrong Parameters機能を活用する

app/controllers/users_controller.rb
  class UsersController < ApplicationController

    ...略

    def create
-     @user = User.new(params[:user])  # TODO:実装はまだ終わっていない
+     @user = User.new(user_params)
      if @user.save
        # TODO: 保存の成功をここに実装する
      else
        render 'new'
      end
    end
+
+   private
+
+     def user_params
+       params.require(:user).permit(:name, :email, :password, password_confirmation)
+     end
  end

なお、Railsチュートリアル本文では、「privateキーワード以降のコードを強調するために、user_paramsのインデントを1段深くしていること」について、ここでより突っ込んだ解説をしています。以下の理由でよろしいのだとか。

  • クラス内に多数のインデントがある場合、privateメソッドの場所を簡単に見つけられるようになる
    • インデントがない場合に比べ、どこからprivateになるのかについて困惑しなくなる

ここまでの実装が完了すれば、「送信ボタンを押してもエラーが出ないユーザー登録フォーム」が実現します。しかしながら、現在のところ、(開発者用のデバッグ領域を除いて)間違った情報を送信しても何もフィードバックは返ってきません。現状で間違った情報を送信した後のWebブラウザの表示内容は以下のようになります。

スクリーンショット 2019-10-20 21.47.46.png

デバッグ領域の内容を見れば「間違った情報が送られた」ということはわかるのですが、それ以外は新規にユーザー登録画面を開いた場合と何ら変わりありません。これでは利用者が困惑することは避けられません。

さらに、有効なユーザー情報を送信しても新しいユーザーが実際に作成されることはありません。

以上の問題は、今後の実装にて解決していきます。

演習 - Strong Parameters

1. /signup?admin=1 にアクセスし、paramsの中にadmin属性が含まれていることをデバッグ情報から確認してみましょう。

http://localhost:8080/signup?admin=1にアクセスした場合、デバッグ領域の内容は以下のようになります。

スクリーンショット 2019-10-20 21.56.22.png

確かにadmin: '1'が含まれていますね。

なお、このときのrails serverのログは以下のようになります。

Started GET "/signup?admin=1" for 172.17.0.1 at 2019-10-20 12:55:02 +0000
Cannot render console from 172.17.0.1! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by UsersController#new as HTML
  Parameters: {"admin"=>"1"}
  ...略
Completed 200 OK in 951ms (Views: 900.6ms | ActiveRecord: 0.0ms)

同様に、例えば /signup?foobar=1 にアクセスすると、paramsの中にはfoobar属性が含まれます。

エラーメッセージ

Railsによるエラーメッセージの自動生成

Railsは、モデルオブジェクトの検証でエラーが発生した場合、発生したエラーに対応するエラーメッセージを自動で生成する機能を有しています。

# rails console
>> user = User.new(name: "Foo Bar", email: "foo@invalid",
?>                 password: "dude", password_confirmation: "dude")
>> user.save
=> false
>> user.errors.full_messages
=> ["Email is invalid", "Password is too short (minimum is 6 characters)"]

以上は、「無効なメールアドレス、かつ、短すぎるパスワード」というUserモデルのオブジェクトを保存しようとした場合の経過です。user.saveが失敗した時点で、@userオブジェクトに関連付けられたエラーメッセージの配列がerrors.full_messagesにされます。

Railsによって自動生成されたエラーメッセージをRailsアプリケーションで使う方法

@userオブジェクトに関連付けられたエラーメッセージをRailsアプリケーションで使う、例えばエラーメッセージをWebブラウザに表示することももちろん可能です。今回は、「ユーザーのnewページでエラーメッセージのパーシャル(partial)を出力する」という方法をとります。

app/views/users/new.html.erb
  <% provide(:title, 'Sign up') %>
  <h1>Sign up</h1>

  <div class="row">
    <div class="col-md-6 col-md-offset-3">
      <%= form_for(@user) do |f| %>
+       <%= render 'shared/error_messages' %>
+
        <%= f.label :name %>
-       <%= f.text_field :name %>
+       <%= f.text_field :name, class: 'form-control' %>

        <%= f.label :email %>
-       <%= f.email_field :email %>
+       <%= f.email_field :email, class: 'form-control' %>

        <%= f.label :password %>
-       <%= f.password_field :password %>
+       <%= f.password_field :password, class: 'form-control' %>

        <%= f.label :password_confirmation, "Confirmation" %>
-       <%= f.password_field :password_confirmation %>
+       <%= f.password_field :password_confirmation, class: 'form-control' %>

        <%= f.submit "Create my account", class: "btn btn-primary" %>
      <% end %>
    </div>
  </div>

class: 'form-control'というコードは、「Bootstrapがうまく扱えるようにクラスを追加する」という意味のコードです。

上述コードにおける主要な変更内容は、「shared/error_messagesというパーシャルを描画(render)する機能を実装した」という点です。より細かく見ていくと、以下のような点が重要です。

  • 複数のビューで使われるパーシャルは、専用のディレクトリであるsharedに置かれる
  • 現時点でapp/views/sharedというディレクトリは存在しないので、新規にディレクトリを作成する必要がある
  • app/views/shared/_error_messages.html.erbというファイルを新規に作成する必要がある
    • error_messagesは部分テンプレートなので、ファイル名の頭には_が必要である

フォーム送信時にエラーメッセージを表示するためのパーシャルと、その解説

早速必要なディレクトリとファイルを生成してみましょう。

>>> pwd
~/docker/rails_tutorial_test/sample_app
>>> mkdir app/views/shared
>>> touch app/views/shared/_error_messages.html.erb
app/views/shared/_error_messages.html.erb
<% if @user.errors.any? %>
  <div id="error_explanation">
    <div class="alert alert-danger">
      The form contains <%= pluralize(@user.errors.count, "error") %>.
    </div>
    <ul>
    <% @user.errors.full_messages.each do |msg| %>
      <li><%= msg %></li>
    <% end %>
    </ul>
  </div>
<% end %>

app/views/shared/_error_messages.html.erbには、色々と興味深い新要素が登場します。

errors.countメソッド

>> user = User.new(name: "Foo Bar", email: "foo@invalid",
?>                 password: "dude", password_confirmation: "dude")
>> user.save
=> false

>> user.errors.count
=> 2

errors.countメソッドは、エラーの数を返すメソッドです。errorsに限らず、配列一般に対し、Ruby標準のメソッドとしてcountメソッドが定義されています。

any?メソッド

>> user.errors.any?
=> true
>> user.errors.empty?
=> false

Enumerableモジュールが実装されたオブジェクト(配列やハッシュ等)に対して定義される、Ruby標準のメソッドです。

  • レシーバ2に一つでも戻り値がtrueとなる値が格納されていればtrue
  • レシーバが空である場合、戻り値はfalse
  • レシーバに値が格納されていても、その全ての戻り値がfalseとなる場合、戻り値はfalse

Railsチュートリアル本文には、「empty?メソッドとany?メソッドは互いに補完する」「any?メソッドはちょうどempty?と逆の動作で、要素が1つでもある場合はtrue、ない場合はfalseを返す」とありますが、実際には「empty?any?も戻り値がfalseになる場合」というのが存在します。例えば以下のような場合です。

>> [false, false, nil, nil].any?
=> false
>> [false, false, nil, nil].empty?
=> false

empty?any?も戻り値がfalseになるのは、全ての要素がfalseまたはnilのいずれかである場合」ですね。

Railsのエラーオブジェクトに限れば、その性質上「全ての要素がfalseまたはnilのいずれかになる」という事態は発生しないはずなので、「any?メソッドは、要素が1つでもある場合はtrue、ない場合はfalseを返す」という認識で構わないでしょう。

helper.pluralizeメソッド

こちらはRailsのActiveSupportライブラリで定義されているメソッドです。第1引数に整数を、第2引数に文字列を取ります。第1の引数に基づき、第2引数(英単語であることが前提です)が単数形になるか複数形になるかを判断した上で、必要なら複数形に変換し、文字列を出力します。

rails consoleから動作を試してみることもできます。

>> helper.pluralize(1, "error")
=> "1 error"
>> helper.pluralize(3, "error")
=> "3 errors"

今回のコードでは、pluralizeが以下のように使われています。

<%= pluralize(@user.errors.count, "error") %>

その結果は、@user.errors.countの内容に応じ、例えば"0 errors","1 error","2 errors"のようになります。結果として、"1 errors"のような英文法に合わないメッセージが出現する事態を自動的に避けることが可能になります。

文字列に対するpluralizeメソッド

RailsのActiveSupportライブラリでは、helper.pluralizeのみならず、一般の文字列に対するpluralizeメソッドも定義されています。レシーバーとして与えられた文字列を(英単語であることを前提として)複数形に変換します。

>> "error".pluralize
=> "errors"
>> "query".pluralize
=> "queries"

不規則活用の名詞を複数形にすることもできます。

>> "woman".pluralize
=> "women"
>> "erattum".pluralize
=> "eratta"
>> "person".pluralize
=> "people"

文字列に対するsingularizeメソッド

RailsのActiveSupportライブラリでは、singularizeメソッドも定義されています。

なお、helper.singularizeは定義されていないようです。pluralizeメソッドとは逆に、レシーバーとして与えられた文字列を(英単語であることを前提として)単数形に変換します。

>> "errors".singularize
=> "error"
>> "women".singularize
=> "woman"
>> "people".singularize
=> "person"

pluralizesingularizeに関する注意事項

pluralizesingularizeといったメソッドは、ActiveSupport::Inflectorというライブラリにて定義されています。

こうした便利なメソッドの恩恵を享受するためには、変数やメソッドなどの名前は英語で定義されている必要があります。Railsに限らず、純粋なRubyも英語を強く意識した言語仕様になっています。特にRails開発においては、pluralizesingularizeによって単数形と複数形を変換して使うのが前提になっているので、変数やメソッドの名前を英語以外の言語で定義するのは避ける必要があります。

エラーメッセージにスタイルを与えるためのCSS

使用可能なCSSセレクタ

改めてapp/views/shared/_error_messages.html.erbには、error_explanationというCSS IDを持つdiv要素が含まれています。

app/views/shared/_error_messages.html.erb
<div id="error_explanation">
...略
</div>

またRailsは、「無効な内容の送信によって元のページに戻される」という動作が発生すると、エラーに関するHTMLの内容のみを、クラスfield_with_errorsが定義されたdiv要素で自動的に囲うという動作をします。

結果、エラーメッセージにスタイルを与えるためには、以下2つのCSSセレクタを使用できることになります。

  • error_explanationというCSS ID
  • field_with_errorsというCSSクラス

実際にSCSSを書いてみる

app/assets/stylesheets/custom.scss
  @import "bootstrap-sprockets";
  @import "bootstrap";
  ...略
  /* forms */
  ...略
+
+ #error_explanation {
+     color: red;
+     ul {
+         color: red;
+         margin: 0 0 30px 0;
+     }
+ }
+
+ .field_with_errors {
+     @extend .has-error;
+     .form_control {
+         color: $state-danger-text;
+     }
+ }
  ...略 

このコードのポイントは「Sassの@extend関数により、Bootstrapのhas-errorというCSSクラスが適用されている」という点です。$state-danger-textというのもBootstrap由来ですね。

ここまで実装してきたものを踏まえ、改めてエラーメッセージをWebブラウザで表示させてみる

ここまでに、以下の実装を行ってきました。

  • Userモデルのerrorsオブジェクトを元に、エラーの内容を得られるようにした
  • ユーザー登録が失敗した場合に、以下の情報が表示されたユーザー登録画面が出力されるようにした
    • エラーが発生した事実
    • 入力内容の何が悪くてエラーが発生したのか
  • SCSSにより、ユーザー登録画面に表示されるエラーメッセージの見栄えを整えた

以上の実装を踏まえた上で、再びユーザー登録を失敗させてみましょう。

スクリーンショット 2019-10-21 16.07.00.png

エラーメッセージがきちんと表示されていますね。全てのエラーをfixすれば、RDBに問題なく保存できる形のデータになりそうです。エラーメッセージの見栄えも整っています。

同じエラーメッセージが2回表示されてしまう問題

しかしながら、上記スクリーンショットにおいては、「Password can't be blank」というエラーメッセージが2回表示されています。原因は、以下2つのバリデーションで、両方とも空のパスワード(nil)を検知してしまうためです。

  • presence: trueによるバリデーション
  • has_secure_passwordによるバリデーション

この問題を解決するには、今後のRailsチュートリアルで登場するallow_nil: trueというオプションを追加すればよいのだそうです。

演習 - エラーメッセージ

1. 最小文字数を5に変更すると、エラーメッセージも自動的に更新されることを確かめてみましょう。

「最小文字数を変更する」というのは、app/models/user.rbを変更するのでしたね。以下の変更を加えます。

app/models/user.rb
  class User < ApplicationRecord
    ...略
-   validates :password, presence: true, length: { minimum: 6 }
+   validates :password, presence: true, length: { minimum: 5 }
  end

結果は以下のように変化します。確かに「minimum is 5 characters」というエラーメッセージが見受けられますね。

スクリーンショット 2019-10-21 16.43.58.png

2. 未送信のユーザー登録フォーム (図 7.12) のURLと、送信済みのユーザー登録フォーム (図 7.18) のURLを比べてみましょう。なぜURLは違っているのでしょうか? 考えてみてください。

前提条件は以下です。

  • 図 7.12では、URLが/signupとなっている
  • 図 7.18では、URLが/usersとなっている

簡潔に言えば理由は以下です。

  • 図 7.12は、/signupGETリクエストを送出した結果である
  • 図 7.18は、/usersPOSTリクエストを送出した結果である

/usersPOSTリクエストを送出した結果が「ユーザー登録に失敗」であった場合、Usersコントローラにより「newビューがrenderされる」という処理が行われます。結果として、エラーメッセージが表示されている以外は/signupGETリクエストを送出した結果と同じものが表示されることになるのです。

失敗時のテスト

  • ブラウザでフォームを表示する
  • 有効なデータと無効なデータを交互に流し込む
  • どちらの場合にもアプリケーションが正常に動作することを確認する
    • 多くの場合、Excel様式にスクリーンショットを貼り付けていく
  • アプリケーションに変更が生じるたびに、まったく同じテストを繰り返す

このような「人海戦術でカバー」式のやり方は、はっきり言って担当者にとって苦痛なものです。しかしながら、Railsをはじめとする現代のWebフレームワークでは、こうしたフォームのテストをも自動化することが可能なのです。

新規ユーザー登録用の統合テストを作成する

開発環境でrails generate integration_test users_signupというコマンドを実行します。

  • 統合テストなのでintegration_test
  • 新規ユーザー登録用のテストなのでusers_signup
    • 「リソース名は複数形」というRailsの慣習に従った名前である
# pwd
/var/www/sample_app
# rails generate integration_test users_signup
Running via Spring preloader in process 204
      invoke  test_unit
      create    test/integration/users_signup_test.rb

今実装するのは「失敗時のテスト」ですが、今後実装する「成功時のテスト」にも、今回生成したファイルを使っていきます。

今回実装するテストで確認するもの

今回実装するテストは、ユーザー登録ボタンを押したときに(ユーザー情報が無効であるため)ユーザーが作成されないことを確認するものです。また、今後うっかり要素を変更してしまっても気付けるようにするために、ユーザー登録フォーム内に存在するHTML要素についてもテストしていきます。

User.countメソッドを使用する

ユーザーが作成されないことを確認するための方法としては、「User.countメソッドを用いて、RDB上に存在するユーザーの数をカウントする」という方法を取ります。

>> User.count
   (2.3ms)  SELECT COUNT(*) FROM "users"
=> 1

assert_selectメソッドを使用する

HTML要素をテストするためには、「assert_selectメソッドを使用する」という方法を取ります。

テストの内容について解説

getメソッドを使ってユーザー登録ページにアクセスする

ユーザー登録ページにアクセスするために、まずsignup_pathに対してgetメソッドを実行します。/signupGETリクエストを実行するのと同じ操作ですね。

get signup_path

ルーティングが正しく書かれていれば、Usersコントローラのnewメソッドが実行され、最終的にユーザー登録ページが返ってくるはずです。

users_pathPOSTリクエストを送信する

新規ユーザー登録に際してユーザー登録フォームをsubmitしたときの動作は、「users_pathPOSTリクエストを送信する」というものでした。この動作をテストするには、postメソッドを含む以下のようなコードで実現できます。

assert_no_difference 'User.count' do
  post users_path, params: { user: { name:  "",
                                     email: "user@invalid",
                                     password:              "foo",
                                     password_confirmation: "bar" } }
end

ブロック内のコードで重要なポイントは以下です。

  • postの引数
    • 第1引数には、POSTリクエストを送信する先のパスが与えられる
    • 第2引数には、POSTリクエストの内容が与えられる
  • postの第2引数に、params[:user]ハッシュを与えている
    • Rails 4.2以前では、paramsを暗黙的に省略しても正常に動作した
      • userハッシュのみでも正しく動作する、ということ
    • Rails 5.0以降は、paramsを省略することは非推奨とされている
      • paramsハッシュを明示的に含めることが推奨される
  • params[:user]において、ユーザー登録情報として正しくない内容になるようなハッシュを与えている
    • :nameの値が空である
    • :emailの値が、メールアドレスとして正しくない文字列である
    • :password:password_confirm共に短すぎる
    • :password:password_confirmが一致していない

assert_no_difference 'User.count'については次の項で解説します。

assert_no_difference 'User.count'とは

assert_no_difference 'User.count' do
  # ...略
end

assert_no_differenceというのは、「ブロックの実行前後で引数(今回はUser.count)の値が変化しないことをテストする」というメソッドです。結果、このテスト全体としては、以下のコードと等価になります。

before_count = User.count
post user_path, ...
after_count = User.count
assert_equal before_count, after_count

しかしながら、assert_no_differenceのほうが明確な記法ですし、Rubyの慣習にも従った記法となります。

ユーザー登録は、技術的にはユーザー登録フォームを経由しなくても可能である

assert_no_differenceブロック内では、getメソッドを使用していません。これは以下のことを意味します。

  • getpostassert等の各メソッドに技術的な関連性がない
  • ユーザー登録フォームを経由せず、直接postメソッドを呼び出してユーザー登録を行うことが可能である

ただ、(実際の手順にならって)getpostの両メソッドをを呼び出すことには、以下の理由から十分な価値があります。

  • テストのコンセプトを明確にするため
  • ユーザー登録ページそのものを改めてチェックするため

実際にテストを実装する

test/integration/users_signup_test.rbの初期状態

生成されたばかりのtest/integration/users_signup_test.rbは、以下の内容になっています。

test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest
  # test "the truth" do
  #   assert true
  # end
end

実際にテストを実装したときのtest/integration/users_signup_test.rbの内容

test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest

  test "invalid signup information" do
    get signup_path
    assert_no_difference 'User.count' do
      post users_path, params: { user: { name: "",
                                        email: "user@invalid",
                                        password:              "foo",
                                        password_confirmation: "bar" } }
    end
    assert_template 'users/new'
  end
end

Railsチュートリアル本文にある以下の記述には注意が必要です。

送信に失敗したときにnewアクションが再描画されるはずなので、assert_templateを使ったテストも含めている

ここまでの学習で、このテストが通るために必要な機能は全て実装してきました。そのため、このテストは問題なく通ります。

# rails test integration
Running via Spring preloader in process 250
Started with run options --seed 50920

  20/20: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.51197s
20 tests, 41 assertions, 0 failures, 0 errors, 0 skips

演習 - 失敗時のテスト

1. リスト 7.20で実装したエラーメッセージに対するテストを書いてみてください。どのくらい細かくテストするかはお任せします。

リスト 7.25にテンプレートを用意しておいたので、参考にしてください。

  require 'test_helper'

  class UsersSignupTest < ActionDispatch::IntegrationTest

    test "invalid signup information" do
      ...略
      assert_template 'users/new'
+     assert_select 'div#error_explanation'
+     assert_select 'div.alert' do
+       assert_select 'div.alert-danger'
+     end
+     assert_select 'ul'
+     assert_select 'li'
    end
  end

私は以下のテストを追加してみました。

  • error_explanationというIDを持つdiv要素が存在する
  • alertalert-danger両方のクラスを持つdiv要素が存在する
  • ul要素が存在する
  • li要素が存在する

ulliというのは、「実際にユーザーデータの不適合の内容が1つ以上表示されているか」をテストするためのアサーションです。

なお、「alertalert-danger両方のクラスを持つdiv要素が存在することをテストする」というのは、初見では方法がわからないかと思います。Rails上におけるテストで、「1つのHTML要素に対し、複数のクラスが定義されていること」をテストする正しい書き方 - Qiitaにて、その点について解説を入れてみました。

2.1. ユーザー登録フォームのURLは /signup ですが、無効なユーザー登録データを送付するとURLが /users に変わってしまいます。リスト 7.26リスト 7.27の内容を参考に、この問題を解決してみてください。

これはリスト 5.43で追加した名前付きルート (/signup) と、RESTfulなルーティング (リスト 7.3) のデフォルト設定との差異によって生じた結果です。

そういえば、演習 - エラーメッセージの設問2にもこの点についての言及が出てきましたね。

まず、config/routes.rbを編集し、/signup に対するPOSTリクエストでUsersコントローラのcreateアクションが実行されるようにします。

config/routes.rb
  Rails.application.routes.draw do
    get 'users/new'

    root 'static_pages#home'
    get '/help', to: 'static_pages#help'
    get '/about', to: 'static_pages#about'
    get '/contact', to: 'static_pages#contact'
    get '/signup', to: 'users#new'
+   post '/signup', to: 'users#create'
    resources :users
  end

続いて、 /signup に対応するビューであるapp/views/users/new.html.erbを編集し、フォームがPOSTリクエストを送出する先が /signup になるようにします。

app/views/users/new.html.erb
  <% provide(:title, 'Sign up') %>
  <h1>Sign up</h1>
  
  <div class="row">
    <div class="col-md-6 col-md-offset-3">
-     <%= form_for(@user) do |f| %>
+     <%= form_for(@user, url: signup_path) do |f| %>     
        ...略
      <% end %>
    </div>
  </div>

2.2. (2.1. の続きです)うまくいけばどちらのURLも /signup になるはずです。あれ、でもテストはgreenのままになっていますね...、なぜでしょうか? (考えてみてください)

# rails test integration
Running via Spring preloader in process 363
Started with run options --seed 26654

  20/20: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.13368s
20 tests, 46 assertions, 0 failures, 0 errors, 0 skips

この時点においても、テストは確かに通ります。

私としては、この場合にテストが問題なく通るのは「少なくともRESTfulなアーキテクチャを前提とした場合、『GETPOST(あるいはPATCHDELETEでも)で、URLが同じだが、Webアプリケーションで実際に呼び出される処理が異なる』というのは、仕組み上正しい挙動であるから」というのが理由と考えています。

3. リスト 7.25post部分を変更して、先ほどの演習課題で作った新しいURL (/signup) に合わせてみましょう。また、テストが greenのままになっている点も確認してください。

変更するファイルはtest/integration/users_signup_test.rbです。

test/integration/users_signup_test.rb
  require 'test_helper'

  class UsersSignupTest < ActionDispatch::IntegrationTest

    test "invalid signup information" do
      get signup_path
      assert_no_difference 'User.count' do
-       post users_path, params: { user: { name: "",
+       post signup_path, params: { user: { name: "",
                                          email: "user@invalid",
                                          password:              "foo",
                                          password_confirmation: "bar" } }
      end
      ...略
    end
  end

改めてテストを実行してみましょう。

p# rails test integration
Running via Spring preloader in process 400
Started with run options --seed 64185

  20/20: [=================================] 100% Time: 00:00:03, Time: 00:00:03

Finished in 3.33716s
20 tests, 46 assertions, 0 failures, 0 errors, 0 skips

テストは問題なく通りますね。

4. リスト 7.27のフォームを以前の状態 (リスト 7.20) に戻してみて、テストがやはりgreenになっていることを確認してください。

これは問題です! なぜなら、現在postが送信されているURLは正しくないのですから。

app/views/users/new.html.erbを以下のように変更した場合にテストは通るか、という話です。

app/views/users/new.html.erb
  <% provide(:title, 'Sign up') %>
  <h1>Sign up</h1>
  
  <div class="row">
    <div class="col-md-6 col-md-offset-3">
-     <%= form_for(@user, url: signup_path) do |f| %>     
+     <%= form_for(@user) do |f| %>
        ...略
      <% end %>
    </div>
  </div>

現状でテストを実行してみましょう。

# rails test integration
Running via Spring preloader in process 452
Started with run options --seed 53403

  20/20: [=================================] 100% Time: 00:00:03, Time: 00:00:03

Finished in 3.06687s
20 tests, 46 assertions, 0 failures, 0 errors, 0 skips

テストが通ってしまいました。

現状でテストが通ることの何が問題か

何が問題かといいますと…

<form class="new_user" id="new_user" action="/users" accept-charset="UTF-8" method="post">
...略
</form>

上記form要素の属性において、action属性の値が/usersになっていることが問題なのです。action属性の値は、本来は/signupでなければなりません。現状ではテストが通ってしまうことが逆に問題となります。

4.2. assert_selectを使ったテストをリスト 7.25に追加し、このバグを検知できるようにしてみましょう (テストを追加してredになれば成功です)。

ヒント: フォームから送信してテストするのではなく、'form[action="/signup"]'という部分が存在するかどうかに着目してテストしてみましょう。

test/integration/users_signup_test.rbを以下のように変更します。

test/integration/users_signup_test.rb
  require 'test_helper'

  class UsersSignupTest < ActionDispatch::IntegrationTest

    test "invalid signup information" do
      get signup_path
+     assert_select 'form[action="/signup"]'
      ...略
    end
  end

ユーザー登録フォームのHTMLの内容が問題なので、/signup に対してgetメソッドを実行した直後がテストを行う適切なタイミングになりますね。そのため、postメソッドを実行する前に当該テストを実行するようにしています。

改めてテストを実行してみましょう。

# rails test integration
Running via Spring preloader in process 478
Started with run options --seed 25723

 FAIL["test_invalid_signup_information", UsersSignupTest, 2.1218616999976803]
 test_invalid_signup_information#UsersSignupTest (2.12s)
        Expected at least 1 element matching "form[action="/signup"]", found 0..
        Expected 0 to be >= 1.
        test/integration/users_signup_test.rb:7:in `block in <class:UsersSignupTest>'

  20/20: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.12470s
20 tests, 40 assertions, 1 failures, 0 errors, 0 skips

めでたく(?)テストが通らなくなりました。Expected at least 1 element matching "form[action="/signup"]", found 0.という内容の指摘事項なので、実装したいテストがきちんと実装されていることになりますね。

4.3. その後、変更後のフォーム (リスト 7.27) に戻してみて、テストがgreenになることを確認してみましょう。

app/views/users/new.html.erbを以下のように変更します。

app/views/users/new.html.erb
  <% provide(:title, 'Sign up') %>
  <h1>Sign up</h1>
  
  <div class="row">
    <div class="col-md-6 col-md-offset-3">
-     <%= form_for(@user) do |f| %>
+     <%= form_for(@user, url: signup_path) do |f| %>     
        ...略
      <% end %>
    </div>
  </div>

改めてテストを実行してみましょう。

# rails test integration
Running via Spring preloader in process 491
Started with run options --seed 19848

  20/20: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.41956s
20 tests, 47 assertions, 0 failures, 0 errors, 0 skips

無事にテストが通りました。

  1. より一般化すると[モデル名]_paramsです。

  2. 今回はuser.errorsですね。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?