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"最高
-
引用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]
で取り出せる
def edit
@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"
<a href="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" do
get edit_user_path(@user)
assert_select 'input[name=commit][value="Save changes"]'
end
10.1.4 TDDで編集を成功させる
想定されるユーザーエクスペリエンスをもとに
テストを定義する
パスワードを変更しないためpasswordはblankになっている
test "successful edit" do
get edit_user_path(@user)
assert_template 'users/edit'
name = "Foo Bar"
email = "foo@bar.com"
patch user_path(@user), params: { user: { name: name,
email: email,
password: "",
password_confirmation: "" } }
assert_not flash.empty?
assert_redirected_to @user
@user.reload
assert_equal name, @user.name
assert_equal email, @user.email
end
現時点で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
で定義
.
.
.
private
def logged_in_user
unless logged_in?
flash[:danger] = "Please log in"
redirect_to login_url
end
end
end
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..." go
get edit_user_path(@user) # リクエスト(or patch)
assert_not flash.empty? # エラーメッセージがある
assert_redirected_to login_url # リダイレクト
end
これでbefore_actionのコメントアウトを外せば
rails test
で(RED) > (GREEN)
10.2.2 正しいユーザーを要求する
アカウントの所有者のみがアカウントの情報を編集できるようにする
TDD開発ですすめる
テストに必要なべつのユーザーをつくる
test/fixtures/users.yml
fixtureファイルに二人目を定義
setupにて@other_user
を定義
def setup
@user = users(:michael)
@other_user = users(:archer)
end
テストの流れは
先程のテストにlog_in_as(@other_user)
が加わり、リダイレクト先がroot_url
へ
test "should..." do
log_in_as(@other_user) # other_userでログイン
get edit_user_path(@user) # @userのedit(もしくはupdate)アクションをリクエスト
assert flash.empty? # エラーメッセージがある
assert_redirected_to root_url #リダイレクト
end
ここで追加した2つのテストがfailure
同様にbefore_actionでセキュリティモデルを実装していく
before_action :correct_user, only: [:edit, :update]
やはり同様にprivate
にcorrect_user
を定義する
def correct_user
@user = User.find(params[:id])
redirect_to(root_url) unless @user == current_user
end
これでrails test
(GREEN)
@user == current_user
の部分を
論理値を返すcurrent_user?
をヘルパーメソッドとして定義することで書き直す
def current_user?(user)
user && user == current_user
end
これを使って実際にコードを書き直すと
可読性が増す
def correct_user
@user = User.find(params[:id])
redirect_to(root_url) unless current_user?(@user)
end
10.2.3 フレンドリーフォワーディング
再びTDD
もはややりたいことをテストで書いたほうが理解しやすい
test "successful edit with friendly forwarding" do
get edit_user_path(@user) # editアクションをリクエスト(ログインしていない)
log_in_as(@user) # ログインする
assert_redirected_to edit_user_url(@user) # アクセスしようとしていたページにリダイレクトさせたい
.
.
.
end
これを
アクセスしようとしたURLを記憶するstore_location
と
実際にそのURLにリダイレクトするredirect_back_or
の2つのヘルパーメソッドを使って実装
まずstore_location
request.original_url
はリクエストされたURLを返す
if request.get?
で制限しないと生じる不具合について想像できなかったが
このようにすることが望ましいとのこと
def store_location
session[:forwarding_url] = request.original_url if request.get?
end
ここで以下のような疑問が
しかしこれはうまく回避される(後述)
get edit #ログインしていないならloginにリダイレクト
get login #ここで`session[:forwarding_url]`が更新されないの?
これをbeforeアクションlogged_in_user
に追加
def logged_in_user
unless logged_in?
store_location
flash[:danger] = "Please log in."
redirect_to login_url
end
end
このbefore_action
はget login
リクエストに対して実行されないので
先程の疑問はうまく回避されることになる
つぎにredirect_back_or(default)
を定義
def redirect_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
を追加
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
log_in user
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
redirect_back_or user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
10.3 すべてのユーザーを表示する
すべてのユーザーを一覧表示するindex
アクションを追加する
10.3.1 ユーザーの一覧ページ
ユーザー一覧のページはログインしていない状態では表示させないように___したい___
TDDですね
test "should redirect index when not logged in" do
get users_path #ログインせずにリクエスト
assert_redirected_to login_url # loginへリダイレクト
end
$ rails test
> (RED)
すでに実装済みのbefore_action
が利用できる
beforeフィルターを書き換える
before_action :logged_in_user, only: [:index, :edit, :update]
index
アクションがないよと言われるので
Userモデルに定義
def index
end
ここで$ rails test
> (GREEN)
Viewファイルを作成していく
インスタンス変数を定義して
def index
@users = User.all
end
ユーザーを一覧で表示するために繰り返し処理を用いる
<% provide(:title, 'All users') %>
<h1>All users</h1>
<ul class="users">
<% @users.each do |user| %>
<li>
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
</li>
<% end %>
</ul>
CSSに手を加えて
Headerのリンクを修正
$ rails test
> (GREEN)
演習
レイアウトにあるすべてのリンクに対して統合テストを書いてみましょう。ログイン済みユーザーとそうでないユーザーのそれぞれに対して、正しい振る舞いを考えてください。ヒント:
log_in_as
ヘルパーを使ってリスト 5.32にテストを追加してみましょう。
私はこう書きました
require 'test_helper'
class SiteLayoutTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
test "layout-links when not login" do
get root_path
assert_template 'static_pages/home'
assert_select "a[href=?]", root_path, count: 2
assert_select "a[href=?]", help_path
assert_select "a[href=?]", about_path
assert_select "a[href=?]", contact_path
get contact_path
assert_select "title", full_title("Contact")
get signup_path
assert_select "title", full_title("Sign up")
end
test "layout-links when login" do
log_in_as @user
get root_path
assert_template 'static_pages/home'
assert_select "a[href=?]", root_path, count: 2
assert_select "a[href=?]", help_path
assert_select "a[href=?]", about_path
assert_select "a[href=?]", contact_path
assert_select "a[href=?]", users_path
assert_select "a[href=?]", user_path(@user)
assert_select "a[href=?]", edit_user_path(@user)
assert_select "a[href=?]", logout_path
end
end
$ 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.times do |n|
name = Faker::Name.name
email = "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
アクションの部分で
def index
@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'
class UsersIndexTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
test "index including pagination" do
log_in_as(@user)
get users_path
assert_template 'users/index'
assert_select 'div.pagination'
User.paginate(page: 1).each do |user|
assert_select 'a[href=?]', user_path(user), text: user.name
end
end
end
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権限が付与されては困るのでセキュリティを考えると重要
def user_params
params.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" do
log_in_as(@other_user)
assert_not @other_user.admin?
patch user_path(@other_user), params: {
user: { password: "password",
password_confirmation: "password",
admin: true } } # admin: trueをpatchしても
assert_not @other_user.reload.admin? # reloadでDBからデータを取ってくるとadmin: false
end
$ 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
アクションを定義
def destroy
User.find(params[:id]).destroy
flash[:success] = "User deleted"
redirect_to users_url
end
リンクを隠しただけでなく
destroy
アクションへのアクセスも制限されるべき
以下のような構成にする
class UsersController < ApplicationController
before_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
.
.
.
# 管理者かどうか確認
def admin_user
redirect_to(root_url) unless current_user.admin?
end
end
10.4.3 ユーザー削除のテスト
fixtureの一つをadmin: true
に
まずは直前に定義したUsersControllerのアクション単位でテストを行って
その後統合テストへ
アクションのテストはdestroy
アクションが呼ばれたときに
ログインしていなければユーザーが削除されることなくlogin_path
にリダイレクト
管理者でなければユーザーが削除されることなくroot_path
にリダイレクトを確認
まず前者
削除されていないことをUser.count
が変化しないと考える
test "should redirect destroy when not logged in" do
assert_no_difference `User.count` do
delete user_path(@user)
end
assert_redirected_to login_url
end
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'
class UsersIndexTest < ActionDispatch::IntegrationTest
def setup
@admin = users(:michael)
@non_admin = users(:archer)
end
test "index as admin including pagination and delete links" do
log_in_as(@admin)
get users_path
assert_template 'users/index'
assert_select 'div.pagination', count: 2 #演習を反映
User.paginate(page: 1).each do |user| #ローカル変数を使っていない
assert_select 'a[href=?]', user_path(user), text: user.name
unless user.admin? #user = @admin 自分自身は消せないの意ならこっちか
assert_select 'a[href=?]', user_path(user), text: 'delete' # ''(シングルクォート)忘れてエラー
end
end
assert_difference 'User.count', -1 do
delete user_path(@non_admin)
end
end
test "index as non-admin" do
log_in_as(@non_admin)
get users_path
assert_select 'a', text: 'delete', count: 0
end
end
多少違う箇所があるが意図はくめていると思う
unless部分をはじめ以下のように書いたが
一行が長くなってしまうので可読性の観点から無理にDRYにするのをやめた
assert_select 'a[href=?]', user_path(user), text: 'delete' unless user.admin?