Abstract
目標:
edit
、update
、index
、destroy
アクションを実装し、UserモデルのRESTアクションを完成させる
この章の気付き
TDDはユーザーの意図(〜したい)と相性が良い
- 〜したいという意図をもとに考えるとテストがスムーズに書ける
- 演繹法的思考プロセス
- 〜したいという引き出しの多さ、セオリー的な知識・経験の蓄積も重要かと
- 経験的に、限られたリソースで必要十分なアウトプットをするためにはこういった思考プロセスが重要
可読性という概念
unless
表記はときに可読性の向上に役立つ- (実行内容) unless helper_method?(論理値を返すヘルパーメソッド)の表記が慣例
- テストコードにおいては特に無理にDRYにせず、他者からのレビューを意識して書くべき
- 可読性の高さ=シンプルなで的確なロジックであり、テストの目的とよく合致するかもしれない
セキュリティ対策
- Strong Parametersの設定は重要
- 編集可能なものだけ設定する、その他は許可しない、という振る舞いが重要
- リンクを隠すだけでなく、リクエストそのものを潰す必要性に気づいた
セキュリティモデルの実装にはbefore_action
を活用する
- only: [...]で
before_action
の適応される範囲を制限可能 - before_actionで定義するアクションは
private
へ(ここちょっと曖昧)
edit
とnew
アクションの共通点と差異、それを吸収するform with
メソッドの振る舞い
form with
は従来のform tag
(URLを受ける)とform for
(モデルを受け取る)の両者の特性を使い分ける- モデルを受けることで、モデルの中身に応じた分岐ができる(中身なし >
create
, 中身あり > update) - モデルは複数受け取る事ができるらしい
target="_blank"
のセキュリティ上の問題を埋めるrel="noopener"
- 外部リンク
target="_blank"
とrel="noopener"はセットで
モデルに属性を追加すると自然と属性名?
メソッドが利用できるようになる
- adminの認可にはこれがそのまま使える
DB上のデータを一覧表示するには繰り返し処理が適切
- インスタンス変数を定義
@user = User.all
して、@user.each do |user|で展開
ページネーションの実装にはgem
を活用
- 複数ユーザーを作成する場合
gem faker
が便利
ほか
redirect
はmethodの最終行や、returnが明示された後に実行されるcreate!
ユーザーが無効な場合にfalse
を返すのではなく例外を発生させる > デバックの効率化Markdownエディター"Typora"最高
強力なMarkdownエディタ「Typora」に今更入門 - Qiita
- 引用URLの取得にsimple url copyというChrome extentionが便利
【Chrome Extension】簡単にURLとタイトルをコピーできる「simple-url-copy」作りました - フリーランチ食べたい
関連した学び
いい加減sessionってなんぞ?となったので調べてみた
Sessionはブラウザを閉じると消去されるように設計されたCookieであり、それをやり取りするメソッドだった: Railsチュートリアル備忘録 - RailsのSessionとは? - Qiita
必要要件
ユーザを更新できる
get edit
に対応するedit
アクションとビューファイルform with
部分はユーザー新規作成時のフォームを再利用可能
patch user_path(@user)
に対応するupdate
アクション- 正しい内容を入力した場合
- flashメッセージが表示される
- プロフィールページにリダイレクト
- テータベースの内容が変更される
- パスワードは空欄でも動作する
- 正しくない内容(validationに引っかかる)を入力した場合
- 誤った内容で
patch
リクエストを送信 - editページを
render
- エラーに応じたflashメッセージが表示される
- 誤った内容で
セキュリティモデルを適応する
- ログインした状態でのみ
edit
,update
アクションが実行可能にする- ログインせずに
edit
,update
アクションをリクエストした場合 - エラーメッセージを表示し
- ログインページにリダイレクトする
- ログインせずに
アカウントの所有者のみが、情報を編集できるようにする
- ログインしたユーザーと異なるユーザーに対する
edit
,update
アクションがリクエストされた場合 - エラーメッセージを表示し
root_path
にリダイレクトする
- ログインしたユーザーと異なるユーザーに対する
フレンドリーフォワーディング
- アクセスしようとしたページにリダイレクトする
ユーザー一覧を表示する
- このページはログインしていないと表示されない
- Userモデルの
index
アクション、index.html.erb
の作成 - DB上のユーザが一覧で表示される(画像、名前(プロフィールリンク)の構造)
ユーザーを削除できる
- ユーザーを削除できる管理者機能を付与する
- 管理者のみがユーザー一覧ページよりユーザーを削除できる
- 管理者自身を削除できないようにする
備忘録:第10章ユーザーの更新・表示・削除
10.1 ユーザーを更新する
10.1.1 編集フォーム
Viewでインスタンス変数を使えるようにする
URLの構造が/users/1/editでユーザーのidはparams[:id]
で取り出せる
defedit@user=User.find(params[:id])end
app/views/users/edit.html.erbを作成
$ touch app/views/users/edit.html.erb
edit.html.erbと重複が多いのでパーシャル化することで省略できるとのことだが
そもそもnew
とedit
で求められる機能が異なるにも関わらず同じコード(form with
)で記述できることに疑問が浮かぶ
Progateだとhtmlのフォームタグを直接記述して<input ... value=<%=... %> >
と記述していたような
ブラックボックス感が強く苦手意識ありましたが
この章で理解が深まりました
参考
【Rails】form_with/form_forについて【入門】 - Qiita
演習:
target="_blank"
のセキュリティ上の問題を埋めるrel="noopener"
<ahref="https://gravatar.com/emails"target="_blank"rel="noopener">change</a>
form with
部分をパーシャル化する
$ touch app/views/users/_form.html.erb
ここにform with部分を挿入new.html.erb
とedit.html.erb
でボタン部分のテキストのみ異なるので
各ページで<% provide (:button_text, '(text)') %>と与えて
パーシャルの中身ではyield(:button_text)
で呼び出す
この時点ではupdate
アクションがまだ定義されていない
10.1.2 編集の失敗
失敗に対応したupdate
アクションの定義create
とほぼ同様の挙動、'user_params'メソッドでStrong Parametersを利用update
アクションを用いることが異なる
def update
@user = User.find(params[:id])
if @user.update(user_params)
else
render 'edit'
end
end
10.1.3 編集失敗時のテスト
$ rails generate integration_test users_edit
validationに引っかかる内容でpatch
リクエストを送る挙動を再現すればいい
test "unsuccessful edit" do
get edit_user_path(@user)
assert_template 'users/edit'
patch user_path(@user), params: { user: { name: "",
email: "foo@invalid",
password: "foo",
password_confirmation: "bar" } }
assert_template 'users/edit'
end
演習:
上記で4つのエラーが含まれておりそれをエラーメッセ時から検証したい
追加すべき内容は
assert_select"div.alert","The form contains 4 errors."
ちょっと応用してさっきのパーシャル化したformが正しく働いているかを確認するオリジナルテスト
test"correct form for edit"dogetedit_user_path(@user)assert_select'input[name=commit][value="Save changes"]'end
10.1.4 TDDで編集を成功させる
想定されるユーザーエクスペリエンスをもとに
テストを定義する
パスワードを変更しないためpasswordはblankになっている
test"successful edit"dogetedit_user_path(@user)assert_template'users/edit'name="Foo Bar"email="foo@bar.com"patchuser_path(@user),params: {user: {name: name,email: email,password: "",password_confirmation: ""}}assert_notflash.empty?assert_redirected_to@user@user.reloadassert_equalname,@user.nameassert_equalemail,@user.emailend
現時点でtestは(RED)
@user.update(user_params)が成功した場合のedit
アクションを追加する
flash[:success]="Profile updated"redirect_to@user
まだtestは(RED)
パスワードが空欄(nil
)となっていることがvalidation
にかかっている
nil
をスルーさせるためにvalidationにallow_nil: true
を加える
validates:password,presence: true,length: {minimum: 6},allow_nil: true
新規登録時はhas_secure_password
によるvalidation
で空のパスワードを防ぐことができる
これでtest (GREEN)
10.2 認可
10.2.1 ユーザーにログインを要求する
セキュリティモデルを実装するbefore_action
を用いるのがよい
before_action:logged_in_user,only: [:edit,:update]
logged_in_user
をprivate
で定義
...privatedeflogged_in_userunlesslogged_in?flash[:danger]="Please log in"redirect_tologin_urlendendend
unlessの挙動については
【初心者必見】Rubyのunlessの使い方まとめ! | 侍エンジニア塾ブログ(Samurai Blog) - プログラミング入門者向けサイト
ここで`rails test
とするとUsersEditTestに3 failures
テスト環境でログインできていないことが原因
すでに実装済みのテストヘルパーlog_in_as(user)
を活用するとよいtest/integration/users_edit_test.rb
のテストに
それぞれlog_in_as(@user)
を追加
再びテストで(GREEN)
ただしこの状態ではbefore_actionをコメントアウトしてもテストは(GREEN)
セキュリティモデルが機能していることを確認するためにedit
, update
それぞれのアクションが想定する動作をしているか検証する
test"should..."gogetedit_user_path(@user)# リクエスト(or patch)assert_notflash.empty?# エラーメッセージがあるassert_redirected_tologin_url# リダイレクトend
これでbefore_actionのコメントアウトを外せばrails test
で(RED) > (GREEN)
10.2.2 正しいユーザーを要求する
アカウントの所有者のみがアカウントの情報を編集できるようにする
TDD開発ですすめる
テストに必要なべつのユーザーをつくるtest/fixtures/users.yml
fixtureファイルに二人目を定義
setupにて@other_user
を定義
defsetup@user=users(:michael)@other_user=users(:archer)end
テストの流れは
先程のテストにlog_in_as(@other_user)
が加わり、リダイレクト先がroot_url
へ
test"should..."dolog_in_as(@other_user)# other_userでログインgetedit_user_path(@user)# @userのedit(もしくはupdate)アクションをリクエストassertflash.empty?# エラーメッセージがあるassert_redirected_toroot_url#リダイレクトend
ここで追加した2つのテストがfailure
同様にbefore_actionでセキュリティモデルを実装していく
before_action:correct_user,only: [:edit,:update]
やはり同様にprivate
にcorrect_user
を定義する
defcorrect_user@user=User.find(params[:id])redirect_to(root_url)unless@user==current_userend
これでrails test
(GREEN)
@user == current_user
の部分を
論理値を返すcurrent_user?
をヘルパーメソッドとして定義することで書き直す
defcurrent_user?(user)user&&user==current_userend
これを使って実際にコードを書き直すと
可読性が増す
defcorrect_user@user=User.find(params[:id])redirect_to(root_url)unlesscurrent_user?(@user)end
10.2.3 フレンドリーフォワーディング
再びTDD
もはややりたいことをテストで書いたほうが理解しやすい
test"successful edit with friendly forwarding"dogetedit_user_path(@user)# editアクションをリクエスト(ログインしていない)log_in_as(@user)# ログインするassert_redirected_toedit_user_url(@user) # アクセスしようとしていたページにリダイレクトさせたい...end
これを
アクセスしようとしたURLを記憶するstore_location
と
実際にそのURLにリダイレクトするredirect_back_or
の2つのヘルパーメソッドを使って実装
まずstore_location
request.original_url
はリクエストされたURLを返すif request.get?
で制限しないと生じる不具合について想像できなかったが
このようにすることが望ましいとのこと
defstore_locationsession[:forwarding_url]=request.original_urlifrequest.get?end
ここで以下のような疑問が
しかしこれはうまく回避される(後述)
getedit#ログインしていないならloginにリダイレクトgetlogin#ここで`session[:forwarding_url]`が更新されないの?
これをbeforeアクションlogged_in_user
に追加
deflogged_in_userunlesslogged_in?store_locationflash[:danger]="Please log in."redirect_tologin_url endend
このbefore_action
はget login
リクエストに対して実行されないので
先程の疑問はうまく回避されることになる
つぎにredirect_back_or(default)
を定義
defredirect_back_or(default)redirect_to(session[:forwarding_url]||default)session.delete(:forwarding_url)end
値がnil
でなければsession[:forwarding_url]
が
そうでなければdefault
へリダイレクト
session.delete(:forwarding_url)
も重要forwarding_url
へのリダイレクトが繰り返さえることを防ぐ
SessionsController
のcreate
アクションにredirect_back_or
を追加
defcreateuser=User.find_by(email: params[:session][:email].downcase)ifuser&&user.authenticate(params[:session][:password])log_inuserparams[:session][:remember_me]=='1'?remember(user):forget(user)redirect_back_oruserelseflash.now[:danger]='Invalid email/password combination'render'new'endend
10.3 すべてのユーザーを表示する
すべてのユーザーを一覧表示するindex
アクションを追加する
10.3.1 ユーザーの一覧ページ
ユーザー一覧のページはログインしていない状態では表示させないようにしたい
TDDですね
test"should redirect index when not logged in"dogetusers_path#ログインせずにリクエストassert_redirected_tologin_url# loginへリダイレクトend
$ rails test
> (RED)
すでに実装済みのbefore_action
が利用できる
beforeフィルターを書き換える
before_action:logged_in_user,only: [:index,:edit,:update]
index
アクションがないよと言われるので
Userモデルに定義
defindexend
ここで$ rails test
> (GREEN)
Viewファイルを作成していく
インスタンス変数を定義して
defindex@users=User.allend
ユーザーを一覧で表示するために繰り返し処理を用いる
<%provide(:title,'Allusers')%><h1>All users</h1><ulclass="users"><%@users.eachdo|user|%><li><%=gravatar_foruser,size:50%><%=link_touser.name,user%></li><%end%></ul>
CSSに手を加えて
Headerのリンクを修正
$ rails test
> (GREEN)
演習
レイアウトにあるすべてのリンクに対して統合テストを書いてみましょう。ログイン済みユーザーとそうでないユーザーのそれぞれに対して、正しい振る舞いを考えてください。ヒント:
log_in_as
ヘルパーを使ってリスト 5.32にテストを追加してみましょう。
私はこう書きました
require'test_helper'classSiteLayoutTest<ActionDispatch::IntegrationTestdefsetup@user=users(:michael)endtest"layout-links when not login"dogetroot_pathassert_template'static_pages/home'assert_select"a[href=?]",root_path,count: 2assert_select"a[href=?]",help_pathassert_select"a[href=?]",about_pathassert_select"a[href=?]",contact_pathgetcontact_pathassert_select"title",full_title("Contact")getsignup_pathassert_select"title",full_title("Sign up")endtest"layout-links when login"dolog_in_as@usergetroot_pathassert_template'static_pages/home'assert_select"a[href=?]",root_path,count: 2assert_select"a[href=?]",help_pathassert_select"a[href=?]",about_pathassert_select"a[href=?]",contact_pathassert_select"a[href=?]",users_pathassert_select"a[href=?]",user_path(@user)assert_select"a[href=?]",edit_user_path(@user)assert_select"a[href=?]",logout_pathendend
$ rails test
> (GREEN)
10.3.2 サンプルのユーザー
ユーザーを生成するfaker
gemを利用する
(本来開発環境以外では使用しないが、後で使うのですべての環境で利用できるようにする)
Gemfile
の編集とbundle install
したら
db/seeds.rb
にユーザーを追加するコードを書く
# メインのサンプルユーザーを1人作成するUser.create!(name: "Example User",email: "example@railstutorial.org",password: "foobar",password_confirmation: "foobar")# 99人分の追加のユーザー生成する99.timesdo|n|name=Faker::Name.nameemail="example-#{n+1}@railstutorial.org"password="password"User.create!(name: name,email: email,password: password,password_confirmation: password)end
create!
は基本的にcreate
メソッドと同じものですが、ユーザーが無効な場合にfalse
を返すのではなく例外を発生させる点が異なります。こうしておくと見過ごしやすいエラーを回避できるので、デバッグが容易になります。
$ rails db:migrate:reset
$ rails db:seed
データベースにユーザーを追加してくれる
10.3.3 ページネーション
ページネーション=ページで分割する
ページネーション用のgem
will_paginate
と
それにBootstrapのページネーションスタイルを適応するgem
bootstrap-will_paginate
をGemfileに追加してbundle install
ユーザーのリスト上下に<%= will_paginate %>
を置く(ガイド用のリンクになる)
そして<% @users.each do |user| %>
の@uses
にpaginate
されたUser.all
を渡せば良いindex
アクションの部分で
defindex@users=User.paginate(page: params[:page])end
params[:page]
の部分はビューに埋め込まれたwill_paginate
が生成して渡してくれる
こうすることでデフォルトで30サンプルずつにページネートしpage:
でリクエストされたページのUserオブジェクトを返す(page:2
なら31-60個目まで)
ブラウザでプレビューするのにrails server
の再起動が必要でした
演習
Railsコンソールを開き、pageオプションにnilをセットして実行すると、1ページ目のユーザーが取得できることを確認してみましょう。
途中で、あれ?はじめてindexビューが表示されるときは?と思ったのですがparams[:page] = nil
でparams[:page] = 1
のときと同じ結果が返されます
先ほどの演習課題で取得したpaginationオブジェクトは、何クラスでしょうか? また、User.allのクラスとどこが違うでしょうか? 比較してみてください。
>> User.paginate(page: 1).class
=> User::ActiveRecord_Relation
>> User.all.class
=> User::ActiveRecord_Relation
どちらも同じUser::ActiveRecord_Relation
クラスです
ビューに埋め込まれたガイドがページをリクエストしpaginate
メソッドがリクエストに応じたidのデータを抽出しリロードしている
というような挙動と想像されます
10.3.4 ユーザー一覧のテスト
以下を検証する
ログイン (log_in_as
)
indexページにアクセス(get
)
最初のページにユーザーがいることを確認 (page: nil
の挙動を確認)
ページネーションのリンクがあることを確認 (assert_select
)
(テスト用のデータベースに31人以上のユーザー必要)
テスト環境なのでtest/fixtures/users.yml
に31人分のfixturesを追加
<% 30.times do |n| %>
user_<%= n %>:
name: <%= "User #{n}" %>
email: <%= "user-#{n}@example.com" %>
password_digest: <%= User.digest('password') %>
<% end %>
統合テストをつくる
$ rails generate integration_test users_index
require'test_helper'classUsersIndexTest<ActionDispatch::IntegrationTestdefsetup@user=users(:michael)endtest"index including pagination"dolog_in_as(@user)getusers_pathassert_template'users/index'assert_select'div.pagination'User.paginate(page: 1).eachdo|user|assert_select'a[href=?]',user_path(user),text: user.nameendendend
each.do
のところが分かりづらかったですが
paginate
されたUserオブジェクトに対応するassert_select 'a[href=?]', user_path(user), text: user.name
つまり<a href=#{user_path(user)}...>#{user.name}</a>
が存在するか確認していると思われる
$ rails test
> (GREEN)
10.3.5 パーシャルのリファクタリング
<%= render @users %>
Userを列挙し_user.html.erb
パーシャルで出力する
パーシャル部分にリストを仕込んでおけば良い
DRYであるがもはや変態
10.4 ユーザーを削除する
10.4.1 管理ユーザー
User
モデルに新たな属性admin
(boolean型)を追加する
$ rails generate migration add_admin_to_users admin:boolean
$ rails db:migrate
とするとadmin?
で論理値を返してくれるようになる
Strong Parametersを設定したことでこのadminは書き換えることができない
admin権限が付与されては困るのでセキュリティを考えると重要
defuser_paramsparams.require(:user).permit(:name,:email,:password,:password_confirmation)end
演習
Web経由でadmin属性を変更できないことを確認してみましょう。具体的には、リスト 10.56に示したように、PATCHを直接ユーザーのURL(/users/:id)に送信するテストを作成してみてください。テストが正しい振る舞いをしているかどうか確信を得るために、まずはadminをuser_paramsメソッド内の許可されたパラメータ一覧に追加するところから始めてみましょう。最初のテストの結果は red になるはずです。最後の行では、更新済みのユーザー情報をデータベースから読み込めることを確認します( 6.1.5)。
以下のうように書きました
test"should not allow the admin attribute to be edited via the web"dolog_in_as(@other_user)assert_not@other_user.admin?patchuser_path(@other_user),params: {user: {password: "password",password_confirmation: "password",admin: true}}# admin: trueをpatchしてもassert_not@other_user.reload.admin?# reloadでDBからデータを取ってくるとadmin: falseend
$ rails test
> (GREEN)
10.4.2 destroyアクション
<% if current_user.admin?&&!current_user?(user)%>|<%= link_to "delete", user, method: :delete,
data: { confirm: "You sure?" } %>
<% end %>
current_user.admin? && !current_user?(user)
の部分ですが
管理者でログインしており、自分のプロフィールでない(!
)場合に削除リンクを表示するという挙動です
destroy
アクションを定義
defdestroyUser.find(params[:id]).destroyflash[:success]="User deleted"redirect_tousers_urlend
リンクを隠しただけでなくdestroy
アクションへのアクセスも制限されるべき
以下のような構成にする
classUsersController<ApplicationControllerbefore_action:logged_in_user,only: [:index,:edit,:update,:destroy] #beforeフィルター: ログインされているかbefore_action:correct_user,only: [:edit,:update]before_action:admin_user,only: :destroy#beforeフィルター: 管理者であるか...private...# 管理者かどうか確認defadmin_userredirect_to(root_url)unlesscurrent_user.admin?endend
10.4.3 ユーザー削除のテスト
fixtureの一つをadmin: true
に
まずは直前に定義したUsersControllerのアクション単位でテストを行って
その後統合テストへ
アクションのテストはdestroy
アクションが呼ばれたときに
ログインしていなければユーザーが削除されることなくlogin_path
にリダイレクト
管理者でなければユーザーが削除されることなくroot_path
にリダイレクトを確認
まず前者
削除されていないことをUser.count
が変化しないと考える
test"should redirect destroy when not logged in"doassert_no_difference`User.count`dodeleteuser_path(@user)endassert_redirected_tologin_urlend
assert_no_differenceはブロック内の処理を実施した前後で評価結果が変わらないことを主張する
Minitestのassert_differenceメソッドについて - Qiita
次に統合テスト/sample_app/test/integration/users_index_test.rb
を再利用
フローをまとめると
- adminユーザーでログイン
- レイアウト、リンクの確認(実装済み)
- admin(自身)以外のプロフィールに
<a href=... >delete</a>
が含まれる delete user_path(user)
でユーザーが削除されることを確認
削除されるユーザーをもうひとり定義する必要がある
フローを書き出してから自分でコードにしてみた
require'test_helper'classUsersIndexTest<ActionDispatch::IntegrationTestdefsetup@admin=users(:michael)@non_admin=users(:archer)endtest"index as admin including pagination and delete links"dolog_in_as(@admin)getusers_pathassert_template'users/index'assert_select'div.pagination',count: 2#演習を反映User.paginate(page: 1).eachdo|user|#ローカル変数を使っていないassert_select'a[href=?]',user_path(user),text: user.nameunlessuser.admin?#user = @admin 自分自身は消せないの意ならこっちかassert_select'a[href=?]',user_path(user),text: 'delete'# ''(シングルクォート)忘れてエラーendendassert_difference'User.count',-1dodeleteuser_path(@non_admin)endendtest"index as non-admin"dolog_in_as(@non_admin)getusers_pathassert_select'a',text: 'delete',count: 0endend
多少違う箇所があるが意図はくめていると思う
unless部分をはじめ以下のように書いたが
一行が長くなってしまうので可読性の観点から無理にDRYにするのをやめた
assert_select'a[href=?]',user_path(user),text: 'delete'unlessuser.admin?