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 3 years have passed since last update.

TDDでテストが書けないことは、論文が書けないことと似ている : Railsチュートリアル備忘録 - 10章

Posted at

Abstract

目標:

editupdateindexdestroyアクションを実装し、UserモデルのRESTアクションを完成させる

この章の気付き

TDDはユーザーの意図(〜したい)と相性が良い

  • 〜したいという意図をもとに考えるとテストがスムーズに書ける
  • 演繹法的思考プロセス
  • 〜したいという引き出しの多さ、セオリー的な知識・経験の蓄積も重要かと
  • 経験的に、限られたリソースで必要十分なアウトプットをするためにはこういった思考プロセスが重要

可読性という概念

  • unless表記はときに可読性の向上に役立つ
  • (実行内容) unless helper_method?(論理値を返すヘルパーメソッド)の表記が慣例
  • テストコードにおいては特に無理にDRYにせず、他者からのレビューを意識して書くべき
  • 可読性の高さ=シンプルなで的確なロジックであり、テストの目的とよく合致するかもしれない

セキュリティ対策

  • Strong Parametersの設定は重要
  • 編集可能なものだけ設定する、その他は許可しない、という振る舞いが重要
  • リンクを隠すだけでなく、リクエストそのものを潰す必要性に気づいた

セキュリティモデルの実装にはbefore_actionを活用する

  • only: [...]でbefore_actionの適応される範囲を制限可能
  • before_actionで定義するアクションはprivateへ(ここちょっと曖昧

editnewアクションの共通点と差異、それを吸収する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が便利

ほか

関連した学び

いい加減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と重複が多いのでパーシャル化することで省略できるとのことだが

そもそもneweditで求められる機能が異なるにも関わらず同じコード(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.erbedit.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_userprivateで定義

.
.
.  
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]

やはり同様にprivatecorrect_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?をヘルパーメソッドとして定義することで書き直す

app/helpers/sessions_helper.rb
	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_actionget 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へのリダイレクトが繰り返さえることを防ぐ

SessionsControllercreateアクションに
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| %>@usespaginateされた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] = nilparams[: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?

テストコードの期待値はDRYを捨ててベタ書きする ~テストコードの重要な役割とは?~ - Qiita

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?