20191112のRailsに関する記事は23件です。

[Ruby]使用頻度は高くないがここぞという時に知っていたら便利そうなメソッドたち

使用頻度は高くないので忘れちゃいそうだけど、ここぞという時に知っていたら便利そうなメソッドを備忘のためにまとめておきます。
(便利かつ高頻度で使うものは忘れないと思うのでここには書きません)

今後も便利そうだと思うメソッドがあったら随時追加していく予定です。

clamp

数値の上限・下限を制限する

523.clamp(0, 100)
# => 100

email_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"
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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/system

Rails 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:migrate
app/models/item.rb
class Item < ApplicationRecord
  validates :name, presence: true
  validates :name, length: { maximum: 20 }
end

少しテストを面白くするために、nameにnot nullと20文字以内のバリデーションをかけました。

Structure

テストコードはspec/system/*_spec.rbファイルを作成して記述していきます。

テストコードの基本的な構文は以下の通りです。

spec/system/*_spec.rb
feature "ユーザーストーリー", 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.rb
feature "ユーザーとして、ページにアクセスしたい", 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

end

visit *_path

visitはパスにGETアクセスする操作です。後ろにURLや名前付きルーティングヘルパー(prefix)を指定して使います。
例えば、visit root_pathと書けば、config/routes.rbroot to:に指定したルートパスにアクセスする操作ということになります。

今回はscaffoldを使っているので、config/routes.rbresources :itemsが定義されています。
このおかげでテストコードに書いたような割り振りのprefixがつけられているというわけです。prefixは

$ docker-compose run web rails routes

で確認できます。

prefixはルーティング毎にconfig/routes.rbで定義できます。

config/routes.rb
Rails.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.rb
feature "ユーザーとして、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

end

click_on target

click_onはリンクまたはボタンをクリックするメソッドです。targetとして、:new_item_linkや、:back_linkなど、idを指定することでそのidをもつ要素をクリックできます。

ちなみに、リンク・ボタン以外はこれではクリックできないです。例えば

みたいな要素は以下の形式でクリックします。
find("#id").click

fill_in target, with: text #input要素に入力する

fill_ininput要素に文字入力するメソッドです。targetinputidまたはnameなどを指定します。with以降の文字列が入力されます。

expect(input_target.value).to eq text

input_targetinput要素です。.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を描かれているのでShowEditDestroyリンクに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.rb
feature "ユーザーとして、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

end

all(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.rb
feature "ユーザーとして、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

end

expect(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.rb
feature "ユーザーとして、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

end

page.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のレコードが存在しないことを検証できます。reloadfind(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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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/system

Rails 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:migrate
app/models/item.rb
class Item < ApplicationRecord
  validates :name, presence: true
  validates :name, length: { maximum: 20 }
end

少しテストを面白くするために、nameにnot nullと20文字以内のバリデーションをかけました。

Structure

テストコードはspec/system/*_spec.rbファイルを作成して記述していきます。

テストコードの基本的な構文は以下の通りです。

spec/system/*_spec.rb
feature "ユーザーストーリー", 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.rb
feature "ユーザーとして、ページにアクセスしたい", 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

end

visit *_path

visitはパスにGETアクセスする操作です。後ろにURLや名前付きルーティングヘルパー(prefix)を指定して使います。
例えば、visit root_pathと書けば、config/routes.rbroot to:に指定したルートパスにアクセスする操作ということになります。

今回はscaffoldを使っているので、config/routes.rbresources :itemsが定義されています。
このおかげでテストコードに書いたような割り振りのprefixがつけられているというわけです。prefixは

$ docker-compose run web rails routes

で確認できます。

prefixはルーティング毎にconfig/routes.rbで定義できます。

config/routes.rb
Rails.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.rb
feature "ユーザーとして、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

end

click_on target

click_onはリンクまたはボタンをクリックするメソッドです。targetとして、:new_item_linkや、:back_linkなど、idを指定することでそのidをもつ要素をクリックできます。

ちなみに、リンク・ボタン以外はこれではクリックできないです。例えば

みたいな要素は以下の形式でクリックします。
find("#id").click

fill_in target, with: text #input要素に入力する

fill_ininput要素に文字入力するメソッドです。targetinputidまたはnameなどを指定します。with以降の文字列が入力されます。

expect(input_target.value).to eq text

input_targetinput要素です。.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を描かれているのでShowEditDestroyリンクに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.rb
feature "ユーザーとして、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

end

all(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.rb
feature "ユーザーとして、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

end

expect(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.rb
feature "ユーザーとして、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

end

page.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のレコードが存在しないことを検証できます。reloadfind(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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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はスキップできない様にしたみたいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails 発展その13 画像について

railsの画像に関する内容です。

ImageMagick

ImageMagickは、コマンドラインから簡単に画像の保存形式の変更などが行えるツールです。

ターミナル

ターミナル
$ brew install imagemagick

ImageMagickがインストールできたら、mini_magickをインストールします。
gemfileに追記しましょう。

gemfile
gem 'mini_magick'

次にターミナルでbundle installします。

ターミナル
$ bundle install

Active Storage

rails active_storage:installコマンドを実行すると
Active Storageが使用するテーブル用のマイグレーションファイルが作成されます。

ターミナル
$ rails active_storage:install
$ bundle exec rake db:migrate

Active Storage用の設定として

model
   class 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">  -->
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails 発展その11 コントローラー

コントローラーの諸知識の軽いまとめです。

before_action

あるコントローラのすべてのアクションで実行の前に共通の処理を行いたい場合のコード

contoroller
  class コントローラ名 < ApplicationController
    before_action :処理させたいメソッドの名前

コントローラーの継承 

コントローラーは継承元のコントローラーから内容を継承することが
可能です。継承元にbefore_actionが設定されていれば
それを行ってから現在のコントローラーで処理が実行されます。

controller
   class コントローラ < 継承元のコントローラ
   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 'レイアウトファイル名'と書くと、そのコントローラでのアクションが呼ばれたあと表示するビューのレイアウトファイルを指定できます。

controller
  class StoreController < ApplicationController
  layout 'レイアウトファイル名'
  # なにも指定しないとレイアウトファイルはapplication.html.erbになります。

  # 例
  layout 'pict'

  # StoreControllerのindexアクションが呼ばれたときに表示されるレイアウトはpict.html.erbとなります。
  def index
  end
end

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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等を行っています。

解決法を教えて頂けないでしょうか。

よろしくお願い致します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

geocoderで緯度と経度が登録されない問題

バージョン

Gemfile.lock
geocoder (1.5.2)

起きたこと

READMEを読んだ通りにやったのに、いくら住所を登録してもlatitudelongitudeがnullのままで登録される気配がない。

確認してみたこと

  • addresslatitudelongitudeのカラム名をTypoしてないか?
  • latitudelongitudeをfloat型で定義したか?
  • モデルにgeocoded_by :addressafter_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]

となり、しっかり緯度と経度が取得できていることが分かります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.rb
class 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という例外が発生し、登録に失敗していることがわかる。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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 の結果は同じです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rake routesの見方で誤解したこと【初心者】

Railsでルーティングの情報を詳しく見るときに、ターミナルでよく使用するコマンドが「rake routes」。

Railを学習し始めの頃にこのrake routesの見方で誤解してしまった点があったので、他に誤解する人が出ない為にも記録しておきます。

Prefixが空欄なワケ

スクリーンショット_2019-10-26_13_52_40.png

上記はroutes.rbで resources :books と記載してrake routesコマンドを実行したときに表示される情報です。このときのPrefixに注目してください。

Verb(HTTPメソッドのこと)がGET以外のものについてはPrefixの欄が空欄になっています。自分は最初にこれを見たときにGET以外のPrefixは存在しないのだと思いましたが、実はこれは空欄の上のPrefixと同じなので省略されているだけなのです。

つまり上記のrake routesを省略せずに記述すると以下の通りになります。

スクリーンショット_2019-10-26_13_52_40 2.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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 %>
    
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsでEC系に良く出てくるカート機能を解説 / 実装してみた

参考にした記事

Rails5でカート機能を作るためのロジックを作ってみた

おそらくこの記事を読みに来てくださる読者の方は上記の記事も読んでる可能性が高いのでモデルの設計は似せようと思います。

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

上記の内容でマイグレートを通した後であればコンソールで軽く遊んでみたりしてデフォルト値が設定されているかとか確認しておくと良いかもですね。

スクリーンショット 2019-11-12 9.58.58.png

(因みに補足ですが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 な感じで

スクリーンショット 2019-11-12 10.08.11.png

詳しくは本家の 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'
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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] #=> nil

stringと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
     => :tomohisa

paramsの中身を眺めていたときに、なんでこれまでsymbolとstringテキトーにやってきたのに値がとれてたんだ?と思って知ったことでした。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails 発展その10 レシーバ self

コードを書いていくと内容の繰り返しが多くなりやすくなるので
それを省略できる方法のまとめを使用と思います。

レシーバ

インスタンスメソッドを利用するインスタンス自身のことです。

   re = "9"
   #以下の式のレシーバはstr
   re.to_i #=> 9

self

インスタンスメソッドの中でselfと書くと
そのメソッドを利用したレシーバ自身が代入された変数のように扱うことができます。

  class Integer
    def kake?
      if self * 10 == 1
        return true
      else
        return false
      end
    end
  end
  10.kake?  #=> false(*は乗算を求めるもので、1ではないのでfalseが返ってくる)

railsでの使用例

ポイントの平均を求めたいメソッドを打つ場合

controller
  class 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のメソッドを使うことができます。-->
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails 発展その10

コードを書いていくと内容の繰り返しが多くなりやすくなるので
それを省略できる方法のまとめを使用と思います。

レシーバ

インスタンスメソッドを利用するインスタンス自身のことです。

   re = "9"
   #以下の式のレシーバはstr
   re.to_i #=> 9

self

インスタンスメソッドの中でselfと書くと
そのメソッドを利用したレシーバ自身が代入された変数のように扱うことができます。

  class Integer
    def kake?
      if self * 10 == 1
        return true
      else
        return false
      end
    end
  end
  10.kake?  #=> false(*は乗算を求めるもので、1ではないのでfalseが返ってくる)

railsでの使用例

ポイントの平均を求めたいメソッドを打つ場合

controller
  class 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のメソッドを使うことができます。-->
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

bundle execとは

rails s bundle execbundle exec、聞いたことあるけど特に書かなくても問題なく動いてきたのでずっと「なんかgemをいい感じにしてくれるもの」くらいの認識でいました。
多分、そろそろきちんと知っておいた方がいいので調べてみた。

bundle execはなぜ必要なのか

要するに、gemをいい感じにしてくれるものでした。

…それでは何もアップデートがないので、もう少し踏み込んでまとめると、gem Agem Bがあるとする。
プロジェクトAではA,B両方使っていて依存関係があり、プロジェクトBではAだけ使っている場合、gemBにアップデートがあるなんてことがあるとプロジェクトBでは勝手にアップデートしてくれという感じだがプロジェクトAではgem Aに影響が出てしまうのでそれは困る。
ここら辺を、プロジェクトごとに臨機応変にバージョン管理してくれるのがbundlerの役割らしい。

便利やん!!

今作っているようなプロダクトだとほとんどgemを使っていないので特に問題ないが、gemがいっぱいあるときには必須なのだと思いました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

joinsで複数のテーブルの結合をスマートに。

はじめに

テーブルの結合に対して苦手意識があり、せっかく便利なActiverecordがあるのにfind_by_sqlで逃げてしまい、sqlをゴリゴリに書いて結合させていた。

joinsを使って、3つ以上のテーブルを結合する方法に向き合ったのでまとめる。

結合するうえで、foreign_key, primary_keyをどう設定していくのかを絡めて、例をとりあげる。

取り上げた例

「本」「レビュー」「レビューした人」の3つテーブルを扱う。
DBは下の画像のとおりに設定する。

スクリーンショット 2019-11-11 15.58.24.png

取り出したい条件を
「女性が評価4.0以上レビューした本のタイトル一覧」とする。

つまり
where句でgender = 'woman' かつ evaluation >= 4.0 を指定して、
selectをtitleとするイメージ。

ただ、これをどう結合して扱うかが問題。

modelを整える

関係性を把握

あるユーザーはいくつもレビューする
あるレビューは一人のユーザーによって作られる
あるレビューは一つの本を対象にする

関係性は次のようになる。

スクリーンショット 2019-11-11 16.33.58.png

ひとつの結合

usersとreviewsの関係性だけみてみる。
まずusersのmodel

users.rb
class 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.rb
class 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_keyforeign_keyを使って、任意の外部キー code を設定する。

※ codeではなくbook_idとして紐付ければよいのだが、任意の外部キー設定をとりあげるためにcodeとしている。

reviews.rb
class Review < ApplicationRecord
  belongs_to :user, optional: true
  belongs_to :book, primary_key: :id, foreign_key: :code, optional: true
end
books.rb
class 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"

以上。
これで複数にまたがる結合からの抽出が完了

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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-dataTZInfoが参照するタイムゾーン情報を提供するgem。
TZInfoはRubyからタイムゾーン情報を参照し、その情報に基づいて時間をコンバートするためのライブラリ。

解決方法

この問題についてググってみると、githubのtzinfo-dataの作者のコメントに解決方法が書いてあった。

解決方法は4つあって下記のいずれかを実行すれば良い。

  1. Gemfileのtzinfo-data行のplatforms: [:mingw, :mswin, :x64_mingw, :jruby]を削除し、bundle updateを実行。プラットフォーム関係なくtzinfo-dataのタイムゾーンを参照する設定。

  2. Gemfileからtzinfo-data行を削除。tzinfo-dataではなくシステムのタイムゾーンを参照するようになる。Windowsの場合は、tzinfo-dataを削除すると、TZInfo::DataSourceNotFound例外が発生してしまう。

  3. bundle lock --add-platform mingw, mswin, x64_mingw, jrubyを実行。Gemfile.lockに依存プラットフォーム情報を記述してしまう方法。

  4. bundle config --local disable_platform_warnings trueを実行(Bundlerのバージョンが1.17.0以上であることが必須)。Bundler自体のwarningを出させないようにする設定。

4はカレントディレクトリのRailsアプリにだけ適用する方法で、システムのカレントユーザに対して非表示にしたければ、下記のように--localを省いて実行すればOK!

bundle config disable_platform_warnings true

Gemfileに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を入れる必要はないそう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.rb
Rails.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.rb
 def self.search(search)
    return Sample.all unless search
    Sample.where('title LIKE(?)', "%#{search}%")
  end

今回はtitleで検索をかけていきます。
モデルの中でメソッドを定義する際に、メソッド名の頭にself.を付けるとコントローラーで使えるクラスメソッドという物になります。

上記でLIKE句whereメソッドが出てきました
簡単に説明するとLIKE句とは、曖昧検索と言われる物に使われるもので、前方一致や後方一致などを指定するときに使います。
whereメソッドとは、モデル.where(条件)のように引数部分に条件を指定することで、テーブル内の条件に一致したレコードのインスタンスを配列の形で取得できます。そして今回は条件式に前述したLIKE句を使っています。
ちなみに上記の記述方法をif文を使い、よりわかりやすく書くと下記のようになります。

sample.rb
def self.search(search)
    if search
      Sample.where('title LIKE(?)', "%#{search}%")
    else
      Sample.all
    end
  end

Sampleテーブルから曖昧検索をかけて絞るのか、全てを出すのかという簡単な式を作ることができます。

searchアクションをコントローラーに定義

次はコントローラーにアクションを設定し先ほど定義したsearchメソッド使っていきます。

sample_controller.rb
 def search
    @sample = Sample.search(params[:keyword])
  end

params[:keyword]と書くことで、formで入力した、:keywordを取得することができます。
もしparamsのなかに何が入っているかを確認したい時は

sample_controller
def 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つのアクションのみで実装する方法も調べてみてください。
この先インクリメンタルサーチを実装したりするかどうかはまた少し工夫が必要になってきますが、ベースは今回記述した検索機能になります。
この記事が検索機能実装でつまずいてしまった人の助けになれれば幸いです。
ではでは!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Devise.rb設定ファイル項目まとめ

イントロダクション

Railsで使用するDeviseの設定ファイルである「/config/initializers/devise.rb」。
Deviseをセッティングする上で不可欠なファイルではありますが、解説してある物が探しても無かったので、備忘録兼ねて作成しました。

英語が疎い上に、全ての機能を利用しているわけじゃないので間違っている可能性あります。
もしおかしい部分あったらズビズビっとご指摘下さいませ・・。

環境

  • Rails 5.1.x
  • Devise 4.7.1

とてつもなく参考にさせて頂いたリンク

それではいってみよー!

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を使ったログイン機能を実装する!メール認証機能付き - Qiita

ORM 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 = true

Basic認証なども入れられるようですね。
gem deviseでAPIの認証を行う方法(Basic認証) - Qiita

Ajaxでログイン処理したい場合の設定などで編集する必要性が出てきそうです。
Rails Devise Ajax ログイン・登録 - Qiita

Configuration 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.minutes

Configuration 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 = true

Configuration for :recoverable / 復旧処理

# パスワードを復旧する際に使用するキーを設定。
config.reset_password_keys = [:email]

# パスワードリセットしてから再設定可能な有効期限。
config.reset_password_within = 6.hours

# パスワードリセット完了後に自動でログイン状態にさせるか。
config.sign_in_after_reset_password = true

Configuration 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
end

Wardenは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
end

Configuration for :registerable / 登録

# パスワード変更後に自動的にサインインさせるかどうか。
config.sign_in_after_change_password = true

まとめ

設定ファイルを眺めるだけで、Deviseがどれだけの事が出来るのかが分かるのが面白いですね。
非常にシンプルに使えて、かつ奥深く実用に耐えうるように設計されているという事が分かります。
一番面倒くさいであろう認証系を一挙に担ってくれるDevise。これからもRailsを使用するならお供する事が多そうです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.ymlwebpacker.ymlの変更を忘れないことです。

database.yml

default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password
  host: db

webpacker.yml

dev_server:
    https: false
    host: webpack
    port: 3035
    public: localhost:3035
    hmr: false

データベースのパスワードはdocker-compose.ymlに記載したパスを、両ファイルのhostにはそれぞれのコンテナ名を記載してください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む