- 投稿日:2019-11-12T23:25:08+09:00
[Ruby]使用頻度は高くないがここぞという時に知っていたら便利そうなメソッドたち
使用頻度は高くないので忘れちゃいそうだけど、ここぞという時に知っていたら便利そうなメソッドを備忘のためにまとめておきます。
(便利かつ高頻度で使うものは忘れないと思うのでここには書きません)今後も便利そうだと思うメソッドがあったら随時追加していく予定です。
clamp
数値の上限・下限を制限する
523.clamp(0, 100) # => 100email_address_with_name
action_mailerのtoに<>や"が含まれていた場合、エスケープしてくれる
# <>や"がnameやemailに入っていてもエスケープしてくれる。 mail(to: email_address_with_name(@user.email, @user.name))squish
スペースやタブ、改行をいい感じに除去してくれる
" \n foo\n\r \t bar \n".squish # => "foo bar"
- 投稿日:2019-11-12T23:11:47+09:00
【Rails】こわくない!テスト自動化はじめの一歩ハンズオン!
Abstract
TDD/BDDは素晴らしいです!テスト自動化は感動的です!この感動を多くの人に感じてもらいたい!
でも実際、テスト自動化と聞くとハードルありますよね。テストコードって難しいんじゃないかとか、時間かかるし手動でテストすればいいんじゃないかとか。でもそうじゃない!
ということで、この記事ではハードルをものすごく下げて
- TDD/BDDのことを語る
- RailsアプリのE2Eテスト自動化をハンズオンする
をしてみたいと思います。
最初にお断りですが、E2EテストをするためのRSpecやCapybaraのいろいろな使い方については語らないです。そのあたりは
などがとても参考になるので...
同じ操作・検証をするにも色々な書き方があるので、今回の記事の書き方はあくまで一例ということでお願いします!なぜTDD/BDDなのか
みなさんは
- あいつらのシステム要件が何言ってるかわからん
- どこまで作ったら開発終わるのかわからん
- テスト工程で初めて理解した。齟齬ってたことを。
みたいな経験ないですか?もしくは
- システム要件って何提示すればいいかわからん
- 開発者たち、念入りにテストしすぎじゃね?
- 齟齬が発覚したときにはもう直せないと言われた、最初からそのつもりだったのに
みたいな経験(プロダクトオーナー目線)。
TDD/BDDはこいつらを解決できます!
TDD/BDDはTest Driven Development(テスト駆動開発)とBehavior Driven Development(振る舞い駆動開発)のことです。"駆動開発"は開発において最も大切にしていること、を表していると思うのですが、そうなると"テスト"と"振る舞い"を大切にしている開発のことです。
TDDは最初にテスト定義しましょう、という開発です。最初っていつ?というと最初、つまりシステム要件定義の段階だと私は思っています。
たとえばウォーターフォール開発では、要件定義・基本設計・詳細設計などのフェーズを踏みコーディングをした後、各フェーズのoutputをinputとしてテストを設計していくかと思いますが、じゃあ最初から各フェーズのoutputがテストならいんじゃない?という考え方ですかね。TDDのいいところは、"明確"になることです。テストはOKかNGしかありません(OK/NGしかないように定義しなければいけません)。
どんなに読みやすい設計書があろうと、軽量なコードがあろうと、テストがNGならダメです。TDDは最初にテストを定義することで、頑張ってコーディングしたけどテスト設計したら要件と違った、ということを防いでくれます。また、最初にテストを定義することで、テストがAll OKになることが完了であることが"明確"になります。
BDDはTDDの派生として、プロダクトの振る舞いを定義します。BDDの方がよりプロダクトのユーザーの行動に近いテストを定義するイメージになります。
わたしのTDD/BDD
わたしの場合は、システム要件としてアジャイル開発で用いられるユーザーストーリーを定義することから始めます。
Whoとして、Whatしたい、なぜならWhyからだ
という型に当てはめて、ストーリーを作っていきます。機能一覧に近いですが、ユーザーの行動が基準に置かれているのでいくつかの機能を束ねていたり、機能の一部のみが必要だったりもします。
例えば、弊サービス4Q4T(チームビルディングサポートツール)のケースだと、
- チームリーダーとして、チームを作成したい、なぜなら自分のチームで4Q4Tを使いよりよいチームを構築したいからだ
- チームリーダーとして、簡単な方法でメンバーを招待したい、なぜなら登録ハードルが低い方がメンバーが登録してくれやすいからだ
- チームメンバーとして、ドラッカー風エクササイズの質問に答えてほかのメンバーに見せたい、なぜならチームメンバーと期待を確認し合いたいからだ
みたいなものがストーリーになります。
さらにこのユーザーストーリーに対して、受入条件を定義しています。
受入条件の型はWho が Where で How の状態で What した場合、 Who は Where で What なること。
を基本としています。BDDのフレームワークツールなどが色々あるのですが、それらを参考に自分が使いやすい形をして求めて今はこの型にしてます。
例えば、上の「チームリーダーとして、チームを作成したい」というユーザーストーリーに対しては
- ユーザーはトップページにアクセスできること
- ユーザーがトップページで"Create a team"ボタンを選択した場合、ユーザーはチーム作成ページへ遷移すること
- ユーザーはチーム作成ページでチーム名を入力できること
- ユーザーがチーム作成ページでチーム名が未入力の状態で"Create"ボタンを選択した場合、ユーザーはチーム作成ページでチーム名未入力のエラーメッセージを受け取れること
- ユーザーがチーム作成ページでチーム名を入力した状態で"Create"ボタンを選択した場合、チームが作成され、ユーザーはそのチームのチームページへ遷移すること
といった具合です。こうやって型を作っておくことで、テストを素早く漏れなく定義できます。
テストは定義したけど…
ここまで定義できてしまえば、このテストが通るコードを書けばいいだけです。
ただ、少しコードをいじったら毎回テストって無理ですよね。リファクタ前と全く同じテストになってますか?デグレなんてありえないけどそのコードが他の機能に影響を与えないとも言い切れない。なので影響調査に時間をかけて慎重に、ってそれじゃ時代の変化にプロダクトが追いつかない。
そこでテスト自動化(Test automation)です!
テスト自動化は、テストをコーディングしてプログラムとして実行することで、プロダクトが定義したテスト通りの振る舞いをしているかをテストすることです。テストコードを書くのにはそれなりの時間が必要ですが、テスト自体は人手でやるよりも圧倒的に速く、再現性もあり、一度作成すれば何回も使いまわせます。
いいことだらけのテスト自動化、やるしかないですよね。ということで、今回はRailsアプリでテスト自動化ハンズオンを開催します!!
テスト自動化ハンズオン
準備
まずは、こちらからベースのRailsアプリケーションをcloneしてください。
$ git clone git@github.com:at946/rails-test-automation-hands-on.git $ cd rails-test-automation-hands-on $ docker-compose build $ docker-compose run web yarn install --check-files $ docker-compose run web rails db:create $ mkdir spec/systemRails on Docker(alpine)でdocker-seleniumを使わないでSelenium+RSpec+Capybaraでテスト自動化してみる - Qiita に記載している手順ですでにSelenium, RSpec, CapybaraがインストールされたRails on Dockerアプリです。
- rspec-rails : Railsのテストフレームワーク
- selenium-webdriver : ブラウザ操作をプログラムで実行できるテストツール
- capybara : RSpecやSeleniumのコードを書きやすくしてくれたりするテストフレームワーク
今回のハンズオンでは、めちゃくちゃシンプルなTodolistアプリをテストしていきましょう。
まず、Todolistアプリをscaffoldで作成していきます。
$ docker-compose run web rails g scaffold item name:string $ docker-compose run web rails db:migrateapp/models/item.rbclass Item < ApplicationRecord validates :name, presence: true validates :name, length: { maximum: 20 } end少しテストを面白くするために、
name
にnot nullと20文字以内のバリデーションをかけました。Structure
テストコードは
spec/system/
に*_spec.rb
ファイルを作成して記述していきます。テストコードの基本的な構文は以下の通りです。
spec/system/*_spec.rbfeature "ユーザーストーリー", type: :system, js: true do before :each do # 各シナリオの前に共通して実行される処理 # モデルの作成などで利用される # 作成されたモデルは各シナリオ終了時にクリアされる end scenario "受入条件1" do # operation... (操作) # expect().to... (検証) end scenario "受入条件2" do # operation... (操作) # expect().to... (検証) end endファイルはユーザーストーリー単位に作成しておくと管理がしやすくていいと思います。共通的な基本処理、例えばサイトにURL直打ちでアクセスできる、だとか、ヘッダーのロゴをクリックしたらトップページに遷移する、みたいなものも1ファイルとして管理するのもオススメです。
また、テスト実行は以下のコマンドで実行できます。
$ docker-compose run web rspec特定のファイルのみを指定してテストすることもできます。
$ docker-compose run web rspec spec/system/*_spec.rbサンプルアプリのユーザーストーリー
まずは今回のサンプルアプリのユーザーストーリー(US)を定義します。scaffoldに合わせてになりますが、RailsアプリだとRESTfulを大切にすることもあり大体以下のような感じになると思います。(Whyは省略)
- US1: ユーザーとして、ページにアクセスしたい
- US2: ユーザーとして、Todoアイテムを作成したい
- US3: ユーザーとして、Todoアイテムを確認したい
- US4: ユーザーとして、Todoアイテムを更新したい
- US5: ユーザーとして、Todoアイテムを削除したい
おまちかね、ここから先は実際にテストコードを書いていきます。
まず、ユーザーストーリーに対して受入条件を定義して、その後にテストコードを書いていきます。そして、テストコード内の書き方についてコメントを添えます。【US1】 ユーザーとして、ページにアクセスしたい
受入条件は以下の通り。scaffoldで用意されたViewに正しくアクセスできることです。
- ユーザーはTodoアイテム一覧ページ(
/items
)に直接アクセスできること- ユーザーはTodoアイテム作成ページ(
/items/new
)に直接アクセスできること- ユーザーはTodoアイテム詳細ページ(
/items/{:id}
)に直接アクセスできること- ユーザーはTodoアイテム編集ページ(
/items/{:id}/edit
)に直接アクセスできることでは、これらのテストコードを書いてみましょう!
spec/system/us1_access_page_spec.rbfeature "ユーザーとして、ページにアクセスしたい", type: :system, js: true do before :each do @item = Item.create(name: "ほげほげ申請する") end scenario "ユーザーはTodoアイテム一覧ページ(/items)に直接アクセスできること" do visit items_path expect(page).to have_current_path items_path expect(page).to have_text "Items" end scenario "ユーザーはTodoアイテム作成ページ(/items/new)に直接アクセスできること" do visit new_item_path expect(page).to have_current_path new_item_path expect(page).to have_text "New Item" end scenario "ユーザーはTodoアイテム詳細ページ(/items/{:id})に直接アクセスできること" do visit item_path @item expect(page).to have_current_path item_path @item expect(page).to have_text "Name: " end scenario "ユーザーはTodoアイテム編集ページ(/items/{:id}/edit)に直接アクセスできること" do visit edit_item_path @item expect(page).to have_current_path edit_item_path @item expect(page).to have_text "Editing Item" end endvisit *_path
visit
はパスにGETアクセスする操作です。後ろにURLや名前付きルーティングヘルパー(prefix)を指定して使います。
例えば、visit root_path
と書けば、config/routes.rb
でroot to:
に指定したルートパスにアクセスする操作ということになります。今回はscaffoldを使っているので、
config/routes.rb
にresources :items
が定義されています。
このおかげでテストコードに書いたような割り振りのprefixがつけられているというわけです。prefixは$ docker-compose run web rails routesで確認できます。
prefixはルーティング毎に
config/routes.rb
で定義できます。config/routes.rbRails.application.routes.draw do # method path, to: 'controller#action', as: 'prefix' get 'hoge', to: 'hoges#index', as: 'fuga' endこの例では、
fuga_path
が/hoge
のprefixになっています。expect(target).to *
expect
は検証をするためのメソッドです。target
に指定したものを検証対象として、to
以降に記載したものと一致するかどうかを検証します。to
の代わりにnot_to
と記載した場合は一致しないことの検証です。今回のテストコードでは
expect(page).to
という使い方をしているのでページ全体に対して検証を行っていると思えばいいと思います。また、
expect(find("#id")).to
のようにid
がid属性として定義されている要素に対して検証したりなどもできます。have_current_path *_path
have_current_path
で現在表示中のページのパスが*_path
と一致するかを検証しています。
ほかにも、expect(current_path).to eq *_path
という書き方でも同じ検証ができます。have_text text
have_text
で表示中のページにtext
の文字列が含まれるかを検証しています。
例えば、pathにはアクセスできたけど表示されているページは期待通りでない!となっていないかという観点で、それぞれのページ固有のワードを検証させてみました。【US2】 ユーザーとして、Todoアイテムを作成したい
受入条件は以下のような感じで。
name
に少し制約を入れているので少し条件が多いです。
- ユーザーがTodoアイテム一覧ページで"New Item"リンクを選択した場合、ユーザーはTodoアイテム作成ページへ遷移すること
- ユーザーはTodoアイテム作成ページでアイテム名を入力できること
- ユーザーがTodoアイテム作成ページでアイテム名が未入力の状態で"Create Item"ボタンを選択した場合、Todoアイテムは作成されず、Todoアイテム作成ページでTodoアイテム名未入力のエラーメッセージが表示されること
- ユーザーがTodoアイテム作成ページでアイテム名が21文字以上の状態で"Create Item"ボタンを選択した場合、Todoアイテムは作成されず、Todoアイテム作成ページでTodoアイテム名21文字以上のエラーメッセージが表示されること
- ユーザーがTodoアイテム作成ページでアイテム名が20文字以内の状態で"Create Item"ボタンを選択した場合、Todoアイテムは作成され、ユーザーはTodoアイテム作成成功メッセージが表示されたTodoアイテム詳細ページへ遷移すること
- ユーザーがTodoアイテム作成ページで"Back"リンクを選択した場合、ユーザーはTodoアプリ一覧ページへ遷移すること
テストコードを書く前に、buttonやlinkなど操作する要素にidを定義していきます。xpathやテキスト、cssなどでも操作をすることはできるのですが、idで操作するようにしておけばデザインや文字をいじったとしてもテストコードを変更することなく開発を進められるのでそうしています。
app/views/items/index.html.erb<%# "New Item"リンクに"new_item_link"のidをつける %> <%= link_to 'New Item', new_item_path, id: :new_item_link %>app/views/items/show.html.erb<%# "Edit"リンクに"edit_item_link"、"Back"リンクに"back_link"のidをつける %> <%= link_to 'Edit', edit_item_path(@item), id: :edit_item_link %> | <%= link_to 'Back', items_path, id: :back_link %>app/views/items/_form.html.erb<%# "Create Item"ボタンに"submit_item_button"のidをつける %> <%= form.submit nil, id: :submit_item_button %>app/views/items/new.html.erb<%# "Back"リンクに"back_link"のidをつける %> <%= link_to 'Back', items_path, id: :back_link %>app/views/items/edit.html.erb<%# "Show"リンクに"show_item_link"、"Back"リンクに"back_link"のidをつける %> <%= link_to 'Show', @item, id: :show_item_link %> | <%= link_to 'Back', items_path, id: :back_link %>これでid指定で要素を操作することができます。
ではテストコードを書いていきましょう!
spec/system/us2_create_todo_items_spec.rbfeature "ユーザーとして、Todoアイテムを作成したい", type: :system, js: true do scenario "ユーザーがTodoアイテム一覧ページで'New Item'ボタンを選択した場合、ユーザーはTodoアイテム作成ページへ遷移すること" do visit items_path click_on :new_item_link expect(page).to have_current_path new_item_path end scenario "ユーザーはTodoアイテム作成ページでアイテム名を入力できること" do name = "ふがふが申請する" visit new_item_path fill_in :item_name, with: name expect(find("#item_name").value).to eq name end scenario "ユーザーがTodoアイテム作成ページでアイテム名が未入力の状態で'Create Item'ボタンを選択した場合、Todoアイテムは作成されず、Todoアイテム作成ページでTodoアイテム名未入力のエラーメッセージが表示されること" do name = "" msg_error = "Name can't be blank" item_count = Item.count visit new_item_path fill_in :item_name, with: name click_on :submit_item_button expect(Item.count).to eq item_count expect(page).to have_text msg_error end scenario "ユーザーがTodoアイテム作成ページでアイテム名が21文字以上の状態で'Create Item'ボタンを選択した場合、Todoアイテムは作成されず、Todoアイテム作成ページでTodoアイテム名21文字以上のエラーメッセージが表示されること" do name = "21文字以上のTodoアイテムは登録できぬ" msg_error = "Name is too long (maximum is 20 characters)" item_count = Item.count visit new_item_path fill_in :item_name, with: name click_on :submit_item_button expect(Item.count).to eq item_count expect(page).to have_text msg_error end scenario "ユーザーがTodoアイテム作成ページでアイテム名が20文字以内の状態で'Create Item'ボタンを選択した場合、Todoアイテムは作成され、ユーザーはTodoアイテム作成成功メッセージが表示されたTodoアイテム詳細ページへ遷移すること" do name = "ふがふが申請する" msg_success = "Item was successfully created." item_count = Item.count visit new_item_path fill_in :item_name, with: name click_on :submit_item_button expect(Item.count).to eq item_count + 1 expect(page).to have_current_path item_path Item.last expect(page).to have_text msg_success expect(page).to have_text name end scenario "ユーザーがTodoアイテム作成ページで'Back'リンクを選択した場合、ユーザーはTodoアプリ一覧ページへ遷移すること" do visit new_item_path click_on :back_link expect(page).to have_current_path items_path end endclick_on target
click_on
はリンクまたはボタンをクリックするメソッドです。target
として、:new_item_link
や、:back_link
など、idを指定することでそのidをもつ要素をクリックできます。ちなみに、リンク・ボタン以外はこれではクリックできないです。例えば
やみたいな要素は以下の形式でクリックします。find("#id").clickfill_in target, with: text #input要素に入力する
fill_in
はinput
要素に文字入力するメソッドです。target
にinput
のid
またはname
などを指定します。with
以降の文字列が入力されます。expect(input_target.value).to eq text
input_target
はinput
要素です。.value
とすることでinput
要素に入力されている値を検証できます。
eq
を使ってtext
と一致しているかを検証しています。expect(Item.count).to eq item_count
item_count = Item.count # なんか色々操作 expect(Item.count).to eq item_countという検証の仕方をしてます。操作前にモデルの数を
item_count
の変数に代入しておいて、操作後のモデルの数と比較するというやり方です。モデルが操作によって作られるならitem_count + 1
、エラーで変わらない、またはアップデートだから変わらないならitem_count
、削除されるならitem_count - 1
といった具合です。【US3】 ユーザーとして、Todoアイテムを確認したい
こちらの受入条件はこんな感じかな。
- ユーザーはTodoアイテム一覧ページで全てのTodoアイテムを更新日昇順で閲覧できること
- ユーザーがTodoアイテム一覧ページであるTodoアイテムの"Show"リンクを選択した場合、ユーザーはそのTodoアイテムのTodoアイテム詳細ページへ遷移すること
- ユーザーがTodoアイテム編集ページで"Show"リンクを選択した場合、ユーザーはTodoアイテム詳細ページへ遷移すること
- ユーザーはTodoアイテム詳細ページでそのTodoアイテムのアイテム名を確認できること
- ユーザーがTodoアイテム詳細ページで"Back"リンクを選択した場合、ユーザーはTodoアイテム一覧ページへ遷移すること
こちらもはじめに、classを指定しておきます。Todoアイテム一覧ページではTodoアイテムがいっぱい並ぶのですが、同じようにリンクも並びます。
each
でviewを描かれているのでShow
、Edit
、Destroy
リンクにclass
を付与して操作しやすいようにしておきます。複数存在するのでid
は付与できないです。
item.name
を検証するためにtd
にもclass
を付与します。app/views/items/index.html.erb<tbody> <% @items.each do |item| %> <tr> <td class="item-name"><%= item.name %></td> <td><%= link_to 'Show', item, class: 'show-link' %></td> <td><%= link_to 'Edit', edit_item_path(item), class: 'edit-link' %></td> <td><%= link_to 'Destroy', item, method: :delete, class: 'destroy-link', data: { confirm: 'Are you sure?' } %></td> </tr> <% end %> </tbody>これで繰り返しの要素に対しても
class
で指定できるようになりました。
テストコードは以下のようになるかと思います。spec/system/us3_show_todo_items_spec.rbfeature "ユーザーとして、Todoアイテムを確認したい", type: :system, js: true do before :each do @item1 = Item.create(name: "ほげほげ申請する") @item2 = Item.create(name: "ふがふが申請する") end scenario "ユーザーはTodoアイテム一覧ページで全てのTodoアイテムを更新日昇順で閲覧できること" do visit items_path expect(all(".item-name")[0]).to have_text @item1.name expect(all(".item-name")[1]).to have_text @item2.name @item1.update(name: "ほげほげほげほげ申請する") visit items_path expect(all(".item-name")[0]).to have_text @item2.name expect(all(".item-name")[1]).to have_text @item1.name end scenario "ユーザーがTodoアイテム一覧ページであるTodoアイテムの'Show'リンクを選択した場合、ユーザーはそのTodoアイテムのTodoアイテム詳細ページへ遷移すること" do visit items_path all(".show-link")[0].click expect(page).to have_current_path item_path @item1 end scenario "ユーザーがTodoアイテム編集ページで'Show'リンクを選択した場合、ユーザーはTodoアイテム詳細ページへ遷移すること" do visit edit_item_path @item1 click_on :show_item_link expect(page).to have_current_path item_path @item1 end scenario "ユーザーはTodoアイテム詳細ページでそのTodoアイテムのアイテム名を確認できること" do visit item_path @item1 expect(page).to have_text @item1.name end scenario "ユーザーがTodoアイテム詳細ページで'Back'リンクを選択した場合、ユーザーはTodoアイテム一覧ページへ遷移すること" do visit item_path @item1 click_on :back_link expect(page).to have_current_path items_path end endall(target)[index]
all
でページ内の全ての対象のclassを取得してます。[index]
でその中の何個目の要素をターゲットにするかを指定できます。index
は0から始まるのに注意です。【US4】 ユーザーとして、Todoアイテムを更新したい
お次はこんなかんじの受入条件。更新は作成とviewが共通化されていることもあり、似た観点になります。
- ユーザーはTodoアイテム一覧ページであるTodoアイテムの"Edit"リンクを選択した場合、ユーザーはそのTodoアイテムのTodoアイテム編集ページへ遷移すること
- ユーザーがTodoアイテム詳細ページで"Edit"リンクを選択した場合、ユーザーはTodoアイテム編集ページへ遷移すること
- Todoアイテム編集ページでアイテム名はデフォルトで現在のアイテム名が入力されていること
- ユーザーはTodoアイテム編集ページでアイテム名を入力できること
- ユーザーがTodoアイテム編集ページでアイテム名が未入力の状態で"Update Item"ボタンを選択した場合、Todoアイテムは更新されず、Todoアイテム編集ページでTodoアイテム名未入力のエラーメッセージが表示されること
- ユーザーがTodoアイテム編集ページでアイテム名が21文字以上の状態で"Update Item"ボタンを選択した場合、Todoアイテムは更新されず、Todoアイテム編集ページでTodoアイテム名21文字以上のエラーメッセージが表示されること
- ユーザーがTodoアイテム編集ページでアイテム名が20文字以内の状態で"Update Item"ボタンを選択した場合、Todoアイテムは更新され、ユーザーはTodoアイテム更新成功メッセージが表示されたTodoアイテム詳細ページへ遷移すること
- ユーザーがTodoアイテム編集ページで"Back"リンクを選択した場合、ユーザーはTodoアプリ一覧ページへ遷移すること
もちろんテストコードも似たような形でかけるかなと思います。
spec/system/us4_update_todo_items_spec.rbfeature "ユーザーとして、Todoアイテムを更新したい", type: :system, js: true do before :each do @item1 = Item.create(name: "ほげほげ申請する") end scenario "ユーザーはTodoアイテム一覧ページであるTodoアイテムの'Edit'リンクを選択した場合、ユーザーはそのTodoアイテムのTodoアイテム編集ページへ遷移すること" do visit items_path all(".edit-link")[0].click expect(page).to have_current_path edit_item_path @item1 end scenario "ユーザーがTodoアイテム詳細ページで'Edit'リンクを選択した場合、ユーザーはTodoアイテム編集ページへ遷移すること" do visit item_path @item1 click_on :edit_item_link expect(page).to have_current_path edit_item_path @item1 end scenario "Todoアイテム編集ページでアイテム名はデフォルトで現在のアイテム名が入力されていること" do visit edit_item_path @item1 expect(find("#item_name").value).to eq @item1.name end scenario "ユーザーはTodoアイテム編集ページでアイテム名を入力できること" do name = "ほげほげふがふが申請する" visit edit_item_path @item1 fill_in :item_name, with: name expect(find("#item_name").value).to eq name end scenario "ユーザーがTodoアイテム編集ページでアイテム名が未入力の状態で'Update Item'ボタンを選択した場合、Todoアイテムは更新されず、Todoアイテム編集ページでTodoアイテム名未入力のエラーメッセージが表示されること" do name = "" msg_error = "Name can't be blank" visit edit_item_path @item1 fill_in :item_name, with: name click_on :submit_item_button expect(@item1).to eq Item.find(@item1.id) expect(page).to have_text msg_error end scenario "ユーザーがTodoアイテム編集ページでアイテム名が21文字以上の状態で'Update Item'ボタンを選択した場合、Todoアイテムは更新されず、Todoアイテム編集ページでTodoアイテム名21文字以上のエラーメッセージが表示されること" do name = "21文字以上のTodoアイテムは登録できぬ" msg_error = "Name is too long (maximum is 20 characters)" visit edit_item_path @item1 fill_in :item_name, with: name click_on :submit_item_button expect(@item1).to eq Item.find(@item1.id) expect(page).to have_text msg_error end scenario "ユーザーがTodoアイテム編集ページでアイテム名が20文字以内の状態で'Update Item'ボタンを選択した場合、Todoアイテムは更新され、ユーザーはTodoアイテム更新成功メッセージが表示されたTodoアイテム詳細ページへ遷移すること" do name = "ほげほげふがふが申請する" msg_success = "Item was successfully updated." visit edit_item_path @item1 fill_in :item_name, with: name click_on :submit_item_button update_item1 = Item.find(@item1.id) expect(@item1.name).not_to eq update_item1.name expect(page).to have_current_path item_path @item1 expect(page).to have_text msg_success expect(page).to have_text "Name: #{update_item1.name}" expect(page).not_to have_text "Name: #{@item1.name}" end scenario "ユーザーがTodoアイテム編集ページで'Back'リンクを選択した場合、ユーザーはTodoアプリ一覧ページへ遷移すること" do visit edit_item_path @item1 click_on :back_link expect(page).to have_current_path items_path end endexpect(model).to eq Item.find(model.id)
@item1 = Item.create(name: "hoge") # 操作 expect(@item1).to eq Item.find(@item1.id)というような使い方でモデルの更新がないことを検証してみました。
@item1
は操作前に変数化されており、Item.find(@item1.id)
は操作後の@item1
を検索した結果です。両者が一致しているということは操作後もupdateは行われなかったことを表しています。ほかにもそれぞれの更新されるかもしれない属性(今回ならname
)やupdated_at
を比較しても検証ができそうです。実際、更新されたことの確認には
name
の値が一致しないことを検証しました。【US5】 ユーザーとして、Todoアイテムを削除したい
最後のユーザーストーリーです。受入条件は以下の通り。
- ユーザーがTodoアイテム一覧ページで"Destroy"リンクを選択した場合、Todoアイテム削除確認ダイアログが表示されること
- ユーザーがTodoアイテム削除確認ダイアログで"キャンセル"を選択した場合、Todoアイテムは削除されず、Todoアイテム削除確認ダイアログが閉じること
- ユーザーがTodoアイテム削除確認ダイアログで"OK"を選択した場合、Todoアイテムは削除され、Todoアイテム削除確認ダイアログが閉じ、Todoアイテム一覧ページでTodoアイテム削除成功メッセージが表示されること
destroy
の際はdata: { confirm: * }
が使われており、jsのダイアログが使われていますのでちょっと今までとはテストコードも違ってきます。
feature
のオプションのjs: true
がない場合、rack_test
が実行される設定になっています(spec/rails_helper.rb
参照)。rack_test
は高速で実行されますが、jsに対応していないのでこの章のテストはかならず失敗します。js: true
のつけ忘れには気をつけてください。ではテストコードをみていきましょう!
spec/system/us5_destroy_todo_items_spec.rbfeature "ユーザーとして、Todoアイテムを削除したい", type: :system, js: true do before :each do @item = Item.create(name: "ほげほげ申請する") end scenario "ユーザーがTodoアイテム一覧ページで'Destroy'リンクを選択した場合、Todoアイテム削除確認ダイアログが表示されること" do visit items_path page.dismiss_confirm("Are you sure?") do all(".destroy-link")[0].click end end scenario "ユーザーがTodoアイテム削除確認ダイアログで'キャンセル'を選択した場合、Todoアイテムは削除されず、Todoアイテム削除確認ダイアログが閉じること" do visit items_path page.dismiss_confirm do all(".destroy-link")[0].click end expect(page).to have_current_path items_path expect{@item.reload}.not_to raise_error end scenario "ユーザーがTodoアイテム削除確認ダイアログで'OK'を選択した場合、Todoアイテムは削除され、Todoアイテム削除確認ダイアログが閉じ、Todoアイテム一覧ページでTodoアイテム削除成功メッセージが表示されること" do visit items_path expect(page).to have_text @item.name page.accept_confirm do all(".destroy-link")[0].click end expect(page).to have_current_path items_path expect{@item.reload}.to raise_error(ActiveRecord::RecordNotFound) expect(page).not_to have_text @item.name end endpage.dismiss_confirm text do ~ end #confirmでキャンセルを選択する
block内の操作が終わったらconfirmで"キャンセル"を選択する操作です。
ついでにtext
がそのconfirmに表示されているかも検証してくれます。page.accept_confirm text do ~ end #confirmでOKを選択する
confirmで"OK"を選択するバージョンです。
expect{OPE}.to raise_error(ERROR) #例外が発生することを検証する
OPE
した時にエラーが出ないかを検証しています。
OPE
としてModel.find(id)
、ERROR
としてActiveRecord::RecordNotFound
を指定すれば、そのid
のレコードが存在しないことを検証できます。reload
はfind(id)
しているだけなのでこれを利用。エラーがないことを検証する場合は
expect{OPE}.not_to raise_error
だけでOKです。confirmの検証はちょっと癖がありますが、これで検証できます。
$ docker-compose run web rspec Starting rails-test-automation-hands-on_db_1 ... done Capybara starting Puma... * Version 4.3.0 , codename: Mysterious Traveller * Min threads: 0, max threads: 4 * Listening on tcp://127.0.0.1:46549 .......................... Finished in 12.82 seconds (files took 4.34 seconds to load) 26 examples, 0 failures成功!!
Conclusion
今回は、TDD/BDD、テスト自動化(Selenium+RSpec+Capybara)の初めの一歩としてRailsのscaffoldアプリでテスト自動化をやってみました。
最初は時間もかかるなーという印象があると思いますが、アプリを改善し続けていく上で自動化は必須の技術になっていると僕は思います。仕様も明確になりますしデグレの心配もなし。
テスト自動化はやってみたいけど、何かしらの理由で見送っている人やプロダクトに少しでも寄与できる記事になっていれば幸いです。Reference
- 投稿日:2019-11-12T23:11:47+09:00
【Rails】こわくない!TDD/BDD・テスト自動化はじめの一歩ハンズオン!
Abstract
TDD/BDDは素晴らしいです!テスト自動化は感動的です!この感動を多くの人に感じてもらいたい!
でも実際、テスト自動化と聞くとハードルありますよね。テストコードって難しいんじゃないかとか、時間かかるし手動でテストすればいいんじゃないかとか。でもそうじゃない!
ということで、この記事ではハードルをものすごく下げて
- TDD/BDDのことを語る
- RailsアプリのE2Eテスト自動化をハンズオンする
をしてみたいと思います。
最初にお断りですが、E2EテストをするためのRSpecやCapybaraのいろいろな使い方については語らないです。そのあたりは
などがとても参考になるので...
同じ操作・検証をするにも色々な書き方があるので、今回の記事の書き方はあくまで一例ということでお願いします!なぜTDD/BDDなのか
みなさんは
- あいつらのシステム要件が何言ってるかわからん
- どこまで作ったら開発終わるのかわからん
- テスト工程で初めて理解した。齟齬ってたことを。
みたいな経験ないですか?もしくは
- システム要件って何提示すればいいかわからん
- 開発者たち、念入りにテストしすぎじゃね?
- 齟齬が発覚したときにはもう直せないと言われた、最初からそのつもりだったのに
みたいな経験(プロダクトオーナー目線)。
TDD/BDDはこいつらを解決できます!
TDD/BDDはTest Driven Development(テスト駆動開発)とBehavior Driven Development(振る舞い駆動開発)のことです。"駆動開発"は開発において最も大切にしていること、を表していると思うのですが、そうなると"テスト"と"振る舞い"を大切にしている開発のことです。
TDDは最初にテスト定義しましょう、という開発です。最初っていつ?というと最初、つまりシステム要件定義の段階だと私は思っています。
たとえばウォーターフォール開発では、要件定義・基本設計・詳細設計などのフェーズを踏みコーディングをした後、各フェーズのoutputをinputとしてテストを設計していくかと思いますが、じゃあ最初から各フェーズのoutputがテストならいんじゃない?という考え方ですかね。TDDのいいところは、"明確"になることです。テストはOKかNGしかありません(OK/NGしかないように定義しなければいけません)。
どんなに読みやすい設計書があろうと、軽量なコードがあろうと、テストがNGならダメです。TDDは最初にテストを定義することで、頑張ってコーディングしたけどテスト設計したら要件と違った、ということを防いでくれます。また、最初にテストを定義することで、テストがAll OKになることが完了であることが"明確"になります。
BDDはTDDの派生として、プロダクトの振る舞いを定義します。BDDの方がよりプロダクトのユーザーの行動に近いテストを定義するイメージになります。
わたしのTDD/BDD
わたしの場合は、システム要件としてアジャイル開発で用いられるユーザーストーリーを定義することから始めます。
Whoとして、Whatしたい、なぜならWhyからだ
という型に当てはめて、ストーリーを作っていきます。機能一覧に近いですが、ユーザーの行動が基準に置かれているのでいくつかの機能を束ねていたり、機能の一部のみが必要だったりもします。
例えば、弊サービス4Q4T(チームビルディングサポートツール)のケースだと、
- チームリーダーとして、チームを作成したい、なぜなら自分のチームで4Q4Tを使いよりよいチームを構築したいからだ
- チームリーダーとして、簡単な方法でメンバーを招待したい、なぜなら登録ハードルが低い方がメンバーが登録してくれやすいからだ
- チームメンバーとして、ドラッカー風エクササイズの質問に答えてほかのメンバーに見せたい、なぜならチームメンバーと期待を確認し合いたいからだ
みたいなものがストーリーになります。
さらにこのユーザーストーリーに対して、受入条件を定義しています。
受入条件の型はWho が Where で How の状態で What した場合、 Who は Where で What なること。
を基本としています。BDDのフレームワークツールなどが色々あるのですが、それらを参考に自分が使いやすい形をして求めて今はこの型にしてます。
例えば、上の「チームリーダーとして、チームを作成したい」というユーザーストーリーに対しては
- ユーザーはトップページにアクセスできること
- ユーザーがトップページで"Create a team"ボタンを選択した場合、ユーザーはチーム作成ページへ遷移すること
- ユーザーはチーム作成ページでチーム名を入力できること
- ユーザーがチーム作成ページでチーム名が未入力の状態で"Create"ボタンを選択した場合、ユーザーはチーム作成ページでチーム名未入力のエラーメッセージを受け取れること
- ユーザーがチーム作成ページでチーム名を入力した状態で"Create"ボタンを選択した場合、チームが作成され、ユーザーはそのチームのチームページへ遷移すること
といった具合です。こうやって型を作っておくことで、テストを素早く漏れなく定義できます。
テストは定義したけど…
ここまで定義できてしまえば、このテストが通るコードを書けばいいだけです。
ただ、少しコードをいじったら毎回テストって無理ですよね。リファクタ前と全く同じテストになってますか?デグレなんてありえないけどそのコードが他の機能に影響を与えないとも言い切れない。なので影響調査に時間をかけて慎重に、ってそれじゃ時代の変化にプロダクトが追いつかない。
そこでテスト自動化(Test automation)です!
テスト自動化は、テストをコーディングしてプログラムとして実行することで、プロダクトが定義したテスト通りの振る舞いをしているかをテストすることです。テストコードを書くのにはそれなりの時間が必要ですが、テスト自体は人手でやるよりも圧倒的に速く、再現性もあり、一度作成すれば何回も使いまわせます。
いいことだらけのテスト自動化、やるしかないですよね。ということで、今回はRailsアプリでテスト自動化ハンズオンを開催します!!
テスト自動化ハンズオン
準備
まずは、こちらからベースのRailsアプリケーションをcloneしてください。
$ git clone git@github.com:at946/rails-test-automation-hands-on.git $ cd rails-test-automation-hands-on $ docker-compose build $ docker-compose run web yarn install --check-files $ docker-compose run web rails db:create $ mkdir spec/systemRails on Docker(alpine)でdocker-seleniumを使わないでSelenium+RSpec+Capybaraでテスト自動化してみる - Qiita に記載している手順ですでにSelenium, RSpec, CapybaraがインストールされたRails on Dockerアプリです。
- rspec-rails : Railsのテストフレームワーク
- selenium-webdriver : ブラウザ操作をプログラムで実行できるテストツール
- capybara : RSpecやSeleniumのコードを書きやすくしてくれたりするテストフレームワーク
今回のハンズオンでは、めちゃくちゃシンプルなTodolistアプリをテストしていきましょう。
まず、Todolistアプリをscaffoldで作成していきます。
$ docker-compose run web rails g scaffold item name:string $ docker-compose run web rails db:migrateapp/models/item.rbclass Item < ApplicationRecord validates :name, presence: true validates :name, length: { maximum: 20 } end少しテストを面白くするために、
name
にnot nullと20文字以内のバリデーションをかけました。Structure
テストコードは
spec/system/
に*_spec.rb
ファイルを作成して記述していきます。テストコードの基本的な構文は以下の通りです。
spec/system/*_spec.rbfeature "ユーザーストーリー", type: :system, js: true do before :each do # 各シナリオの前に共通して実行される処理 # モデルの作成などで利用される # 作成されたモデルは各シナリオ終了時にクリアされる end scenario "受入条件1" do # operation... (操作) # expect().to... (検証) end scenario "受入条件2" do # operation... (操作) # expect().to... (検証) end endファイルはユーザーストーリー単位に作成しておくと管理がしやすくていいと思います。共通的な基本処理、例えばサイトにURL直打ちでアクセスできる、だとか、ヘッダーのロゴをクリックしたらトップページに遷移する、みたいなものも1ファイルとして管理するのもオススメです。
また、テスト実行は以下のコマンドで実行できます。
$ docker-compose run web rspec特定のファイルのみを指定してテストすることもできます。
$ docker-compose run web rspec spec/system/*_spec.rbサンプルアプリのユーザーストーリー
まずは今回のサンプルアプリのユーザーストーリー(US)を定義します。scaffoldに合わせてになりますが、RailsアプリだとRESTfulを大切にすることもあり大体以下のような感じになると思います。(Whyは省略)
- US1: ユーザーとして、ページにアクセスしたい
- US2: ユーザーとして、Todoアイテムを作成したい
- US3: ユーザーとして、Todoアイテムを確認したい
- US4: ユーザーとして、Todoアイテムを更新したい
- US5: ユーザーとして、Todoアイテムを削除したい
おまちかね、ここから先は実際にテストコードを書いていきます。
まず、ユーザーストーリーに対して受入条件を定義して、その後にテストコードを書いていきます。そして、テストコード内の書き方についてコメントを添えます。【US1】 ユーザーとして、ページにアクセスしたい
受入条件は以下の通り。scaffoldで用意されたViewに正しくアクセスできることです。
- ユーザーはTodoアイテム一覧ページ(
/items
)に直接アクセスできること- ユーザーはTodoアイテム作成ページ(
/items/new
)に直接アクセスできること- ユーザーはTodoアイテム詳細ページ(
/items/{:id}
)に直接アクセスできること- ユーザーはTodoアイテム編集ページ(
/items/{:id}/edit
)に直接アクセスできることでは、これらのテストコードを書いてみましょう!
spec/system/us1_access_page_spec.rbfeature "ユーザーとして、ページにアクセスしたい", type: :system, js: true do before :each do @item = Item.create(name: "ほげほげ申請する") end scenario "ユーザーはTodoアイテム一覧ページ(/items)に直接アクセスできること" do visit items_path expect(page).to have_current_path items_path expect(page).to have_text "Items" end scenario "ユーザーはTodoアイテム作成ページ(/items/new)に直接アクセスできること" do visit new_item_path expect(page).to have_current_path new_item_path expect(page).to have_text "New Item" end scenario "ユーザーはTodoアイテム詳細ページ(/items/{:id})に直接アクセスできること" do visit item_path @item expect(page).to have_current_path item_path @item expect(page).to have_text "Name: " end scenario "ユーザーはTodoアイテム編集ページ(/items/{:id}/edit)に直接アクセスできること" do visit edit_item_path @item expect(page).to have_current_path edit_item_path @item expect(page).to have_text "Editing Item" end endvisit *_path
visit
はパスにGETアクセスする操作です。後ろにURLや名前付きルーティングヘルパー(prefix)を指定して使います。
例えば、visit root_path
と書けば、config/routes.rb
でroot to:
に指定したルートパスにアクセスする操作ということになります。今回はscaffoldを使っているので、
config/routes.rb
にresources :items
が定義されています。
このおかげでテストコードに書いたような割り振りのprefixがつけられているというわけです。prefixは$ docker-compose run web rails routesで確認できます。
prefixはルーティング毎に
config/routes.rb
で定義できます。config/routes.rbRails.application.routes.draw do # method path, to: 'controller#action', as: 'prefix' get 'hoge', to: 'hoges#index', as: 'fuga' endこの例では、
fuga_path
が/hoge
のprefixになっています。expect(target).to *
expect
は検証をするためのメソッドです。target
に指定したものを検証対象として、to
以降に記載したものと一致するかどうかを検証します。to
の代わりにnot_to
と記載した場合は一致しないことの検証です。今回のテストコードでは
expect(page).to
という使い方をしているのでページ全体に対して検証を行っていると思えばいいと思います。また、
expect(find("#id")).to
のようにid
がid属性として定義されている要素に対して検証したりなどもできます。have_current_path *_path
have_current_path
で現在表示中のページのパスが*_path
と一致するかを検証しています。
ほかにも、expect(current_path).to eq *_path
という書き方でも同じ検証ができます。have_text text
have_text
で表示中のページにtext
の文字列が含まれるかを検証しています。
例えば、pathにはアクセスできたけど表示されているページは期待通りでない!となっていないかという観点で、それぞれのページ固有のワードを検証させてみました。【US2】 ユーザーとして、Todoアイテムを作成したい
受入条件は以下のような感じで。
name
に少し制約を入れているので少し条件が多いです。
- ユーザーがTodoアイテム一覧ページで"New Item"リンクを選択した場合、ユーザーはTodoアイテム作成ページへ遷移すること
- ユーザーはTodoアイテム作成ページでアイテム名を入力できること
- ユーザーがTodoアイテム作成ページでアイテム名が未入力の状態で"Create Item"ボタンを選択した場合、Todoアイテムは作成されず、Todoアイテム作成ページでTodoアイテム名未入力のエラーメッセージが表示されること
- ユーザーがTodoアイテム作成ページでアイテム名が21文字以上の状態で"Create Item"ボタンを選択した場合、Todoアイテムは作成されず、Todoアイテム作成ページでTodoアイテム名21文字以上のエラーメッセージが表示されること
- ユーザーがTodoアイテム作成ページでアイテム名が20文字以内の状態で"Create Item"ボタンを選択した場合、Todoアイテムは作成され、ユーザーはTodoアイテム作成成功メッセージが表示されたTodoアイテム詳細ページへ遷移すること
- ユーザーがTodoアイテム作成ページで"Back"リンクを選択した場合、ユーザーはTodoアプリ一覧ページへ遷移すること
テストコードを書く前に、buttonやlinkなど操作する要素にidを定義していきます。xpathやテキスト、cssなどでも操作をすることはできるのですが、idで操作するようにしておけばデザインや文字をいじったとしてもテストコードを変更することなく開発を進められるのでそうしています。
app/views/items/index.html.erb<%# "New Item"リンクに"new_item_link"のidをつける %> <%= link_to 'New Item', new_item_path, id: :new_item_link %>app/views/items/show.html.erb<%# "Edit"リンクに"edit_item_link"、"Back"リンクに"back_link"のidをつける %> <%= link_to 'Edit', edit_item_path(@item), id: :edit_item_link %> | <%= link_to 'Back', items_path, id: :back_link %>app/views/items/_form.html.erb<%# "Create Item"ボタンに"submit_item_button"のidをつける %> <%= form.submit nil, id: :submit_item_button %>app/views/items/new.html.erb<%# "Back"リンクに"back_link"のidをつける %> <%= link_to 'Back', items_path, id: :back_link %>app/views/items/edit.html.erb<%# "Show"リンクに"show_item_link"、"Back"リンクに"back_link"のidをつける %> <%= link_to 'Show', @item, id: :show_item_link %> | <%= link_to 'Back', items_path, id: :back_link %>これでid指定で要素を操作することができます。
ではテストコードを書いていきましょう!
spec/system/us2_create_todo_items_spec.rbfeature "ユーザーとして、Todoアイテムを作成したい", type: :system, js: true do scenario "ユーザーがTodoアイテム一覧ページで'New Item'ボタンを選択した場合、ユーザーはTodoアイテム作成ページへ遷移すること" do visit items_path click_on :new_item_link expect(page).to have_current_path new_item_path end scenario "ユーザーはTodoアイテム作成ページでアイテム名を入力できること" do name = "ふがふが申請する" visit new_item_path fill_in :item_name, with: name expect(find("#item_name").value).to eq name end scenario "ユーザーがTodoアイテム作成ページでアイテム名が未入力の状態で'Create Item'ボタンを選択した場合、Todoアイテムは作成されず、Todoアイテム作成ページでTodoアイテム名未入力のエラーメッセージが表示されること" do name = "" msg_error = "Name can't be blank" item_count = Item.count visit new_item_path fill_in :item_name, with: name click_on :submit_item_button expect(Item.count).to eq item_count expect(page).to have_text msg_error end scenario "ユーザーがTodoアイテム作成ページでアイテム名が21文字以上の状態で'Create Item'ボタンを選択した場合、Todoアイテムは作成されず、Todoアイテム作成ページでTodoアイテム名21文字以上のエラーメッセージが表示されること" do name = "21文字以上のTodoアイテムは登録できぬ" msg_error = "Name is too long (maximum is 20 characters)" item_count = Item.count visit new_item_path fill_in :item_name, with: name click_on :submit_item_button expect(Item.count).to eq item_count expect(page).to have_text msg_error end scenario "ユーザーがTodoアイテム作成ページでアイテム名が20文字以内の状態で'Create Item'ボタンを選択した場合、Todoアイテムは作成され、ユーザーはTodoアイテム作成成功メッセージが表示されたTodoアイテム詳細ページへ遷移すること" do name = "ふがふが申請する" msg_success = "Item was successfully created." item_count = Item.count visit new_item_path fill_in :item_name, with: name click_on :submit_item_button expect(Item.count).to eq item_count + 1 expect(page).to have_current_path item_path Item.last expect(page).to have_text msg_success expect(page).to have_text name end scenario "ユーザーがTodoアイテム作成ページで'Back'リンクを選択した場合、ユーザーはTodoアプリ一覧ページへ遷移すること" do visit new_item_path click_on :back_link expect(page).to have_current_path items_path end endclick_on target
click_on
はリンクまたはボタンをクリックするメソッドです。target
として、:new_item_link
や、:back_link
など、idを指定することでそのidをもつ要素をクリックできます。ちなみに、リンク・ボタン以外はこれではクリックできないです。例えば
やみたいな要素は以下の形式でクリックします。find("#id").clickfill_in target, with: text #input要素に入力する
fill_in
はinput
要素に文字入力するメソッドです。target
にinput
のid
またはname
などを指定します。with
以降の文字列が入力されます。expect(input_target.value).to eq text
input_target
はinput
要素です。.value
とすることでinput
要素に入力されている値を検証できます。
eq
を使ってtext
と一致しているかを検証しています。expect(Item.count).to eq item_count
item_count = Item.count # なんか色々操作 expect(Item.count).to eq item_countという検証の仕方をしてます。操作前にモデルの数を
item_count
の変数に代入しておいて、操作後のモデルの数と比較するというやり方です。モデルが操作によって作られるならitem_count + 1
、エラーで変わらない、またはアップデートだから変わらないならitem_count
、削除されるならitem_count - 1
といった具合です。【US3】 ユーザーとして、Todoアイテムを確認したい
こちらの受入条件はこんな感じかな。
- ユーザーはTodoアイテム一覧ページで全てのTodoアイテムを更新日昇順で閲覧できること
- ユーザーがTodoアイテム一覧ページであるTodoアイテムの"Show"リンクを選択した場合、ユーザーはそのTodoアイテムのTodoアイテム詳細ページへ遷移すること
- ユーザーがTodoアイテム編集ページで"Show"リンクを選択した場合、ユーザーはTodoアイテム詳細ページへ遷移すること
- ユーザーはTodoアイテム詳細ページでそのTodoアイテムのアイテム名を確認できること
- ユーザーがTodoアイテム詳細ページで"Back"リンクを選択した場合、ユーザーはTodoアイテム一覧ページへ遷移すること
こちらもはじめに、classを指定しておきます。Todoアイテム一覧ページではTodoアイテムがいっぱい並ぶのですが、同じようにリンクも並びます。
each
でviewを描かれているのでShow
、Edit
、Destroy
リンクにclass
を付与して操作しやすいようにしておきます。複数存在するのでid
は付与できないです。
item.name
を検証するためにtd
にもclass
を付与します。app/views/items/index.html.erb<tbody> <% @items.each do |item| %> <tr> <td class="item-name"><%= item.name %></td> <td><%= link_to 'Show', item, class: 'show-link' %></td> <td><%= link_to 'Edit', edit_item_path(item), class: 'edit-link' %></td> <td><%= link_to 'Destroy', item, method: :delete, class: 'destroy-link', data: { confirm: 'Are you sure?' } %></td> </tr> <% end %> </tbody>これで繰り返しの要素に対しても
class
で指定できるようになりました。
テストコードは以下のようになるかと思います。spec/system/us3_show_todo_items_spec.rbfeature "ユーザーとして、Todoアイテムを確認したい", type: :system, js: true do before :each do @item1 = Item.create(name: "ほげほげ申請する") @item2 = Item.create(name: "ふがふが申請する") end scenario "ユーザーはTodoアイテム一覧ページで全てのTodoアイテムを更新日昇順で閲覧できること" do visit items_path expect(all(".item-name")[0]).to have_text @item1.name expect(all(".item-name")[1]).to have_text @item2.name @item1.update(name: "ほげほげほげほげ申請する") visit items_path expect(all(".item-name")[0]).to have_text @item2.name expect(all(".item-name")[1]).to have_text @item1.name end scenario "ユーザーがTodoアイテム一覧ページであるTodoアイテムの'Show'リンクを選択した場合、ユーザーはそのTodoアイテムのTodoアイテム詳細ページへ遷移すること" do visit items_path all(".show-link")[0].click expect(page).to have_current_path item_path @item1 end scenario "ユーザーがTodoアイテム編集ページで'Show'リンクを選択した場合、ユーザーはTodoアイテム詳細ページへ遷移すること" do visit edit_item_path @item1 click_on :show_item_link expect(page).to have_current_path item_path @item1 end scenario "ユーザーはTodoアイテム詳細ページでそのTodoアイテムのアイテム名を確認できること" do visit item_path @item1 expect(page).to have_text @item1.name end scenario "ユーザーがTodoアイテム詳細ページで'Back'リンクを選択した場合、ユーザーはTodoアイテム一覧ページへ遷移すること" do visit item_path @item1 click_on :back_link expect(page).to have_current_path items_path end endall(target)[index]
all
でページ内の全ての対象のclassを取得してます。[index]
でその中の何個目の要素をターゲットにするかを指定できます。index
は0から始まるのに注意です。【US4】 ユーザーとして、Todoアイテムを更新したい
お次はこんなかんじの受入条件。更新は作成とviewが共通化されていることもあり、似た観点になります。
- ユーザーはTodoアイテム一覧ページであるTodoアイテムの"Edit"リンクを選択した場合、ユーザーはそのTodoアイテムのTodoアイテム編集ページへ遷移すること
- ユーザーがTodoアイテム詳細ページで"Edit"リンクを選択した場合、ユーザーはTodoアイテム編集ページへ遷移すること
- Todoアイテム編集ページでアイテム名はデフォルトで現在のアイテム名が入力されていること
- ユーザーはTodoアイテム編集ページでアイテム名を入力できること
- ユーザーがTodoアイテム編集ページでアイテム名が未入力の状態で"Update Item"ボタンを選択した場合、Todoアイテムは更新されず、Todoアイテム編集ページでTodoアイテム名未入力のエラーメッセージが表示されること
- ユーザーがTodoアイテム編集ページでアイテム名が21文字以上の状態で"Update Item"ボタンを選択した場合、Todoアイテムは更新されず、Todoアイテム編集ページでTodoアイテム名21文字以上のエラーメッセージが表示されること
- ユーザーがTodoアイテム編集ページでアイテム名が20文字以内の状態で"Update Item"ボタンを選択した場合、Todoアイテムは更新され、ユーザーはTodoアイテム更新成功メッセージが表示されたTodoアイテム詳細ページへ遷移すること
- ユーザーがTodoアイテム編集ページで"Back"リンクを選択した場合、ユーザーはTodoアプリ一覧ページへ遷移すること
もちろんテストコードも似たような形でかけるかなと思います。
spec/system/us4_update_todo_items_spec.rbfeature "ユーザーとして、Todoアイテムを更新したい", type: :system, js: true do before :each do @item1 = Item.create(name: "ほげほげ申請する") end scenario "ユーザーはTodoアイテム一覧ページであるTodoアイテムの'Edit'リンクを選択した場合、ユーザーはそのTodoアイテムのTodoアイテム編集ページへ遷移すること" do visit items_path all(".edit-link")[0].click expect(page).to have_current_path edit_item_path @item1 end scenario "ユーザーがTodoアイテム詳細ページで'Edit'リンクを選択した場合、ユーザーはTodoアイテム編集ページへ遷移すること" do visit item_path @item1 click_on :edit_item_link expect(page).to have_current_path edit_item_path @item1 end scenario "Todoアイテム編集ページでアイテム名はデフォルトで現在のアイテム名が入力されていること" do visit edit_item_path @item1 expect(find("#item_name").value).to eq @item1.name end scenario "ユーザーはTodoアイテム編集ページでアイテム名を入力できること" do name = "ほげほげふがふが申請する" visit edit_item_path @item1 fill_in :item_name, with: name expect(find("#item_name").value).to eq name end scenario "ユーザーがTodoアイテム編集ページでアイテム名が未入力の状態で'Update Item'ボタンを選択した場合、Todoアイテムは更新されず、Todoアイテム編集ページでTodoアイテム名未入力のエラーメッセージが表示されること" do name = "" msg_error = "Name can't be blank" visit edit_item_path @item1 fill_in :item_name, with: name click_on :submit_item_button expect(@item1).to eq Item.find(@item1.id) expect(page).to have_text msg_error end scenario "ユーザーがTodoアイテム編集ページでアイテム名が21文字以上の状態で'Update Item'ボタンを選択した場合、Todoアイテムは更新されず、Todoアイテム編集ページでTodoアイテム名21文字以上のエラーメッセージが表示されること" do name = "21文字以上のTodoアイテムは登録できぬ" msg_error = "Name is too long (maximum is 20 characters)" visit edit_item_path @item1 fill_in :item_name, with: name click_on :submit_item_button expect(@item1).to eq Item.find(@item1.id) expect(page).to have_text msg_error end scenario "ユーザーがTodoアイテム編集ページでアイテム名が20文字以内の状態で'Update Item'ボタンを選択した場合、Todoアイテムは更新され、ユーザーはTodoアイテム更新成功メッセージが表示されたTodoアイテム詳細ページへ遷移すること" do name = "ほげほげふがふが申請する" msg_success = "Item was successfully updated." visit edit_item_path @item1 fill_in :item_name, with: name click_on :submit_item_button update_item1 = Item.find(@item1.id) expect(@item1.name).not_to eq update_item1.name expect(page).to have_current_path item_path @item1 expect(page).to have_text msg_success expect(page).to have_text "Name: #{update_item1.name}" expect(page).not_to have_text "Name: #{@item1.name}" end scenario "ユーザーがTodoアイテム編集ページで'Back'リンクを選択した場合、ユーザーはTodoアプリ一覧ページへ遷移すること" do visit edit_item_path @item1 click_on :back_link expect(page).to have_current_path items_path end endexpect(model).to eq Item.find(model.id)
@item1 = Item.create(name: "hoge") # 操作 expect(@item1).to eq Item.find(@item1.id)というような使い方でモデルの更新がないことを検証してみました。
@item1
は操作前に変数化されており、Item.find(@item1.id)
は操作後の@item1
を検索した結果です。両者が一致しているということは操作後もupdateは行われなかったことを表しています。ほかにもそれぞれの更新されるかもしれない属性(今回ならname
)やupdated_at
を比較しても検証ができそうです。実際、更新されたことの確認には
name
の値が一致しないことを検証しました。【US5】 ユーザーとして、Todoアイテムを削除したい
最後のユーザーストーリーです。受入条件は以下の通り。
- ユーザーがTodoアイテム一覧ページで"Destroy"リンクを選択した場合、Todoアイテム削除確認ダイアログが表示されること
- ユーザーがTodoアイテム削除確認ダイアログで"キャンセル"を選択した場合、Todoアイテムは削除されず、Todoアイテム削除確認ダイアログが閉じること
- ユーザーがTodoアイテム削除確認ダイアログで"OK"を選択した場合、Todoアイテムは削除され、Todoアイテム削除確認ダイアログが閉じ、Todoアイテム一覧ページでTodoアイテム削除成功メッセージが表示されること
destroy
の際はdata: { confirm: * }
が使われており、jsのダイアログが使われていますのでちょっと今までとはテストコードも違ってきます。
feature
のオプションのjs: true
がない場合、rack_test
が実行される設定になっています(spec/rails_helper.rb
参照)。rack_test
は高速で実行されますが、jsに対応していないのでこの章のテストはかならず失敗します。js: true
のつけ忘れには気をつけてください。ではテストコードをみていきましょう!
spec/system/us5_destroy_todo_items_spec.rbfeature "ユーザーとして、Todoアイテムを削除したい", type: :system, js: true do before :each do @item = Item.create(name: "ほげほげ申請する") end scenario "ユーザーがTodoアイテム一覧ページで'Destroy'リンクを選択した場合、Todoアイテム削除確認ダイアログが表示されること" do visit items_path page.dismiss_confirm("Are you sure?") do all(".destroy-link")[0].click end end scenario "ユーザーがTodoアイテム削除確認ダイアログで'キャンセル'を選択した場合、Todoアイテムは削除されず、Todoアイテム削除確認ダイアログが閉じること" do visit items_path page.dismiss_confirm do all(".destroy-link")[0].click end expect(page).to have_current_path items_path expect{@item.reload}.not_to raise_error end scenario "ユーザーがTodoアイテム削除確認ダイアログで'OK'を選択した場合、Todoアイテムは削除され、Todoアイテム削除確認ダイアログが閉じ、Todoアイテム一覧ページでTodoアイテム削除成功メッセージが表示されること" do visit items_path expect(page).to have_text @item.name page.accept_confirm do all(".destroy-link")[0].click end expect(page).to have_current_path items_path expect{@item.reload}.to raise_error(ActiveRecord::RecordNotFound) expect(page).not_to have_text @item.name end endpage.dismiss_confirm text do ~ end #confirmでキャンセルを選択する
block内の操作が終わったらconfirmで"キャンセル"を選択する操作です。
ついでにtext
がそのconfirmに表示されているかも検証してくれます。page.accept_confirm text do ~ end #confirmでOKを選択する
confirmで"OK"を選択するバージョンです。
expect{OPE}.to raise_error(ERROR) #例外が発生することを検証する
OPE
した時にエラーが出ないかを検証しています。
OPE
としてModel.find(id)
、ERROR
としてActiveRecord::RecordNotFound
を指定すれば、そのid
のレコードが存在しないことを検証できます。reload
はfind(id)
しているだけなのでこれを利用。エラーがないことを検証する場合は
expect{OPE}.not_to raise_error
だけでOKです。confirmの検証はちょっと癖がありますが、これで検証できます。
$ docker-compose run web rspec Starting rails-test-automation-hands-on_db_1 ... done Capybara starting Puma... * Version 4.3.0 , codename: Mysterious Traveller * Min threads: 0, max threads: 4 * Listening on tcp://127.0.0.1:46549 .......................... Finished in 12.82 seconds (files took 4.34 seconds to load) 26 examples, 0 failures成功!!
Conclusion
今回は、TDD/BDD、テスト自動化(Selenium+RSpec+Capybara)の初めの一歩としてRailsのscaffoldアプリでテスト自動化をやってみました。
最初は時間もかかるなーという印象があると思いますが、アプリを改善し続けていく上で自動化は必須の技術になっていると僕は思います。仕様も明確になりますしデグレの心配もなし。
テスト自動化はやってみたいけど、何かしらの理由で見送っている人やプロダクトに少しでも寄与できる記事になっていれば幸いです。Reference
- 投稿日:2019-11-12T23:04:53+09:00
rails newする時にActive Jobをスキップできない理由
rails newのスキップオプション
railsアプリを新規で作成する時に実行する
rails new
ですが、不要な初期設定がある場合はrails new --skip-active-storage
の様にスキップする事ができます。skipで指定できるオプションは以下のファイルで確認できます。
https://github.com/rails/rails/blob/master/railties/lib/rails/generators/app_base.rb#L33-L85--skip-active-jobが見当たらない
ですが、
--skip-active-job
の指定ができないので、調べてみました。githubで調べた所、以下のPRを発見
https://github.com/rails/rails/pull/16584
内容を見てみると、Active Job is like Active Model. It defines an API that can be used for other components of Rails. If you choose to not use a queue implementation Active Job will use its inline queue which will behaves in the same way of you not using it.
Keeping Active Job always available will make possible to engines to rely on it without having to add it as explicit dependency or check if it is available.
Active JobはActive Modelの様に、他のコンポーネントから使用されるAPIを含んでいます。
Active Jobを常に使用可能にしておく事で、エンジンは明示的な依存関係として追加したり、使用可能かどうかを確認したりすることなく、エンジンに依存することができます。
その為、Active Jobのスキップオプションは追加しません。らしいので、Active Jobはスキップできない様にしたみたいです。
- 投稿日:2019-11-12T22:38:20+09:00
rails 発展その13 画像について
railsの画像に関する内容です。
ImageMagick
ImageMagickは、コマンドラインから簡単に画像の保存形式の変更などが行えるツールです。
ターミナル
ターミナル$ brew install imagemagickImageMagickがインストールできたら、mini_magickをインストールします。
gemfileに追記しましょう。gemfilegem 'mini_magick'次にターミナルでbundle installします。
ターミナル$ bundle installActive Storage
rails active_storage:installコマンドを実行すると
Active Storageが使用するテーブル用のマイグレーションファイルが作成されます。ターミナル$ rails active_storage:install $ bundle exec rake db:migrateActive Storage用の設定として
modelclass User < ApplicationRecord has_one_attached :avatar endこの記述を追加することで、ユーザーのレコードと画像を紐づけることができます。ユーザーテーブルにカラムを追加する必要はありません。
image_tag
image_tagは、htmlの
タグを生成するヘルパーメソッドです。
sample.html.erb<%= image_tag "image/sample.jpg" %> <!-- <img src="image/sample.jpg"> -->
- 投稿日:2019-11-12T21:34:51+09:00
rails 発展その12 カラムのデータまとめかた。
コントローラーからデータをいかに処理してまとめられる
備忘録を書きます。groupメソッド
groupメソッドはテーブルのレコードを指定したカラムでまとめることができます。
モデル.group(カラム名) # 例 Rei.group(:product_id) # これでproduct_idの21,22,23,などにまとめられ格product_idの先頭が出力されます。countメソッド
countメソッドは配列などの要素数を返すメソッドです。
Rei.group(:product_id).count => {21=>2, 22=>1, 23=>4}order('count_カラム名').count(カラム名)
countメソッドの引数にカラム名を指定することができます。
するとorderメソッドでcount_カラム名でのソートが可能となります。
これはそのカラムを持つレコードの数でソートするという意味です。ここでlimitの注意点です
count(:product_id)の時点ではハッシュになっているため
その後に付け加えるとハッシュに対してlimitメソッドを実行することになりエラーが起こります。
そのため、その直前に付け加え、下記のようにします。order('count_カラム名').count(カラム名 # 例 Rei.group(:product_id).order('count_product_id DESC').limit(5).count(:product_id) => {23=>4, 21=>2, 22=>1}keysメソッド
ハッシュはkeysというメソッドを持っています。
これはハッシュのキーだけを取り出して配列として返すメソッドです。# 例 Rei.group(:product_id).order('count_product_id DESC').limit(5).count(:product_id).keys => [23, 21, 22]mapメソッド
whereで値を配列にした場合、並びがid順になってしまうので。
この問題を解決するためにmapメソッドを使用します。mapメソッドは配列オブジェクトのインスタンスメソッドです。
mapメソッドは配列の中身を1つずつ取り出してブロックという構文を繰り返し実行します。
そして、ブロックの返り値を集めた新しい配列を作成します。配列オブジェクト.map {|ele| ブロックの処理} # eleには配列の要素が1つずつ代入される # ブロックの処理は配列の要素の数だけ繰り返し実行される # 例 class RankingController < ApplicationController def ranking product_ids = Rei.group(:product_id).order('count_product_id DESC').limit(5).count(:product_id).keys # これでproduct_idの多い順に並ばれました。 @ranking = product_ids.map { |id| Product.find(id) } #Prodict_idsを順番に実行してproductテーブルからインスタンスを持ってきます。 end end
- 投稿日:2019-11-12T21:00:00+09:00
rails 発展その11 コントローラー
コントローラーの諸知識の軽いまとめです。
before_action
あるコントローラのすべてのアクションで実行の前に共通の処理を行いたい場合のコード
contorollerclass コントローラ名 < ApplicationController before_action :処理させたいメソッドの名前コントローラーの継承
コントローラーは継承元のコントローラーから内容を継承することが
可能です。継承元にbefore_actionが設定されていれば
それを行ってから現在のコントローラーで処理が実行されます。controllerclass コントローラ < 継承元のコントローラ end # 例 # 継承元 class StoreController < ApplicationController before_action :say def say puts "Hello" end end # 継承先 sayが実行されてから以下の変数が実行される class PetController < StoreController def show @saru = Saru.find(params[:id]) end endレイアウトファイル
app/views/layouts/の下に入っているHTMLファイルです。
レイアウトファイルはURLにアクセスして対応するコントローラが呼ばれたあと、最初に表示されるHTMLのことです。コントローラ内でlayout 'レイアウトファイル名'と書くと、そのコントローラでのアクションが呼ばれたあと表示するビューのレイアウトファイルを指定できます。
controllerclass StoreController < ApplicationController layout 'レイアウトファイル名' # なにも指定しないとレイアウトファイルはapplication.html.erbになります。 # 例 layout 'pict' # StoreControllerのindexアクションが呼ばれたときに表示されるレイアウトはpict.html.erbとなります。 def index end end
- 投稿日:2019-11-12T20:31:06+09:00
rails db:migrateでforeign key mismatchが出ました。
rails db:migrateをしたのですが、エラーが出てしまい、実行できません。
StandardError: An error has occurred, this and all later migrations canceled:
SQLite3::SQLException: foreign key mismatch - "microposts" referencing "users": INSERT INTO "users" ("user_id","name","email","created_at","updated_at","password_digest","website","introduction","sex","tel","nickname","remember_digest","admin")
SELECT "user_id","name","email","created_at","updated_at","password_digest","website","introduction","sex","tel","nickname","remember_digest","admin" FROM "ausers"メッセージとしてこのようなものが出ています。
このメッセージの前にも同じようにStandardError: An error has occurred, this and all later migrations canceled:が出たことから、rails db:reset rails db:drop rails db:create等を行っています。
解決法を教えて頂けないでしょうか。
よろしくお願い致します。
- 投稿日:2019-11-12T18:51:15+09:00
geocoderで緯度と経度が登録されない問題
バージョン
Gemfile.lockgeocoder (1.5.2)起きたこと
READMEを読んだ通りにやったのに、いくら住所を登録しても
latitude
とlongitude
がnullのままで登録される気配がない。確認してみたこと
address
やlatitude
、longitude
のカラム名をTypoしてないか?latitude
とlongitude
をfloat型で定義したか?- モデルに
geocoded_by :address
とafter_validation :geocode
を記述したか?- そもそもGemをInstallした?
で上記は全部ちゃんとできていました。
原因
bin/rails c
で色々叩いてみてそれとなく原因が分かりました。[1] pry(main)> Geocoder.coordinates("東京都渋谷区道玄坂1-12-5渋谷マークシティ") => nil [2] pry(main)> Geocoder.coordinates("東京都渋谷区道玄坂1-12-5") => nil [3] pry(main)> Geocoder.coordinates("東京都渋谷区道玄坂1-12") => nil [4] pry(main)> Geocoder.coordinates("東京都渋谷区道玄坂1") => nil [5] pry(main)> Geocoder.coordinates("東京都渋谷区道玄坂") => [35.6588903, 139.6975169] [6] pry(main)> Geocoder.coordinates("東京都渋谷区") => [35.6645956, 139.6987107]railsのアプリ上では「東京都渋谷区道玄坂1-12-5渋谷マークシティ」で登録しようとしていたのですが、そもそもその住所だと緯度と経度を取得できていないことが分かりました。
解決策
geocoder
のデフォルトのgeocoding serviceはnominatimを使用しており
このAPIの精度だと上記の結果になるようです。
これをgoogleのAPIに変更することにより精度を上げることができます。
以下の手順で使用サービスをgoogleに変更してあげましょう。
- configファイルを作成する
$ bin/rails g geocoder:config
- configの設定
config/initializers/geocoder.rb
が作成されるので編集する。
(※googleのAPI KEYを取得しておく必要があります)Geocoder.configure( # Geocoding options # timeout: 3, # geocoding service timeout (secs) lookup: :google, # name of geocoding service (symbol) # ip_lookup: :ipinfo_io, # name of IP address geocoding service (symbol) # language: :en, # ISO-639 language code use_https: true, # use HTTPS for lookup requests? (if supported) # http_proxy: nil, # HTTP proxy server (user:pass@host:port) # https_proxy: nil, # HTTPS proxy server (user:pass@host:port) api_key: {your google api key}, # cache: nil, # cache object (must respond to #[], #[]=, and #del) # cache_prefix: 'geocoder:', # prefix (string) to use for all cache keys # Exceptions that should not be rescued by default # (if you want to implement custom error handling); # supports SocketError and Timeout::Error # always_raise: [], # Calculation options # units: :mi, # :km for kilometers or :mi for miles # distances: :linear # :spherical or :linear )これで、再度コンソール上で叩くと…
[1] pry(main)> Geocoder.coordinates("東京都渋谷区道玄坂1-12-5渋谷マークシティ") => [35.6581487, 139.6979397] [2] pry(main)> Geocoder.coordinates("東京都渋谷区道玄坂1-12-5") => [35.6577383, 139.6973389] [3] pry(main)> Geocoder.coordinates("東京都渋谷区道玄坂1-12") => [35.6579443, 139.6979978] [4] pry(main)> Geocoder.coordinates("東京都渋谷区道玄坂1") => [35.65751710000001, 139.6994338] [5] pry(main)> Geocoder.coordinates("東京都渋谷区道玄坂") => [35.6581518, 139.6981574] [6] pry(main)> Geocoder.coordinates("東京都渋谷区") => [35.6619707, 139.703795]となり、しっかり緯度と経度が取得できていることが分かります。
- 投稿日:2019-11-12T15:32:29+09:00
Rails データ内容の制限
データの内容を制限する
NOT NULL制約
すでに作成してあるhogeテーブルのnameカラムにNOT NULL制約をつける場合。
ターミナル$ rails g migration ChangeHogesNameNotNull Running via Spring preloader in process 6959 invoke active_record create db/migrate/xxxxxxxxxxx_change_hoges_name_not_null.rbマイグレーションファイルが生成されたらそのファイルの中身を編集する。
db/migrate/xxxxxxxxxxx_change_hoges_name_not_null.rbclass ChangeHogesNameNotNull < ActiveRecord::Migration[5.2] def change change_column_null :hoges, :name, false end end編集したら、マイグレーションファイルをデータベースに適用する。
ターミナル$ rails db:migrateこれで、データベースにおいて、hogesテーブルのnameカラムにNULLを入れることができなくなった。
しかし、今の状態だと、""のような空文字を保存できてしまう。この対応の仕方は、hoge.rbの方でバリデーションを記述すれば良い。記述方法は割愛。NOT NULL制約を加えることでどうなるかRailsコンソールで確認する
ターミナル$ rails c Running via Spring preloader in process 7167 Loading development environment (Rails 5.2.3) >> Hoge.new(name: nil).save (0.3ms) BEGIN Hoge Create (27.2ms) INSERT INTO "hoges" ("created_at", "updated_at") VALUES ($1, $2) RETURNING "id" [["created_at", "2019-11-12 05:35:19.735447"], ["updated_at", "2019-11-12 05:35:19.735447"]] (0.2ms) ROLLBACK Traceback (most recent call last): 1: from (irb):1 ActiveRecord::NotNullViolation (PG::NotNullViolation: ERROR: null value in column "name" violates not-null constraint) DETAIL: Failing row contains (7, null, null, 2019-11-12 05:35:19.735447, 2019-11-12 05:35:19.735447). : INSERT INTO "hoges" ("created_at", "updated_at") VALUES ($1, $2) RETURNING "id" >>ActiveRecord::NotNullViolationという例外が発生し、登録に失敗していることがわかる。
- 投稿日:2019-11-12T15:20:58+09:00
Railsのコントローラ名とモデル名の書き方について【初心者】
少し気になっていたので以下のコマンドについて整理してみました。
- rails g controller コントローラ名
- rails g model モデル名
コントローラ名は複数形
例) rails g controller tests
モデル名は単数形
例) rails g model test
※それぞれの名前のイニシャルは大文字でも小文字でも結果は変わりません。
例) rails g controller tests と rails g controller Tests の結果は同じです。
例) rails g model test と rails g model Test の結果は同じです。
- 投稿日:2019-11-12T14:58:38+09:00
rake routesの見方で誤解したこと【初心者】
Railsでルーティングの情報を詳しく見るときに、ターミナルでよく使用するコマンドが「rake routes」。
Railを学習し始めの頃にこのrake routesの見方で誤解してしまった点があったので、他に誤解する人が出ない為にも記録しておきます。
Prefixが空欄なワケ
上記はroutes.rbで resources :books と記載してrake routesコマンドを実行したときに表示される情報です。このときのPrefixに注目してください。
Verb(HTTPメソッドのこと)がGET以外のものについてはPrefixの欄が空欄になっています。自分は最初にこれを見たときにGET以外のPrefixは存在しないのだと思いましたが、実はこれは
空欄の上のPrefixと同じなので省略されているだけ
なのです。つまり上記のrake routesを省略せずに記述すると以下の通りになります。
- 投稿日:2019-11-12T13:16:54+09:00
Rails ビューファイルのHTMLにRubyのコードを書く時の<% %>と<%= %>の違い
目的
- あんまりわかっていないビューファイル(HTML)内にRubyのコードを書く時の<% %>と<%= %>の違いをまとめる
簡単に言うと?
- ビューファイルが司るWebページ(以降、Webページと記載する)内に表示したいものは<%= %>の中に記載する。
- Webページに表示したくないものは<% %>の中に記載する。
ちょっとだけ詳しく言うと?
- <%= %>は中に書かれた内容を実行し、結果を評価し、文字として出する。
- <% %>は中に書かれた内容を実行する。
使い分けは?
- あまり難しい事は考えず、ビューファイルに記載するRubyのメソッドやコードの結果をそのWebページに表示したいのか、表示したくないのかで判断する。
- 例えば、とあるテーブル情報を
モデル名.all
で取得し、each文で取得内容を全て出力したいときの変数名A.each do |変数名B|
の処理はWebページに出力する必要はないので<% %>で囲む。- 前述の処理で変数Bに入った内容をWebページに出力したいときは<%= %>で囲む。
書き方の例
- 変数Aにはとあるデータベースのとあるデータが格納されているものとする。
- 変数Bの内容をWebページに出力したい。
each文を使用した書き方の例を下記に記載する。
<!-- 下記の処理は出力したくないので<% %>で囲む --> <% each.変数A do |変数B| %> <!-- 変数Bを出力したいので<%= %>で囲む --> <%= 変数B %> <!-- each文の終了を意味するendも出力したくないので<% %>で囲む --> <% end %>より具体的な例
- usersにはuserテーブルのすべてのデータが格納されているものとする。(
users = User.all
を実行した状態)- userの内容をWebページに出力したい。
each文を使用した書き方の例を下記に記載する。
<!-- 下記の処理は出力したくないので<% %>で囲む --> <% each.users do |user| %> <!-- userを出力したいので<%= %>で囲む --> <%= user %> <!-- each文の終了を意味するendも出力したくないので<% %>で囲む --> <% end %>
- 投稿日:2019-11-12T12:45:32+09:00
RailsでEC系に良く出てくるカート機能を解説 / 実装してみた
参考にした記事
おそらくこの記事を読みに来てくださる読者の方は上記の記事も読んでる可能性が高いのでモデルの設計は似せようと思います。
STEP.1 モデル設計
rails g model product name price:integer rails g model cart rails g model cart_item quantity:integer product:references cart:referencesここらへんは記事と全く一緒です。
因みに補足としてマイグレーション部分のコードをあげとくと以下のようになると思います。
CartItemのデフォルト値のところは後から追記してます。マイグレーションの内容
class CreateProducts < ActiveRecord::Migration[6.0] def change create_table :products do |t| t.string :name t.integer :price t.timestamps end end end class CreateCarts < ActiveRecord::Migration[6.0] def change create_table :carts do |t| t.timestamps end end end class CreateCartItems < ActiveRecord::Migration[6.0] def change create_table :cart_items do |t| t.integer :quantity, default: 0 t.references :product, null: false, foreign_key: true t.references :cart, null: false, foreign_key: true t.timestamps end end end上記の内容でマイグレートを通した後であればコンソールで軽く遊んでみたりしてデフォルト値が設定されているかとか確認しておくと良いかもですね。
(因みに補足ですがCartItemの外部キーは存在しないものを指定するとrollbackするので先にCartとProductを作らないとハマります)
アソシエーションの内容
class Product < ApplicationRecord end class Cart < ApplicationRecord has_many :cart_items end class CartItem < ApplicationRecord belongs_to :product belongs_to :cart end※参考記事と一緒
因みに、もしここのアソシエーションを指定している意味が曖昧な人のために↓
このアソシエーションを追加することで以下のように親のモデルから関連する子のモデルが抽出出来たりするってことですね。
Cart.fitst.cart_items
な感じで詳しくは本家の Active Record の関連付け を
STEP.2 コントローラー設計
次にコントローラーですが無駄なコードは極力無くしてます。
例えばこの記事では一旦、View側まで書かないのでhelper_method
などは記述してません。
必要に応じて設定して頂ければと思います。# app/controllers/application_controller.rb class ApplicationController < ActionController::Base def current_cart Cart.find(session[:cart_id]) rescue ActiveRecord::RecordNotFound cart = Cart.create session[:cart_id] = cart.id cart end end個人的に
find
を使う場合はできるだけ例外処理で丁寧に扱うことを意識しています。class CartsController < ApplicationController before_action :setup_cart_item!, only: [:add_item, :update_item, :delete_item] def show @cart_items = current_cart.cart_items end def add_item if @cart_item.blank? @cart_item = current_cart.cart_items.build(product_id: params[:product_id]) end @cart_item.quantity += params[:quantity].to_i @cart_item.save redirect_to '' end def update_item @cart_item.update(quantity: params[:quantity].to_i) redirect_to '' end def delete_item @cart_item.destroy redirect_to '' end private def setup_cart_item! @cart_item = current_cart.cart_items.find_by(product_id: params[:product_id]) end end
setup_cart_item!
部分でインスタンス変数を使ってますがViewで引っ張ってくる予定がないのであれば普通の変数にリファクタリングして良いと思います。またリダイレクト部分はよしなに変更していけば良いかなと
STEP.3 ルーティング設定
参考記事通り以下の部分を追加すればカート周りは大丈夫そうですね。
resource :carts, only: [:show] post '/add_item' => 'carts#add_item' post '/update_item' => 'carts#update_item' delete '/delete_item' => 'carts#delete_item'
- 投稿日:2019-11-12T11:43:15+09:00
Railsのparamsはキーのstringとsymbolの違いを隠蔽してくれる
私が今日知ったプチ衝撃の事実!
Railsの
params
では、Hashの機能が拡張されているためキーの型に関係なく値を取ってくることができる!!
つまり…h = { :last_name => "山下", "first_name" => "智久" } #キーに指定した要素の型で値を取ってくることができる h[:last_name] #=> "山下" h["first_name"] #=> "智久" #指定した要素とは違う型で値を取ってくることはできない h["last_name"] #=> nil h[:first_name] #=> nilstringとsymbolが間違っていると取ってくることができない。
#(railsの場合) #railsで受け取ったparams params => <ActionController::Parameters {"last_name"=>"山下", "first_name"=>"智久"}> #paramsの見た目はHashだが、ActionController::Parametersのインスタンス params.class => ActionController::Parameters #キーの型に関係なく値を取ってくることができる! params["last_name"] #=> "山下" params["first_name"] #=> "智久" params[:last_name] #=> "山下" params[:first_name] #=> "智久"railsを書いている限りは、コントローラでparamsを受け取って処理をするときなどキーの型を気にしなくてもよしなにやってくれる。怖い…
ちなみに
string => symbolに変換してくれるmethodは
to_sym
god = "tomohisa" p god.to_sym => :tomohisaparamsの中身を眺めていたときに、なんでこれまでsymbolとstringテキトーにやってきたのに値がとれてたんだ?と思って知ったことでした。
- 投稿日:2019-11-12T11:38:57+09:00
rails 発展その10 レシーバ self
コードを書いていくと内容の繰り返しが多くなりやすくなるので
それを省略できる方法のまとめを使用と思います。レシーバ
インスタンスメソッドを利用するインスタンス自身のことです。
re = "9" #以下の式のレシーバはstr re.to_i #=> 9self
インスタンスメソッドの中でselfと書くと
そのメソッドを利用したレシーバ自身が代入された変数のように扱うことができます。class Integer def kake? if self * 10 == 1 return true else return false end end end 10.kake? #=> false(*は乗算を求めるもので、1ではないのでfalseが返ってくる)railsでの使用例
ポイントの平均を求めたいメソッドを打つ場合
controllerclass Product < ApplicationRecord def point_average self.points.average(:rate).round end # selfの省略も可能 def point_average points.average(:rate).round end end仮にクラス名をその都度合わせるコードを描きたい場合は
view<i class="point rate-<%= product.point_average %>0"></i> <!--これでコントローラーからproductをレシーバとしてpoint_averageのメソッドを使うことができます。-->
- 投稿日:2019-11-12T11:38:57+09:00
rails 発展その10
コードを書いていくと内容の繰り返しが多くなりやすくなるので
それを省略できる方法のまとめを使用と思います。レシーバ
インスタンスメソッドを利用するインスタンス自身のことです。
re = "9" #以下の式のレシーバはstr re.to_i #=> 9self
インスタンスメソッドの中でselfと書くと
そのメソッドを利用したレシーバ自身が代入された変数のように扱うことができます。class Integer def kake? if self * 10 == 1 return true else return false end end end 10.kake? #=> false(*は乗算を求めるもので、1ではないのでfalseが返ってくる)railsでの使用例
ポイントの平均を求めたいメソッドを打つ場合
controllerclass Product < ApplicationRecord def point_average self.points.average(:rate).round end # selfの省略も可能 def point_average points.average(:rate).round end end仮にクラス名をその都度合わせるコードを描きたい場合は
view<i class="point rate-<%= product.point_average %>0"></i> <!--これでコントローラーからproductをレシーバとしてpoint_averageのメソッドを使うことができます。-->
- 投稿日:2019-11-12T11:34:43+09:00
bundle execとは
rails s bundle exec
のbundle exec
、聞いたことあるけど特に書かなくても問題なく動いてきたのでずっと「なんかgemをいい感じにしてくれるもの」くらいの認識でいました。
多分、そろそろきちんと知っておいた方がいいので調べてみた。要するに、gemをいい感じにしてくれるものでした。
…それでは何もアップデートがないので、もう少し踏み込んでまとめると、
gem A
とgem B
があるとする。
プロジェクトA
ではA,B両方使っていて依存関係があり、プロジェクトB
ではAだけ使っている場合、gemB
にアップデートがあるなんてことがあるとプロジェクトB
では勝手にアップデートしてくれという感じだがプロジェクトA
ではgem A
に影響が出てしまうのでそれは困る。
ここら辺を、プロジェクトごとに臨機応変にバージョン管理してくれるのがbundlerの役割らしい。便利やん!!
今作っているようなプロダクトだとほとんどgemを使っていないので特に問題ないが、gemがいっぱいあるときには必須なのだと思いました。
- 投稿日:2019-11-12T10:38:22+09:00
joinsで複数のテーブルの結合をスマートに。
はじめに
テーブルの結合に対して苦手意識があり、せっかく便利な
Activerecord
があるのにfind_by_sql
で逃げてしまい、sqlをゴリゴリに書いて結合させていた。
joins
を使って、3つ以上のテーブルを結合する方法に向き合ったのでまとめる。結合するうえで、
foreign_key
,primary_key
をどう設定していくのかを絡めて、例をとりあげる。取り上げた例
「本」「レビュー」「レビューした人」の3つテーブルを扱う。
DBは下の画像のとおりに設定する。取り出したい条件を
「女性が評価4.0以上レビューした本のタイトル一覧」とする。つまり
where
句でgender = 'woman' かつ evaluation >= 4.0 を指定して、
select
をtitleとするイメージ。ただ、これをどう結合して扱うかが問題。
modelを整える
関係性を把握
あるユーザーはいくつもレビューする
あるレビューは一人のユーザーによって作られる
あるレビューは一つの本を対象にする関係性は次のようになる。
ひとつの結合
usersとreviewsの関係性だけみてみる。
まずusersのmodelusers.rbclass User < ApplicationRecord has_many :reviews end
has_many
なのでreviews
と複数形。
これを設定すればUser.joins(:reviews)
が使えるようになる。
:reviews
はmodelで定義したとおりに複数形User.joins(:reviews) => "SELECT `users`.* FROM `users` INNER JOIN `reviews` ON `reviews`.`user_id` = `users`.`id`"reviewsのmodelも設定してみる。
reviews.rbclass Review < ApplicationRecord belongs_to :user, optional: true endこれを設定すれば
Review.joins(:user)
が使えるようになる。
belongs_to
だからuser
と単数形。Review.joins(:user) => "SELECT `reviews`.* FROM `reviews` INNER JOIN `users` ON `users`.`id` = `reviews`.`user_id`"任意の主キー,外部キー
primary_key
とforeign_key
を使って、任意の外部キー code を設定する。※ codeではなくbook_idとして紐付ければよいのだが、任意の外部キー設定をとりあげるためにcodeとしている。
reviews.rbclass Review < ApplicationRecord belongs_to :user, optional: true belongs_to :book, primary_key: :id, foreign_key: :code, optional: true endbooks.rbclass Book < ApplicationRecord has_many :reviews endこのように設定すると複数の結合を
Book.joins(reviews: :user)
と書くことができる。Book.joins(reviews: :user) => "SELECT `books`.* FROM `books` INNER JOIN `reviews` ON `reviews`.`code` = `books`.`id` INNER JOIN `users` ON `users`.`id` = `reviews`.`user_id`"目的の条件で取り出す
目的としていた「女性が評価4.0以上レビューした本のタイトル一覧」を取り出すには、
Book .joins(reviews: :user) .select('books.title') .where('users.gender = ?', 'woman') .where('reviews.evaluation >= ?', 4.0) => "SELECT `books`.* FROM `books` INNER JOIN `reviews` ON `reviews`.`code` = `books`.`id` INNER JOIN `users` ON `users`.`id` = `reviews`.`user_id` WHERE users.gender = 'woman' AND reviews.evaluation >= 4.0"以上。
これで複数にまたがる結合からの抽出が完了
- 投稿日:2019-11-12T08:50:25+09:00
bundle installする際のtzinfo-dataのwarningがウザい
問題のエラーメッセージ
The dependency tzinfo-data (>= 0) will be unused by any of the platforms Bundler is installing for. Bundler is installing for ruby but the dependency is only for x86-mingw32, x86-mswin32, x64-mingw32, java. To add those platforms to the bundle, run `bundle lock --add-platform x86-mingw32 x86-mswin32 x64-mingw32 java`.
bundle install
実行時に毎回上記warningが出ていたのだが、面倒なので後回しにしていた。
そろそろ重い腰を上げて調査・対処してみる。実行環境
macOS Mojave 10.14.6
Ruby 2.6.1
Rails 6.0.1
Bundler 2.0.2問題の原因
Railsアプリを作成した時にGemfileに書かれる下記行がwarningの原因。
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]tzinfo-dataはTZInfoが参照するタイムゾーン情報を提供するgem。
TZInfoはRubyからタイムゾーン情報を参照し、その情報に基づいて時間をコンバートするためのライブラリ。解決方法
この問題についてググってみると、githubのtzinfo-dataの作者のコメントに解決方法が書いてあった。
解決方法は4つあって下記のいずれかを実行すれば良い。
Gemfileのtzinfo-data行の
platforms: [:mingw, :mswin, :x64_mingw, :jruby]
を削除し、bundle update
を実行。プラットフォーム関係なくtzinfo-dataのタイムゾーンを参照する設定。Gemfileからtzinfo-data行を削除。tzinfo-dataではなくシステムのタイムゾーンを参照するようになる。Windowsの場合は、tzinfo-dataを削除すると、
TZInfo::DataSourceNotFound
例外が発生してしまう。
bundle lock --add-platform mingw, mswin, x64_mingw, jruby
を実行。Gemfile.lockに依存プラットフォーム情報を記述してしまう方法。
bundle config --local disable_platform_warnings true
を実行(Bundlerのバージョンが1.17.0以上であることが必須)。Bundler自体のwarningを出させないようにする設定。4はカレントディレクトリのRailsアプリにだけ適用する方法で、システムのカレントユーザに対して非表示にしたければ、下記のように
--local
を省いて実行すればOK!bundle config disable_platform_warnings trueGemfileにtzinfo-dataを指定している理由
解決方法と同じく、githubのtzinfo-dataの作者のコメントに書いてある。
The purpose of this line is to include the tzinfo-data gem in the bundle on Windows to act as a source of time zone data. This gem is unnecessary on Ubuntu (and Unix-based systems in general) because the system includes time zone data that can be read directly by tzinfo.
コメントによると、Windowsでタイムゾーン情報を取得することを目的として、tzinfo-dataを入れているらしい。Windowsでtzinfo-dataを削除すると例外が発生するのは、tzinfo-dataがないとタイムゾーン情報が取得できないからのようだ。
そもそもUnixベースのOSではtzinfoからシステムのタイムゾーン情報に直接アクセスできるので、このgemを入れる必要はないそう。
- 投稿日:2019-11-12T08:23:34+09:00
Rails 検索機能の付け方
まえがき
今回の、モデルに新しいメソッドを定義し、7つのアクション以外のものを使うというのはどうやら非推奨みたいです。ただし、こちらの方が私的には簡単に作ることができましたので共有させていただきます。
慣れない点がありますので間違い等がありましたらご指摘いただけると嬉しいです。目次
・検索フォームの作成
・searchアクションのルーティング設定
・searchメソッドをモデルに定義
・searchアクションをコントローラーに定義
・検索結果画面のビューファイルを作成検索フォームの作成
まずは検索フォームを付けていきます。今回はform_withを使って書きます。書き方は以下の通りです。
search.html.erb<%= form_with(url: search_sample_path, local: true, method: :get, class: "search-form") do |form| %> <%= form.text_field :keyword, placeholder: "投稿を検索する", class: "search-input" %> <%= form.submit "検索", class: "search-btn" %> <% end %>一つずつ説明していきます。まずurlと指定してある部分はこの後設定する、searchのルーティングで設定されるplefixを書きます。
plefixをパスとして書く時は文字の最後に_pathをつけるような書き方をします。
plefixはターミナル上でrails routesというコマンドを入力したときに一番ひだりに書いてあるものです。次にlocal:trueですが、form_withでは何を指定しなくてもremote: trueになるようなのですが、
remote: trueではXMLHTTPRequestオブジェクトリクエストを自動で送信するので、無効化する為にlocal: trueを指定してあげます。remote:trueだと、非同期通信が発動してしまい、画面が遷移しないようです。
ちなみにXMLHTTPRequestオブジェクトとはXMLHttpRequest (XHR) は、JavaScriptなどのウェブブラウザ搭載のスクリプト言語でサーバとのHTTP通信を行うための、組み込みオブジェクト(API)である。
ということらしいです。簡単にいうと、非同期なデータ通信をするためのAPIです。
今後Ajaxを使う予定がある人は覚えておいても損はないかもしれません。
ちなみにAjaxは(Asynchronous JavaScript + XML)の略です。
method: getは、フォーム送信時のHTTPメソッド(verb)を指定します。
通常は:getや:postを指定するようです。searchアクションのルーティング設定
routes.rbRails.application.routes.draw do devise_for :users root to: 'samples#index' resources :samples do resources :comments, only: :create collection do get 'search' end end resources :users, only: :show end今回はコメント機能の検索をかけるので、samplesコントローラーとcommentsコントローラーがネスト構造になっていることに注意してください。
collection doとはidを付けない書き方です。
今回は検索をするときに詳細の情報は必要ないのでid情報は付けません、もしid情報を付けたい時はmember doというものがあるのでそちらを使いましょう。searchメソッドをモデルに定義
先ほどルーティングにsearchを設定したので今度はモデルにメソッドを定義していきます。現時点だと、.searchというメソッドは使うことができませんが、モデルで設定することにより、コントローラーで使用することができるようになります。
sample.rbdef self.search(search) return Sample.all unless search Sample.where('title LIKE(?)', "%#{search}%") end今回はtitleで検索をかけていきます。
モデルの中でメソッドを定義する際に、メソッド名の頭にself.を付けるとコントローラーで使えるクラスメソッドという物になります。上記でLIKE句とwhereメソッドが出てきました
簡単に説明するとLIKE句とは、曖昧検索と言われる物に使われるもので、前方一致や後方一致などを指定するときに使います。
whereメソッドとは、モデル.where(条件)のように引数部分に条件を指定することで、テーブル内の条件に一致したレコードのインスタンスを配列の形で取得できます。そして今回は条件式に前述したLIKE句を使っています。
ちなみに上記の記述方法をif文を使い、よりわかりやすく書くと下記のようになります。sample.rbdef self.search(search) if search Sample.where('title LIKE(?)', "%#{search}%") else Sample.all end endSampleテーブルから曖昧検索をかけて絞るのか、全てを出すのかという簡単な式を作ることができます。
searchアクションをコントローラーに定義
次はコントローラーにアクションを設定し先ほど定義したsearchメソッド使っていきます。
sample_controller.rbdef search @sample = Sample.search(params[:keyword]) end
params[:keyword]
と書くことで、formで入力した、:keywordを取得することができます。
もしparamsのなかに何が入っているかを確認したい時はsample_controllerdef search binding.pry #このように記述する @sample = Sample.search(params[:keyword]) endこのようにbinding.pryを起動させ、一度ターミナルでparamsと打って、paramsのなかを確認してみると良いでしょう。
今回はparams[:keyword]にはフォームで入力した文字が入るようになっています。search.html.erb# 検索結果画面のビューファイルを作成 <%= form_with(url: search_samples_path, local: true, method: :get, class: "search-form") do |form| %> <%= form.text_field :keyword, placeholder: "投稿を検索する", class: "search-input" %> <%= form.submit "検索", class: "search-btn" %> <% end %> <div class="contents row"> #renderを繰り返し出力する <% @sample.each do |sample| %> <%= render partial: "sample", locals: { sample: sample } %> <% end %> </div>このビューファイルではrenderを用いて、効率よく投稿ができるような記述をしています。
ビューファイルで使うrenderは部分テンプレートのことで、
<%= render partial: "sample", locals: { sample: sample } %>
と記述をすることで、_sample.html.erbというファイルを読み込むことができます。
locals: { sample: sample }
はsampleというeach文で定義した変数を部分テンプレートでなんという変数で扱うかという意味です。右側が、each文で定義した変数で、左側のsampleが部分テンプレート先で扱う変数です。あとがき
あとは、部分テンプレート先で検索結果をどのように表示したいかを記述すれば終了です。
はじめにも書きましたが、7つのアクション以外のものを使って検索機能を作るということは本来推奨されていない方法なので、7つのアクションのみで実装する方法も調べてみてください。
この先インクリメンタルサーチを実装したりするかどうかはまた少し工夫が必要になってきますが、ベースは今回記述した検索機能になります。
この記事が検索機能実装でつまずいてしまった人の助けになれれば幸いです。
ではでは!!
- 投稿日:2019-11-12T03:56:04+09:00
Devise.rb設定ファイル項目まとめ
イントロダクション
Railsで使用するDeviseの設定ファイルである「
/config/initializers/devise.rb
」。
Deviseをセッティングする上で不可欠なファイルではありますが、解説してある物が探しても無かったので、備忘録兼ねて作成しました。英語が疎い上に、全ての機能を利用しているわけじゃないので間違っている可能性あります。
もしおかしい部分あったらズビズビっとご指摘下さいませ・・。環境
- Rails 5.1.x
- Devise 4.7.1
とてつもなく参考にさせて頂いたリンク
- DeviseのREADMEを翻訳してみた
- [Rails] Facebook/Twitter/Googleでのユーザー登録をDevise & Omniauthを使って爆速で実装する
- deviseを使ったログイン機能を実装する!メール認証機能付き
- RailsのDeviseで最低限付与したいセキュリティ設定
それではいってみよー!
Devise.rbの設定項目一覧
Controller configuration / コントローラー
# Deviseのコントローラーのクラス名 config.parent_controller = 'DeviseController'デフォルト名のクラスを使いたくない場合などに指定します。普段は変更する必要はなさげ。
Mailer Configuration / メーラー
# Devise::Mailerに使用するメールアドレスを指定する。 # 独自のクラスを使用すると上書きされる。デフォルトでは、 「from」パラメータを使用。 config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com' # メーラーのクラス名 config.mailer = 'Devise::Mailer' # メールを送信する親クラス名 config.parent_mailer = 'ActionMailer::Base'メール認証を行う際のメールアドレスなどを設定する場所なのでお世話になる可能性は高そう。ただ、メールアドレス以外の部分は特にいじる必要はない感じです。
また、このメールアドレスを変更しただけでは機能しません。詳しくはこちらの記事が参考になります。
deviseを使ったログイン機能を実装する!メール認証機能付き - QiitaORM configuration / ORM
# 読み込むORM require 'devise/orm/active_record'使用したいORMを設定します。
ORMとは、オブジェクト・リレーショナル・マッピング
の略で、SQL文の代わりに短い構文を使用してDB操作を可能とする物。↓こんなやつです。
User.allデフォルトでは
ActiveRecord
が設定されています。Configuration for any authentication mechanism / 認証メカニズム
# ユーザー認証時に使用するキーの設定。 # :email、:username、:subdomainが設定できる。 config.authentication_keys = [:email] # 認証に使用するリクエストオブジェクト。 config.request_keys = [] # 大文字と小文字を区別しないキーを設定。デフォルトはemail。 config.case_insensitive_keys = [:email] # 空白を除外するキーを設定。デフォルトはemai。 config.strip_whitespace_keys = [:email] # パラメーターリクエストでの認証を許可するか。 # true、falseの代わりに:databaseを指定すると、databaseでのみ認証を許可する。 config.params_authenticatable = true # HTTP認証(Basic認証)を有効化させるかどうか。基本はfalse。 # :databaseを指定するとデータベースのみに有効になる。 config.http_authenticatable = false # 401ステータス時にAjaxリクエストを返すか。 # Ajaxで認証したい場合はfalse。 config.http_authenticatable_on_xhr = true # Basic認証を使用する範囲。デフォルトでは「Application」。 config.http_authentication_realm = 'Application' # パスワード変更を要求された際の動作を、入力されたemailの正誤に関わらず同じように振る舞うかどうか。 config.paranoid = true # セッション処理をスキップする場所を設定する。 config.skip_session_storage = [:http_auth] # CSRFトークンを固定化するとセキュリティリスクがある。 # その為、Ajaxリクエストを利用したサインアップを行うなら新しいCSRFトークンをサーバーから発行できる。 # 新しいトークン発行を許可するか? config.clean_up_csrf_token_on_authentication = true # Falseの場合はリロードしない。リロードしない場合はページ読み込み時間の短縮になるが、正しくDiveseをマッピングできない可能性がある。 # リロードするかどうか。 config.reload_routes = trueBasic認証なども入れられるようですね。
gem deviseでAPIの認証を行う方法(Basic認証) - QiitaAjaxでログイン処理したい場合の設定などで編集する必要性が出てきそうです。
Rails Devise Ajax ログイン・登録 - QiitaConfiguration for :database_authenticatable / データベース認証
# パスワードハッシュをtestで発行する回数。デフォルトは11回。 # 20の設定で既に1計算あたり20秒とかなり遅いので注意してねとのこと。 config.stretches = Rails.env.test? ? 1 : 11 # pepperをセットアップする時の生成ハッシュパスワードを設定。 config.pepper = '' # ユーザーのemailが変更された時に元のメールへ通知メールを送るかどうか。 config.send_email_changed_notification = false # ユーザーのパスワードが変更された時にメールへ告知のメールを送るかどうか。 config.send_password_change_notification = falseユーザーのメール・パスワード変更された時の通知は設定しておいてあげると喜ばれそうです。セキュリティ向上にも繋がりますね。
通知設定はメーラー設定が必要なので、こちらも設定をいじるだけでは使えません。Configuration for :confirmable / メール認証
# ユーザーがメール認証できなくてもWebサイトを閲覧できる期限。 # 0.days設定で閲覧不可。nilにすると認証されるまでアクセスし続けられる。 config.allow_unconfirmed_access_for = 2.days # メール認証トークンを無効にするまでの期間。 config.confirm_within = 3.days # メール変更時に変更することをメール認証にて確認するかどうか。 config.reconfirmable = true # アカウント確認を行う時に使用するキーを指定。 config.confirmation_keys = [:email]メール認証で必要な項目が詰まってます。
メーラーの設定と合わせて編集しとくと良さげです。Configuration for :rememberable / ログイン情報の保存
# ログイン情報を保存する期間の設定。 config.remember_for = 2.weeks # サインアウトした時にログイントークンを無効にするか。 config.expire_all_remember_me_on_sign_out = true # Cookieを使用する場合にログイン可能期間を伸ばすか。 config.extend_remember_period = false # Cookieを使用する際の値を設定可能。 # trueにするとSSLのみのCookieが強制される。 config.rememberable_options = {}Configuration for :validatable / バリデーション
# パスワードの最小最大値の設定。 config.password_length = 6..128 # email登録時の正規表現パターン。 config.email_regexp = /\A[^@\s]+@[^@\s]+\z/Configuration for :timeoutable / タイムアウト
# セッション接続が切れる時間。接続が切れると再びログイン認証を求められる。 # デフォルトは30分。 config.timeout_in = 30.minutesConfiguration for :lockable / アカウントロック
# アカウントロックに使用するストラテジー。 # :failed_attempts = 一定の試行回数を超えるとロック # :none = アカウントをロックしない config.lock_strategy = :failed_attempts # ロック、アンロックするキー指定。デフォルトはemail。 config.unlock_keys = [:email] # アカウントロックを解除する方法。 # :email = email認証で解除 # :time = 時間経過で解除 # :both = 上記の両方の方法で解除 # :none = 解除方法無し config.unlock_strategy = :both # アカウントロックするまでの試行回数指定。 config.maximum_attempts = 20 # アカウントロック解除までの時間指定。 config.unlock_in = 1.hour # ログイン失敗時、アカウントロックする直前に警告するか。 config.last_attempt_warning = trueConfiguration for :recoverable / 復旧処理
# パスワードを復旧する際に使用するキーを設定。 config.reset_password_keys = [:email] # パスワードリセットしてから再設定可能な有効期限。 config.reset_password_within = 6.hours # パスワードリセット完了後に自動でログイン状態にさせるか。 config.sign_in_after_reset_password = trueConfiguration for :encryptable / 暗号化
# 使用する暗号化アルゴリズムを指定。 config.encryptor = :sha512暗号化アルゴリズムとしてデフォルトで設定されているbcrypt以外にも他のハッシュを使用できるみたいです。
ただし、bycrypt以外のアルゴリズム使用時は、gem 'devise-encryptable'が必要となります。:sha1、:sha512や、:cliarance_sha1、:authlogic_sha512、:restful_authentication_sha1を使用可能。
詳しい事はこちらのブログが参考になりそうです。
Scopes configuration / 許可範囲
# カスタマイズビューを許可するか。 # trueにする事で、viewが編集可能になる。 config.scoped_views = false # アカウントロックされてないユーザーを管理するモデル名を指定する。 config.default_scope = :user # 全てのRoutingでサインアウトを許可するかどうか。 # もし、「/users/sign_out」でのみ許可したい場合などはfalseにする。 config.sign_out_all_scopes = true基本的にViewの編集は必ず行うと思うので、この項目の編集は必須でしょう。
Navigation configuration / ナビゲーション
# ナビゲーションのフォーマット形式をリストで指定。 config.navigational_formats = ['*/*', :html] # サインアウトで使用するルーティング。デフォルトは「:delete」。 config.sign_out_via = :deleteナビゲーションのフォーマット形式をリストとして設定します。
:htmlはユーザーがアクセス出来ない場合にリダイレクトさせないといけないですが、:xmlや:json形式だと、401コードを返す必要があるようです。
また、IEには「*/*」を指定して対応します。OmniAuth / OmniAuth
# 新しく提供されたOmniAuthの追加を行える。 # Wikiの情報をチェックしてmodelsとhooksの設定を行う。 config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'OmniAuthを使用する際にここでSNSのAPIキーを設定。
Twitter認証やFacebook認証でも使用します。御存知の通り、この設定はDeviseだけの設定なので、実際に認証を導入するには様々なプログラミング的実装が必要です。
もちろん、Githubに上げる際なども考慮して、APIキーは環境変数で記述し.envファイルなどで保管しつつ運用します。Warden configuration / Warden
# もし別のストラテジー、Deviseでサポートしていないものや、 # 不具合のあるアプリを変更する場合などは、condig.wardenのブロックを使用して設定できる。 config.warden do |manager| manager.intercept_401 = false manager.default_strategies(scope: :user).unshift :some_external_strategy endWardenはDeviseが使用する認証フレームワークです。
Wardenが認証の仕組みを提供し、Deviseが認証方法までをパッケージ化して提供してくれるというイメージでしょうか。Mountable engine configurations / Mountable engine
# 搭載するエンジン名が下記の場合、 # mount MyEngine, at: '/my_engine' # # 上記の例でconfig/routes.rbで「devise_for」を呼び出した時は以下の名前となる。 config.router_name = :my_engine # OmuniAuthのpathを設定する場合に使用。 # 基本的に使用しなさそう。 config.omniauth_path_prefix = '/my_engine/users/auth'Mountable engineとは、Railsアプリに他のRailsアプリをgemファイルとして組み込む方法。
ここでのengineはgem化したRailsアプリの事を指しています。Turbolinks configuration / ターボリンク
# ターボリンクを使用している場合、リダイレクトを正しく行わせる為に # 「Turbolinks::Controller」をインクルードする必要がある。 ActiveSupport.on_load(:devise_failure_app) do include Turbolinks::Controller endConfiguration for :registerable / 登録
# パスワード変更後に自動的にサインインさせるかどうか。 config.sign_in_after_change_password = trueまとめ
設定ファイルを眺めるだけで、Deviseがどれだけの事が出来るのかが分かるのが面白いですね。
非常にシンプルに使えて、かつ奥深く実用に耐えうるように設計されているという事が分かります。
一番面倒くさいであろう認証系を一挙に担ってくれるDevise。これからもRailsを使用するならお供する事が多そうです。
- 投稿日:2019-11-12T03:23:16+09:00
Rails 6.0 × MySQL8でDocker環境構築(Alpineベース)
はじめに
Dockerについての知識を深めるため、表記の環境構築をしました。
※当環境は開発環境を想定して構築しています。また、不具合等で後日記事を修正する可能性がありますので、予めご了承下さい。
概要
構成のベースは、主に下記の記事を参考にしました。
・DockerでRails+Webpackerの開発環境を構築するテンプレート
Rails6ではwebpackerが標準で備わっているため、webpacker導入の記事を参考にしました。
当環境の特徴は、
・DockerイメージをAlpine Linuxベースを使用し、軽量化。
・マルチステージビルド機能を使用し、Nodeイメージから実行ファイルをコピーしてくることで、ビルド時間短縮、軽量化。
・Entrykitのコマンド「prehook」を使用し、コンテナ起動時にbundle installすることで、作業効率向上。
です。各項目の参考URLを下記に記載します。・Alpine Linux で Docker イメージを劇的に小さくする
・Multi-stage build でNode.jsのインストールをちょっぴり効率化する
・RailsのDocker環境にEntrykitを導入し、bundle installを自動実行させる方法開発環境
・MacOS Mojave 10.14.6
・Ruby 2.6.5
・Rails 6.0.0
・Docker 19.03.1
・Docker compose ver.1.24.3各種ファイル
Dockerfile.dev
# nodeイメージをビルド FROM node:13.1.0-alpine as node # 軽量のAlpine Linuxベースのイメージ FROM ruby:2.6.5-alpine3.10 # rails consoleの中で日本語入力を設定 ENV LANG C.UTF-8 # 環境構築に必要なパッケージをインストール RUN apk add --no-cache alpine-sdk \ mysql-client \ mysql-dev \ build-base \ bash \ tzdata # Dockerコンテナ起動時の実行タスクを処理するためのツール ENV ENTRYKIT_VERSION 0.4.0 # entrykitインストール RUN wget https://github.com/progrium/entrykit/releases/download/v${ENTRYKIT_VERSION}/entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \ && tar -xvzf entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \ && rm entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \ && mv entrykit /bin/entrykit \ && chmod +x /bin/entrykit \ && entrykit --symlink # javascriptパッケージマネージャ ENV YARN_VERSION 1.19.1 # MSBで、ビルドしたnodeイメージからyarnとnodeをコピー COPY --from=node /opt/yarn-v$YARN_VERSION /opt/yarn COPY --from=node /usr/local/bin/node /usr/local/bin/ # dockerイメージから参照できる様シンボリックリンク作成 RUN ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \ && ln -s /opt/yarn/bin/yarnpkg /usr/local/bin/yarnpkg # ディレクトリ作成 RUN mkdir /app # 作業フォルダとして設定 WORKDIR /app # entrykitのコマンド設定 ENTRYPOINT [ \ "prehook", "ruby -v", "--", \ "prehook", "bundle install -j3 --quiet", "--"]docker-compose.yml
# docker-compose最新(2019.11.12現)Ver. version: "3.7" # アプリケーションを動かすための各要素 services: app: &app_base # rails server用コンテナ build: # Dockerfileを実行し、ビルドされる時のパス指定 context: . dockerfile: Dockerfile.dev volumes: # マウントする設定ファイルのパス指定 - .:/app - bundle-data:/usr/local/bundle ports: # port番号(docker:host) - "3000:3000" depends_on: # Service同士の依存関係 - db command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'" tty: true # railsのデバッグを使用するための設定 stdin_open: true # 同上 webpack: # webpack-dev-server用コンテナ <<: *app_base command: "bin/webpack-dev-server" ports: - "3035:3035" depends_on: - app tty: false stdin_open: false db: # データベース用コンテナ image: mysql:8.0.18 command: --default-authentication-plugin=mysql_native_password # ユーザーの認証方式変更 environment: MYSQL_ROOT_PASSWORD: password ports: - "4306:3306" volumes: - "mysql-data:/var/lib/mysql" volumes: mysql-data: driver: local bundle-data: driver: local説明についてはコメントアウトの通りです。
また、Mysql8ではユーザーの認証方式がcaching_sha2_password
に変わっているみたいで、現環境だと対応するのが厳しいため、一時的に5.7までの認証方式であるmysql_native_password
に変更しています。
他、基本的には参考サイトと構成は同じです。注意すべき点はrails newの後、
database.yml
とwebpacker.yml
の変更を忘れないことです。database.yml
default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: root password: password host: dbwebpacker.yml
dev_server: https: false host: webpack port: 3035 public: localhost:3035 hmr: falseデータベースのパスワードは
docker-compose.yml
に記載したパスを、両ファイルのhost
にはそれぞれのコンテナ名を記載してください。