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.ymlfixtureファイルに二人目を定義
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?