Quantcast
Channel: 初心者タグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 21093

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

$
0
0

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が便利

ほか

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

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

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

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]

やはり同様にprivatecorrect_userを定義する

defcorrect_user@user=User.find(params[:id])redirect_to(root_url)unless@user==current_userend

これでrails test(GREEN)

@user == current_userの部分を
論理値を返すcurrent_user?をヘルパーメソッドとして定義することで書き直す

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

SessionsControllercreateアクションに
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 ページネーション

ページネーション=ページで分割する

ページネーション用のgemwill_paginate
それにBootstrapのページネーションスタイルを適応する
gembootstrap-will_paginateをGemfileに追加して
bundle install

ユーザーのリスト上下に
<%= will_paginate %>を置く(ガイド用のリンクになる)

そして<% @users.each do |user| %>@usespaginateされた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] = 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'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?

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


Viewing all articles
Browse latest Browse all 21093

Trending Articles