はじめに
こちらは社内技術勉強会用の資料として作成したものです。
Ruby on Railsで、RSpecの使い方の一つである、Request Specを使ってみます。
サンプルプログラムを使って、7つの課題に取り組みながら、Request Specの使い方を見ていきます。課題の内容は、RSpecを使ってみるところから、会議室予約の新規登録画面を完成させるまでの流れになっています。
サンプルプログラムについて
概要
サンプルプログラムMR2(Meeting Room Reservation)は、会議室予約をするWebアプリケーションです。
ソースコードはこちらです。
- 以下のリソースを管理する機能があります。
- ユーザ
- 会議室
- 予約
- 各リソースについて、以下の画面があります。
- 一覧
- 新規登録
- 編集
- 詳細
- 各リソースの編集と削除は、詳細画面から行います。
- 各リソースの管理を行う画面にアクセスするにはログインが必要です。
- ログインは、プログラムを簡単にするために、ユーザIDのみで行います。
画面イメージ
ログイン画面
予約新規登録画面
プロジェクトの説明
サンプルプログラムのソースコードには、Railsのプロジェクトが2つ含まれています。プロジェクトはapps
ディレクトリに配置されています。
プロジェクト名 | 用途 |
---|---|
example | 課題を終えた後の状態のソースコードです。Webサーバを起動し、Webブラウザからアクセスして操作できるようになっています。 |
web | 課題を始める前の状態のソースコードです。 |
DB
DBは以下のテーブルで構成されています。
- ユーザ(users)
- 会議室(meeting_rooms)
- 予約(reservations)
- 予約ユーザ(reservation_users)
予約ユーザテーブルは、どの会議にどのユーザが参加するかを管理するための、中間テーブルです。
課題を始める前に
覚えておくこと
その1: 初期データの登録
例えばユーザの一覧を表示するには、DBにユーザが登録されていなければいけません。RSpecを使ってプログラムを実行する時、そのプログラムが使用するデータ(初期データ)をあらかじめ登録しておかなければならないことがあります。
初期データの登録には、今回はfactory_botを使用します。factory_botを使用すると、あらかじめ用意しておいたテンプレートを使って、DBへのレコードの作成を簡単に行うことができます。
例えば、usersテーブルにレコードを登録するための:user
という名前のテンプレートがある場合、以下のように記述すると、新しいレコードを1件登録することができます。
user = create(:user)
戻り値は登録したレコードを表すUserモデルのインスタンスになっています。
レコードは作成しないで、Userモデルのインスタンスだけ生成したい場合は以下のように記述します。
user = build(:user)
rails consoleで実行してみましょう。build()の実行により、Userモデルのインスタンスが生成されています。
$ RAILS_ENV=test bundle exec rails console
Loading test environment (Rails 5.2.3)
irb(main):001:0> include FactoryBot::Syntax::Methods
=> Object
irb(main):002:0> user = build(:user)
=> #<User id: nil, user_cd: "user1", user_nm: "User1", created_at: nil, updated_at: nil>
irb(main):003:0> exit
サンプルプログラムでは、テンプレートは各プロジェクトのspec/factories
ディレクトリに保存されています。書き方や使い方はGETTING_STARTEDなどを参照してください。
その2: HTTPリクエストの送信
Request Specではget
、post
などのメソッドを使ってHTTPリクエストを送信することができます。例えばログイン画面、サンプルプログラムのプロジェクトでは/sessions/new
にGETメソッドでアクセスする場合、以下のように記述します。
get new_session_path
POSTメソッドでパラメータを送信する場合は、そのパラメータをparams
引数に記述します。
post sessions_path, params: { session_form: { user_cd: 'user1' } }
その3: 結果の確認
ある画面の表示に成功したかどうかは、HTTPレスポンスのステータスコードが200かどうかで判定することができます。expectを使って以下のように書くことができます。
expect(response).to have_http_status(200)
その他、以下のような確認を行うことができます。
# 予約一覧画面にリダイレクトされること
expect(response).to redirect_to reservations_path
# HTTPレスポンスで出力されたHTMLに「認証に失敗しました。」という文字列が含まれていること
expect(response.body).to include '認証に失敗しました。'
その4: RSpecの基本的な使い方
ログイン画面の表示に成功することを確認するには、以下のようなspecファイルを作成します。
require 'rails_helper'
RSpec.describe "Sessions", type: :request do
describe "GET /sessions/new" do
it 'ログイン画面の表示に成功すること' do
get new_session_path
expect(response).to have_http_status(200)
# puts response.body
end
end
end
ファイルを作成したら、プロジェクトのルートディレクトリで、以下のように実行します。
$ bundle exec rspec -fd spec/requests/sessions_spec.rb
実行が正常に終了すると、以下のように表示されます。
Sessions
GET /sessions/new
ログイン画面の表示に成功すること
Finished in 2.86 seconds (files took 1.25 seconds to load)
1 example, 0 failures
前述「結果の確認」で期待する結果に一致しなかった場合、failuresの件数が増えていきます。
その5: 初期データと入出力パラメータの定義
specファイル内のit
には、「何をすればどうなるのか」を書きます。初期データの登録や入出力パラメータの定義などはlet
で記述します。例えば以下のような書き方ができます。
describe 'user1で' do
let (:user_cd) { 'user1' }
it 'ログインに成功すること' do
post sessions_path, params: { session_form: { user_cd: user_cd } }
expect(response).to redirect_to reservations_path
end
end
post
メソッド呼び出しのパラメータ内、user_cd
の値がuser1
になります。
let
から別のlet
を参照することもできます。
describe 'user1で' do
let (:req_params) { { session_form: { user_cd: user_cd } } }
let (:user_cd) { 'user1' }
it 'ログインに成功すること' do
post sessions_path, params: req_params
expect(response).to redirect_to reservations_path
end
end
req_params
定義内のuser_cd
の値がuser1
となり、その結果post
メソッド呼び出しのパラメータが{ session_form: { user_cd: 'user1' } }
となります。
上記の書き方ではあまり意味がありませんが、以下のような場合では効果が出てきます。
describe 'POST /sessions' do
let (:req_params) { { session_form: { user_cd: user_cd } } }
describe 'user1で' do
let (:user_cd) { 'user1' }
it 'ログインに成功すること' do
post sessions_path, params: req_params
expect(response).to redirect_to reservations_path
end
end
describe 'user2で' do
let (:user_cd) { 'user2' }
it 'ログインに失敗すること' do
post sessions_path, params: req_params
expect(response.body).to include '認証に失敗しました。'
end
end
end
2つのit
で、POST /sessionsアクションに与えるパラメータを切り替えつつ、post
メソッド呼び出しのパラメータを生成する処理が共通化されました。
課題1
サンプルプログラムのwebプロジェクトにおいて、ログイン画面の表示に成功することを確認してください。
説明
まずはすでに開発済みのコードのspecを書いてみましょう。webプロジェクトで、specファイルを作成し、実行してください。specファイルの書き方は前章を参照してください。
Hint 1
前述「覚えておくこと」の「その4: RSpecの基本的な使い方」に解答例があります。コードをコピー&ペーストして実行してみてください。
課題2
存在するユーザで、ログインに成功することを確認してください。
説明
Webブラウザを使わずに、ログイン処理が正しく行われるかどうかを確認してみましょう。
Hint 1
ログインの処理は、sessionsコントローラのcreateアクションで行われています。このアクションが実行されるようにHTTPリクエストを送信すると、ログイン処理の動作を確認することができます。
送信するべきパラメータは、createアクションのストロングパラメータを確認したり、exampleプロジェクトを使って実際にWebブラウザからアクセスしてみたりするとわかります。以下のような内容になっています。
Started POST "/sessions" for 127.0.0.1 at 2019-08-10 17:37:41 +0900
Processing by SessionsController#create as HTML
Parameters: {"session_form"=>{"user_cd"=>"user1"}}
Hint 2
ログインを成功させるためには、ログインできるユーザが必要です。usersテーブルにログインID(カラム名はuser_cd
)が一致するレコードがあれば、ログイン成功としています。
Hint 3
ログインが成功すると、予約一覧画面にリダイレクトされるようになっています。すなわち、ログインが成功したかどうかは、予約一覧画面のパスにリダイレクトが行われたかどうかで確認することができます。
解答例1
describe "POST /sessions" do
it '存在するユーザでログインに成功すること' do
user = create(:user)
post sessions_path, params: { session_form: { user_cd: user.user_cd } }
expect(response).to redirect_to reservations_path
end
end
解答例2
it
内には、「何をすればどうなるのか」を書きます。初期データを登録したり、パラメータを用意したりする処理を、let
を使って定義してみましょう。it
のコードが簡潔になり、わかりやすくなります。
describe "POST /sessions" do
describe '存在するユーザで' do
let (:user) { create(:user) }
let (:req_params) { { session_form: { user_cd: user.user_cd } } }
it 'ログインに成功すること' do
post sessions_path, params: req_params
expect(response).to redirect_to reservations_path
end
end
end
課題3
存在しないユーザで、ログインに失敗することを確認してください。
Hint 1
ログインに失敗するには、存在しない、すなわちDBに登録されていないユーザの情報を使ってログインを試みればよいでしょう。factory_botのbuild
メソッドを使うと、DBにレコードを作成しないでログインIDを生成することができます。
Hint 2
ログインに失敗すると、ログイン画面が再表示され、エラーメッセージとして「Failed to authenticate.
」と表示されます。HTTPレスポンスで出力されたHTMLにこの文字列が含まれているかどうかで、ログイン失敗を確認することができます。
解答例1
describe "POST /sessions" do
it '存在しないユーザでログインに失敗すること' do
user = build(:user)
post sessions_path, params: { session_form: { user_cd: user.user_cd } }
expect(response.body).to include 'Failed to authenticate.'
end
end
解答例2
HTTPリクエストに与えるパラメータの形は課題2と同じです。let
を使って定義し、共通化してみましょう。
describe "POST /sessions" do
let (:req_params) { { session_form: { user_cd: user.user_cd } } }
describe '存在するユーザで' do
let (:user) { create(:user) }
it 'ログインに成功すること' do
post sessions_path, params: req_params
expect(response).to redirect_to reservations_path
end
end
describe '存在しないユーザで' do
let (:user) { build(:user) }
it 'ログインに失敗すること' do
post sessions_path, params: req_params
expect(response.body).to include 'Failed to authenticate.'
end
end
end
課題4
ユーザの新規登録処理において、ログインIDが未入力の場合、登録に失敗するようにしてください。
説明
ここからいよいよ開発に入っていきます。
まずはモデルの開発をしてみましょう。ユーザの新規登録画面では、ログインID(DB上のテーブルのカラム名はuser_cd
)とユーザ名(user_nm
)を入力します。ログインIDはユーザを識別するコードであるため、空文字列での登録は許可するべきではありませんが、webプロジェクトのソースコードでは空文字列でも登録に成功してしまいます。
モデルにバリデーションルールを追加しましょう。
Hint 1
Userモデルの属性user_cd
に、入力必須のバリデーションルールpresence
を追加します。
モデルにバリデーションルールを追加できたら、RSpecで空文字列を与えて実行してみます。
factory_botのcreate
やbuild
メソッドは、第2引数にハッシュでパラメータを指定すると、テンプレートで定義されている値の代わりに、その値がレコードやモデルのインスタンスの生成に使用されます。user_cd
を空文字列としてUserモデルのインスタンスを生成するには、以下のように指定します。
user = build(:user, { user_cd: '' })
Hint 2
モデルのバリデーションエラーのメッセージは設定次第になりますが、標準的にはLogin ID can't be blank
のようなメッセージになります。HTMLではシングルクォーテーションが実体参照で表現され、Login ID can't be blank
となります。
Hint 3
RSpecを実行した時、もしかして以下のようなエラーで失敗になりましたか?
expected "<html><body>You are being <a href=\"http://www.example.com/sessions/new\">redirected</a>.</body></html>" to include "Login ID can't be blank"
/sessions/new
というパスにリダイレクトされた、という内容です。
ユーザの登録処理を行うためには、あらかじめいずれかのユーザでログインをしている必要があります。このエラーは、未ログイン状態でユーザの登録処理を実行しようとしたために、ログイン画面に遷移させられたことを表しています。
ユーザの登録処理を行うために、私たちはこの強固なセキュリティの壁を打ち破る必要があるのです。
解決策は単純です。ユーザの登録処理を実行する前に、ログイン処理を実行すればよいのです。
解答例1
class User < ApplicationRecord
validates :user_cd, presence: true
end
describe 'ログインIDが未入力の場合' do
it '登録に失敗すること' do
login_user = create(:user)
post sessions_path, params: { session_form: { user_cd: login_user.user_cd } }
user = build(:user, { user_cd: '' })
req_params = { user: user.serializable_hash(only: [ :user_cd, :user_nm ]) }
post users_path, params: req_params
expect(response.body).to include 'Login ID can't be blank'
end
end
解答例2
it
内の処理が多くなってきましたね。let
を使って簡潔になるようにしてみましょう。
また、ログイン処理はit
のたびに必要になります。before(:each)
を使うと、it
の前に必ず実行する処理を記述することができます。
let (:login_user) { create(:user) }
before (:each) do
post sessions_path, params: { session_form: { user_cd: login_user.user_cd } }
end
describe "POST /users" do
let (:req_params) { { user: user.serializable_hash(only: [:user_cd, :user_nm]) } }
describe 'ログインIDが未入力の場合' do
let (:user) { build(:user, { user_cd: '' }) }
it '登録に失敗すること' do
post users_path, params: req_params
expect(response.body).to include 'Login ID can't be blank'
end
end
end
課題5
予約の新規登録画面を作成し、新規登録処理が成功するようにしてください。ただし、参加者一覧は除きます。
説明
次はコントローラとビューの開発をしてみましょう。予約の新規登録画面には、以下の入力欄を用意します。
- タイトル (title、テキスト)
- 会議室 (meeting_room_id、ドロップダウン)
- 開始日時 (start_at、日時)
- 終了日時 (endat、日時)
- 参加者 (reservations_users、チェックボックス)
- メモ内容 (memo_content、テキストエリア)
webプロジェクトのソースコードでは、reservationsコントローラのnewとcreateのアクションはscaffoldで生成されたままの内容になっています。これらのアクションの処理内容を変更していきます。
また、新規登録画面のビューテンプレートも、scaffoldで生成されたままの内容になっています。こちらも併せて修正していきます。
会議の参加者は、自分以外のすべてのユーザの一覧から、チェックボックスで選択するようにします。ただしこの項目は、次の課題で取り扱うこととして、ここでは省略します。
なお、会議の主催者は、ログインユーザであるとします。
Hint 1
まず、ビューテンプレートの内容を確認してみましょう。どの入力項目もtext_fieldかtext_areaになってしまっているので、適切なフォームヘルパーに置き換える必要があります。
例えば、会議室(meeting_room_id
)という項目はどうでしょうか?ドロップダウンによる選択式にしたいですね。ドロップダウンを配置するには、フォームヘルパーselect
を使用します。form_with
のブロック引数の名前がform
となっている場合は、form.select
として使います。
ドロップダウンの選択肢は、options_for_selectを使って生成するとよいでしょう。
Hint 2
日時の入力欄はどのように作成すればよいでしょうか?JavaScriptでDatePickerのようなユーザインターフェイスを用意するのがよいですが、ここではRailsの標準機能にあるAction View フォームヘルパーの一つ、datetime_selectを使ってみましょう。以下のように記述すると、年、月、日、時、分を選択する5つのドロップダウンを持つ、開始日時の入力欄が生成されます。
<%= form.datetime_select :start_at %>
Hint 3
デザインはともかくとして、入力欄がうまく表示されるでしょうか?確認するには、/reservations/new
にGETメソッドでアクセスしてみると、HTMLを出力させることができます。
describe "GET /reservations/new" do
it "works! (now write some real specs)" do
get new_reservation_path
expect(response).to have_http_status(200)
# puts response.body
end
end
上記のコメントになっている行のコメントを解除してRSpecを実行すると、画面にHTMLが表示されます。
Hint 4
フォームヘルパーdatetime_selectで生成した入力欄を含むフォームがsubmitされた時、日時はどのような形で送信されるのでしょうか?exampleプロジェクトでWebブラウザからアクセスしてみると、以下のように送信されていることがわかります(一部省略しています)。
Parameters: {
"reservation"=>{
"title"=>"会議1",
"meeting_room_id"=>"1",
"start_at(1i)"=>"2019",
"start_at(2i)"=>"8",
"start_at(3i)"=>"11",
"start_at(4i)"=>"16",
"start_at(5i)"=>"00",
"end_at(1i)"=>"2019",
"end_at(2i)"=>"8",
"end_at(3i)"=>"11",
"end_at(4i)"=>"17",
"end_at(5i)"=>"00",
"memo_content"=>""
},
"commit"=>"登録"
}
どうやら5つのキーに分解されているようです。この日時の値はコントローラ側ではどのように受け取ればよいのでしょうか?
HTTPリクエストのパラメータとしては、日時の値は5つのキーに分かれていますが、Railsはこれらを自動的に一つの属性値として認識します。すなわち、ストロングパラメータには以下のように記述すればよいことになります。
params.require(:reservation).permit(
:title,
:meeting_room_id,
:start_at,
:end_at,
:memo_content,
)
解答例
class ReservationsController < LoggedInController
before_action :prepare_meeting_room_options, only: [ :new, :edit, :create, :update ]
def new
@reservation = Reservation.new
end
def create
@reservation = Reservation.new(reservation_params)
if @reservation.save
redirect_to @reservation, notice: '登録に成功しました。'
else
render :new
end
end
private
def reservation_params
params.require(:reservation).permit(
:title,
:meeting_room_id,
:start_at,
:end_at,
:memo_content,
).merge({ user_id: @me.id })
end
def prepare_meeting_room_options
@meeting_room_options = MeetingRoom.all.map { |it|
[ it.meeting_room_nm, it.id ]
}.unshift(['', 0])
end
end
<%= form_with(model: reservation, local: true) do |form| %>
<div class="field">
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div class="field">
<%= form.label :meeting_room_id %>
<%= form.select :meeting_room_id, options_for_select(@meeting_room_options, {
selected: @reservation.meeting_room_id
}) %>
</div>
<%= field_set_tag i18n_attr('reservation.start_at'), { class: 'field' } do %>
<%= form.datetime_select :start_at %>
<% end %>
<%= field_set_tag i18n_attr('reservation.end_at'), { class: 'field' } do %>
<%= form.datetime_select :end_at %>
<% end %>
<div class="field">
<%= form.label :memo_content %>
<%= form.text_area :memo_content %>
</div>
<div class="actions">
<%= form.submit t('view.app.submit') %>
</div>
<% end %>
require 'rails_helper'
module ReservationsSpecHelper
# 日時を年、月、日、時、分に分解する
def datetime_attrs(model, attr_key)
attr_value = model.public_send(attr_key)
{
"#{attr_key}(1i)": attr_value.year,
"#{attr_key}(2i)": attr_value.mon,
"#{attr_key}(3i)": attr_value.mday,
"#{attr_key}(4i)": attr_value.hour,
"#{attr_key}(5i)": attr_value.min,
}
end
# 予約のリクエストに与えるパラメータを生成
def reservation_req_params(reservation)
others = reservation.reservation_users.map { |it| it.user }
reservation.serializable_hash(only: [
:title,
:user_id,
:meeting_room_id,
:memo_content,
]).merge(
datetime_attrs(reservation, :start_at)
).merge(
datetime_attrs(reservation, :end_at)
)
end
end
RSpec.describe "Reservations", type: :request do
include ReservationsSpecHelper
let (:login_user) { create(:user) }
before (:each) do
post sessions_path, params: { session_form: { user_cd: login_user.user_cd } }
end
describe "GET /reservations/new" do
it "予約の新規登録画面の表示に成功すること" do
get new_reservation_path
expect(response).to have_http_status(200)
# puts response.body
end
end
describe "POST /reservations" do
let (:meeting_room) { create(:meeting_room) }
let (:others) { create_list(:user, 2) }
let (:reservation_attrs) do
{
user_id: login_user.id,
meeting_room_id: meeting_room.id,
start_at: Time.parse('2019-08-01 10:00:00+09'),
end_at: Time.parse('2019-08-01 11:00:00+09'),
}
end
let (:reservation) { build(:reservation, reservation_attrs) }
let (:req_params) { { reservation: reservation_req_params(reservation) } }
describe '新規登録に' do
let (:created_reservation) { Reservation.last }
it '成功すること' do
post reservations_path, params: req_params
expect(response).to redirect_to reservation_path(created_reservation.id)
end
end
end
end
課題6
予約の新規登録画面で、会議の参加者を登録できるようにしてください。
説明
「課題5」では参加者の登録を省略しました。ここではその参加者を登録できるようにして、新規登録画面を完成させます。
Hint 1
会議には複数のユーザが参加することができるようにします。これは、DB上では予約1件に対して複数のユーザを関連付けることで表現します。
予約の新規登録画面ではどのようなインターフェイスにすればよいでしょうか?いろいろ方法はありそうですが、ここではNested Attributesの機能を使ってみることにします。
Nested Attributesを使うためには、まず親となるモデルクラスで、Nested Attributesの宣言をします。
accepts_nested_attributes_for :reservation_users, allow_destroy: true
次にコントローラにおいて、新規登録画面の表示の際に、関連付けられる子モデルのインスタンスを生成しておきます。
@reservation = Reservation.new
others = User.where.not({ id: @me.id })
@reservation.reservation_users_attributes = others.map { |it| { user_id: it.id } }
ビューではfields_for
を使って子モデル用の入力欄を生成させます。
<%= form.fields_for :reservation_users do |form_builder| %><% reservation_user = form_builder.object %>
<li>
<%= form_builder.check_box :attendance_flag, { checked: reservation_user.attendance_flag }, 1, 0 %>
<%= form_builder.label :attendance_flag, reservation_user.user.user_nm %>
<%= form_builder.hidden_field :reservation_id %>
<%= form_builder.hidden_field :user_id %>
<%= form_builder.hidden_field :id %>
</li>
<% end %>
Hint 2
Nested Attributesで生成した入力欄を含むフォームがsubmitされた時、その値はどのような形で送信されるのでしょうか?もう一度確認してみましょう。
Parameters: {
"reservation"=>{
"title"=>"会議1",
"meeting_room_id"=>"1",
"start_at(1i)"=>"2019",
"start_at(2i)"=>"8",
"start_at(3i)"=>"11",
"start_at(4i)"=>"16",
"start_at(5i)"=>"00",
"end_at(1i)"=>"2019",
"end_at(2i)"=>"8",
"end_at(3i)"=>"11",
"end_at(4i)"=>"17",
"end_at(5i)"=>"00",
"reservation_users_attributes"=>{
"0"=>{
"attendance_flag"=>"1",
"reservation_id"=>"",
"user_id"=>"2",
"id"=>""
},
"1"=>{
"attendance_flag"=>"0",
"reservation_id"=>"",
"user_id"=>"3",
"id"=>""
}
},
"memo_content"=>""
},
"commit"=>"登録"
}
reservation_users_attributes
というキーで送信されているようです。コントローラでは、以下のように記述すると受け取ることができます。
params.require(:reservation).permit(
:reservation_users_attributes => [ :id, :reservation_id, :user_id, :attendance_flag ],
)
解答例
class Reservation < ApplicationRecord
has_many :reservation_users, inverse_of: :reservation
accepts_nested_attributes_for :reservation_users, allow_destroy: true
belongs_to :user
belongs_to :meeting_room, required: false
end
class ReservationsController < LoggedInController
def new
@reservation = Reservation.new
others = User.where.not({ id: @me.id })
@reservation.reservation_users_attributes = others.map { |it| { user_id: it.id } }
end
private
def reservation_params
params.require(:reservation).permit(
:title,
:meeting_room_id,
:start_at,
:end_at,
:memo_content,
:reservation_users_attributes => [ :id, :reservation_id, :user_id, :attendance_flag ]
).merge({ user_id: @me.id })
end
end
<%= field_set_tag i18n_attr('reservation.reservation_users'), { class: 'field' } do %>
<ul>
<%= form.fields_for :reservation_users do |form_builder| %><% reservation_user = form_builder.object %>
<li>
<%= form_builder.check_box :attendance_flag, { checked: reservation_user.attendance_flag }, 1, 0 %>
<%= form_builder.label :attendance_flag, reservation_user.user.user_nm %>
<%= form_builder.hidden_field :reservation_id %>
<%= form_builder.hidden_field :user_id %>
<%= form_builder.hidden_field :id %>
</li>
<% end %>
</ul>
<% end %>
module ReservationsSpecHelper
def nested_reservation_users(others)
others.each_with_index.map { |user, idx| [ idx, { user_id: user.id } ] }.to_h
end
def reservation_req_params(reservation)
others = reservation.reservation_users.map { |it| it.user }
reservation.serializable_hash(only: [
:title,
:meeting_room_id,
:memo_content,
]).merge(
datetime_attrs(reservation, :start_at)
).merge(
datetime_attrs(reservation, :end_at)
).merge({
reservation_users_attributes: nested_reservation_users(others)
})
end
end
RSpec.describe "Reservations", type: :request do
include ReservationsSpecHelper
describe "POST /reservations" do
let (:meeting_room) { create(:meeting_room) }
let (:others) { create_list(:user, 2) }
let (:reservation_attrs) do
{
user_id: login_user.id,
meeting_room_id: meeting_room.id,
start_at: Time.parse('2019-08-01 10:00:00+09'),
end_at: Time.parse('2019-08-01 11:00:00+09'),
reservation_users_attributes: nested_reservation_users(others),
}
end
let (:reservation) { build(:reservation, reservation_attrs) }
let (:req_params) { { reservation: reservation_req_params(reservation) } }
describe '新規登録に' do
let (:created_reservation) { Reservation.last }
it '成功すること' do
post reservations_path, params: req_params, headers: headers
expect(response).to redirect_to reservation_path(created_reservation.id)
end
end
end
end
課題7
予約の新規登録において、同じ会議室、時間帯にすでに予約が登録されている場合、登録に失敗するようにしてください。
説明
最後の課題です。会議室予約のシステムは二つの会議が同じ会議室で予定されてしまうことを防ぐためのものですが、現在のソースコードでは特に警告もなく登録できてしまいます。
実用的には、登録は成功するが警告が表示される、というような動作が望ましいかもしれませんが、ここでは簡単にするために登録を禁止することにします。
Hint 1
登録を禁止するのでバリデーションルールを定義することで対応できそうです。DBの検索にはfind_by_sql
を使ってみてもよいでしょう。
解答例
class Reservation < ApplicationRecord
validate :check_booking
private
def check_booking
return if self.meeting_room_id.blank? || self.meeting_room_id.to_i <= 0
sql = <<-SQL
SELECT
EXISTS (
SELECT
1
FROM
reservations AS t1
WHERE
(:id IS NULL OR t1.id <> :id)
AND t1.meeting_room_id = :meeting_room_id
AND (
( :start_at <= t1.start_at AND :end_at > t1.start_at )
OR ( :start_at < t1.end_at AND :end_at >= t1.end_at )
)
) AS booked_flag
SQL
res = self.class.find_by_sql([sql, {
id: self.id,
meeting_room_id: self.meeting_room_id,
start_at: self.start_at.to_s,
end_at: self.end_at.to_s,
}])
if res.first.booked_flag
self.errors.add(:meeting_room_id, I18n.t('errors.messages.booked'))
end
end
end
RSpec.describe "Reservations", type: :request do
describe "POST /reservations" do
let (:meeting_room) { create(:meeting_room) }
let (:others) { create_list(:user, 2) }
let (:reservation_attrs) do
{
user_id: login_user.id,
meeting_room_id: meeting_room.id,
start_at: Time.parse('2019-08-01 10:00:00+09'),
end_at: Time.parse('2019-08-01 11:00:00+09'),
reservation_users_attributes: nested_reservation_users(others),
}
end
let (:reservation) { build(:reservation, reservation_attrs) }
let (:req_params) { { reservation: reservation_req_params(reservation) } }
describe 'すでに予約されている場合' do
let! (:prepare_reserved) { create(:reservation, reservation_attrs) }
it '予約済みで失敗すること' do
post reservations_path, params: req_params
expect(response.body).to include 'Meeting Room has been already booked'
end
end
end
end
宿題
ユーザの新規登録をした後、すでに登録済みの予約で、新しいユーザを参加できるようにしてください。
説明
user1、user2、user3が存在する状態で、user1でログインし、予約の新規登録画面を表示すると、参加者の選択部分にuser2、user3が表示されます。予約の登録を完了させた後、(予約の編集機能が完成していたとして、)予約の編集画面を表示すると、参加者の一覧には再びuser2、user3が表示されます。ここまでは期待している動作です。
しかしこの後、user4を新規登録してから先ほどの予約の編集画面を表示すると、相変わらずuser2、user3しか表示されず、user4をこの会議に参加させることができません。
原因は、編集画面に表示されるのはユーザテーブルのレコードではなく、予約ユーザテーブルのレコードだからです。ユーザの新規登録を行っても予約ユーザテーブルにレコードは増えないため、user4は予約編集画面には表示されません。
予約が存在する状態でユーザの新規登録した時、新しいユーザが予約ユーザにも追加されるように修正してください。
解答例
class User < ApplicationRecord
after_create :update_reservation_users
def update_reservation_users
list = Reservation.select(:id)
list.each do |reservation|
reservation_user = ReservationUser.new({
reservation_id: reservation.id,
user_id: self.id,
})
reservation_user.save
end
end
end
module UsersRequestSpecHelper
def nested_reservation_users(others)
others.each_with_index.map { |user, idx| [ idx, { user_id: user.id } ] }.to_h
end
end
RSpec.describe "Users", type: :request do
include UsersRequestSpecHelper
let (:login_user) { create(:user) }
before (:each) do
post sessions_path, params: { session_form: { user_cd: login_user.user_cd } }, headers: headers
end
describe "POST /users" do
let (:user) { build(:user) }
let (:req_params) { { user: user.serializable_hash(only: [:user_cd, :user_nm]) } }
let (:meeting_room) { create(:meeting_room) }
let (:others) { create_list(:user, 2) }
let (:reservation_attrs) do
{
user_id: login_user.id,
meeting_room_id: meeting_room.id,
reservation_users_attributes: nested_reservation_users(others),
}
end
let (:reservation_base_at) { Time.parse('2019-08-01 10:00:00+09') }
let! (:reservations) do
(0..1).map do |i|
meeting_time_attrs = {
start_at: reservation_base_at + i.hour,
end_at: reservation_base_at + (i + 1).hour,
}
create(:reservation, reservation_attrs.merge(meeting_time_attrs))
end
end
describe '予約が存在する状態でユーザの新規登録を行うと' do
let (:created_user) { User.last }
let (:created_reservation_users) { ReservationUser.where({ user_id: created_user.id }) }
it '新しいユーザが予約ユーザに追加されること' do
post users_path, params: req_params, headers: headers
expect(response).to redirect_to user_path(created_user.id)
expect(created_reservation_users.length).to eq(reservations.length)
end
end
end
end
おわりに
Request Specで、初期データの登録、HTTPリクエストの送信、結果の確認を行うことができました。
プログラミングはたくさん書かないと身に着かないので、RSpecもたくさん使って、時間をかけずにテストコードを書けるようにしていきたいと思います。