大ちゃんの駆け出し技術ブログ

RUNTEQ受講生のかわいいといわれるアウトプットブログ

partialについて解説

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

本記事では複数のページで共通のファイルを利用するための機能であるパーシャルについて解説をします。

基礎編を学習していた時のメモを見つけたため、初学者向けに記事を作ろうかなと思ったためです。 では早速見ていきましょう。

パーシャルとは

Ruby on Railsでは度々目にするパーシャルですが、一体どのようなものでしょうか?

Rails学習の初期段階では、「複数のページで同じレイアウト部分のファイルを共通化する方法」と説明されることが多いかと思います。

例えば、簡単なタスク管理アプリがあり、中に新規タスク登録ページとタスク編集ページがあるとします。

新規タスク登録ページ e4b93bb581673f73b2a795aaa5233fe6.png

タスク編集ページ 45a46fc86db90f64820687aba42a9e7a.png

一目瞭然ですが、レイアウトがほとんど一緒です。 わかりやすい例を見せるためにシンプル過ぎるレイアウトを使用していますが、サイトのレイアウトの統一感のために、複数のページで似たようなレイアウトになるのはよくありそうです。

実際、これら2つのページのファイルの構成も共通部分が多くなり、無駄が多いように思えます。(RubyDRY(Don't Repeat Yourself)に反していますね、、)。form_withから下は全く同じですよね。 なお、今回は参考にしたテキストでerbではなくslimが使われていたので、そのまま使用していますmm

新規タスク登録ページのファイル

h1 タスクの新規登録

.nav.justify-content-end
  / tasks_path = /tasks
  = link_to '一覧', tasks_path, class: 'nav-link'

= form_with model: @task, local: true do |f|
  .form-group
    = f.label :name
    = f.text_field :name, class: 'form-control', id: 'task_name'
  .form-group
    = f.label :description
    = f.text_area :description, rows: 5, class: 'form-control', id: 'task_name'
  = f.submit nil, class: 'btn btn-primary'

タスク編集ページのファイル

h1 タスクの編集

.nav.justify-content-end
  = link_to  "一覧", tasks_path, class: 'nav-link'

= form_with model: @task, local: true do |f|
  .form-group
    = f.label :name
    = f.text_field :name, class: 'form-control', id: 'task_name'
  .form-group
    = f.label :description
    = f.text_area :description, rows: 5, class: 'form-control', id: 'task_name'
  = f.submit nil, class: 'btn btn-primary'

パーシャルを使用して共通化

本題であるパーシャルを使った共通化をします。

まずはじめにやることは、パーシャルテンプレートとしてapp/views/tasks/_form.html.slimというファイルを作成します。 そしての新規タスク登録ページのファイルとタスク編集ページのファイルの共通部分を貼り付けます。( 2つのファイルから共通部分は削除しておきましょう!) また、インスタンス変数@tasktaskに変更してください。(後ほど理由を説明します!)

= form_with model: task, local: true do |f|
  .form-group
    = f.label :name
    = f.text_field :name, class: 'form-control', id: 'task_name'
  .form-group
    = f.label :description
    = f.text_area :description, rows: 5, class: 'form-control', id: 'task_name'
  = f.submit nil, class: 'btn btn-primary'

原則として_form.html.slimのように、テンプレートファイルの名前の先頭には_(アンダースコア)を付けるのが一般的です。

次に、すっきりした新規タスク登録ページのファイルとタスク編集ページのファイルに同じコード render partial: "form", locals: { task: @task } を記載します。

h1 タスクの新規登録

.nav.justify-content-end
  = link_to '一覧', tasks_path, class: 'nav-link'

= render partial: "form", locals: { task: @task }
h1 タスクの編集

.nav.justify-content-end
  = link_to  "一覧", tasks_path, class: 'nav-link'

= render partial: "form", locals: { task: @task } 

render partial: "form", locals: { task: @task }のオプション部分partiallocalsは一般的には省略されますが、説明のため敢えて省略しない書き方にしてあります。省略するとrender "form", { task: @task }と書きます。

まず、partial:で読み込むテンプレートファイルを指定します。 今回は同じフォルダにテンプレートファイルがあるので"form"で読み込むことができますが、別フォルダにパーシャルファイルがある場合は、フォルダも含めて読み込むファイルを指定する必要があります。

locals:は一見わかりにくいのですがざっくり説明すると、インスタンス@taskの中身をローカル変数taskに入れてパーシャルのテンプレートファイルに渡すという意味になります。 つまり、task: @taskの部分でインスタンス変数の値が変数taskに渡される。

task = @task

そしてlocals:によってローカル変数としてパーシャルにtaskを渡すといったイメージです。

これにより、パーシャル内でローカル変数taskが有効になり、先ほどパーシャル内で記述したtaskも有効になります。

これだけの記述で共通化は終了です。 レイアウトも変わらず、共通化を行うことでファイルの中身もすっきりしました。

パーシャルにローカル変数を使う理由

今回の実装方法に反してインスタンス変数のままでも問題なく実装できます。

/ インスタンス変数@taskのまま
= form_with model: @task, local: true do |f|
  .form-group
    = f.label :name
    = f.text_field :name, class: 'form-control', id: 'task_name'
  .form-group
    = f.label :description
    = f.text_area :description, rows: 5, class: 'form-control', id: 'task_name'
  = f.submit nil, class: 'btn btn-primary'
h1 タスクの新規登録

.nav.justify-content-end
  = link_to '一覧', tasks_path, class: 'nav-link'

/ ローカル変数を指定しないことでパーシャルにインスタンス変数が渡される
= render partial: "form"

ではなぜわざわざローカル変数に変える必要があるのでしょうか。

実はインスタンス変数をパーシャルで使ってしまうと、再利用性が低下するという問題が発生します。

パーシャルにコントローラーで作成されたインスタンス変数を使ってしまうと、コントローラーとパーシャルの関係が密結合となってしまいます。つまりコントローラー側での変更が直接パーシャルに影響してしまうのです。これは複数のビューから呼びだされるパーシャルの再現性を低下させます。

例えば、コントローラーのインスタンス変数の名前を変えた場合は、パーシャルのインスタンス変数も同じように変えなければなりません。それに加えて、そのパーシャルを使っている他のビューのコントローラーでも同じインスタンス変数の名前に変更しなければならないのです。再利用性が崩壊しています、、、

終わりに

いかがでしたでしょうか。

パーシャルは非常によく使われると思います。

今回の例のようなシンプル過ぎるレイアウトなら簡単ですが、本来であればここまでシンプルなページはそうないでしょう。しかし、それでもUIの統一性を考慮する場合、パーシャルが使われるケースは多いでしょう。ヘッダーやフッターなど、どのページにも共通する部分にパーシャルを使うことは明らかです。

是非パーシャルを使いこなしてみてください!

以上、大ちゃんの駆け出し技術ブログでした!

【リクエストスペック②】リクエストスペックの作成

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

1日空けてしまいましたが、本日はリクエストスペックの記述方法についてまとめていきたいと思います。

先日出した記事ではAPIの概要などを簡単に紹介しました。

sakitadaiki.hatenablog.com

本記事ではいよいよリクエストスペックを書いていきます!

テスト内容は前回の記事の中で紹介した『ログインユーザーの情報にアクセスできること』です。正しいパラメータを入力した上で、POST形式でlocalhost:3000/api/v1/authenticationにアクセスすると下記JSON形式の情報が取得できことを検証します。

{
    "data": {
        "id": "1",
        "type": "user",
        "attributes": {
            "name": "MyString1",
            "email": "MyText1"
        }
    }
}

ファイルの作成

早速スペックファイルを作成します。

他のシステムスペックやモデルスペックでもファイル作成用のコマンドがありますが、リクエストスペックにもあります。

$ bin/rails g rspec:request ファイル名

今回はapi/v1/配下のファイルをテストするため、テストファイルも同様にapi/v1/配下のフォルダに格納した方が良さそうです。どうするかというとファイル名の手前にApi::V1::を置くだけです。そして今回テストするファイルはauthenticationですので、ファイル名もそれに合わせて指定しましょう。

$ bin/rails g rspec:request Api::V1::authentication

こちらを実行すると指定したディレクトリにフォルダが作成されました。

$ bin/rails g rspec:request Api::V1::authentication
Running via Spring preloader in process 6744
      create  spec/requests/api/v1/authentications_spec.rb

無事ファイルが作成されました。

ファイルの中身を説明

作成されたフォルダの中身を見てみましょう。

# spec/requests/api/v1/authentications_spec.rb
require 'rails_helper'

RSpec.describe "Api::V1::Authentications", type: :request do
  describe "GET /api/v1/authentications" do
    it "works! (now write some real specs)" do
      get api_v1_authentications_path
      expect(response).to have_http_status(200)
    end
  end
end

いつものシステムスペックやモデルスペック作成時とはかなり見た目が違うのではないでしょうか。

まず、type: :requestの部分からわかる通り、テストの種類がリクエストであることがわかると思います。

その下のdescribeの中身は現状「GET形式で/api/v1/authenticationsにアクセスする」という記述になっていますね。これはリクエストスペックの作成時はデフォルトでGET形式が設定されているからです。今回テストするのはPOST形式ですので後ほどこちらは修正します。

その下のitの中身も目新しいかなと思います。

get api_v1_authentications_path
expect(response).to have_http_status(200)

get api_v1_authentications_pathについては直感的に理解可能かと思います。describe部分で記述している通り、「GET形式で/api/v1/authenticationsにアクセス」を行っています。こちらはHTTPリクエストの形式です。これを例えばPOST形式にする場合、post api_v1_authentications_pathを使うことができます。

次の検証部分ですが、これはHTTPステータスコードの検証をしています。HTTPステータスコードを知らないという方はMDNの説明をご覧ください。

HTTP レスポンスステータスコードは、特定の HTTP リクエストが正常に完了したどうかを示します。レスポンスは 5 つのクラスに分類されています。 1. 情報レスポンス (100199), 2. 成功レスポンス (200299), 3. リダイレクト (300399), 4. クライアントエラー (400499), 5. サーバエラー (500599)

developer.mozilla.org

have_http_status(200)とすることでステータスコードが200、つまりHTTPリクエストが正常に完了したことを検証しています。反対に200を400などにすることでクライアントエラーが発生するかどうかを検証することができます。

リクエストスペックについて少し理解ができたのではないでしょうか?

POST形式でのアクセス方法

それではPOST形式でlocalhost:3000/api/v1/authenticationに正しいパラメータでアクセスすると、JSON形式の情報が取得できことを検証するテストを書いていきます。テストのマッチャは記載せず、describecontextitのみを記載して検証ファイルの流れを作ります。

require 'rails_helper'

RSpec.describe "Api::V1::Authentications", type: :request do
  describe "POST /api/v1/authentication" do
    context '正しいパラメータが入力された時' do
      it 'JSON形式の情報を返す' do
      end
    end
  end
end

それでは正しいパラメータを入力する方法から紹介していきます。

UserのFactoryBotは現在以下のように定義されています。

FactoryBot.define do
  factory :user do
    sequence(:name)  { |n| "MyString#{n}" }
    sequence(:email) { |n| "MyText#{n}" }
    password { 'password' }
  end
end

このFactoryBotのユーザーでログインしたとき、ログインユーザーの情報を取得できるかどうかを検証します。リクエストスペックファイル内ではlet!を使用してユーザーを新規作成します。

require 'rails_helper'

RSpec.describe "Api::V1::Authentications", type: :request do
  describe "POST /api/v1/authentications" do
    let!(:user) { create(:user) }
    context '正しいパラメータが入力された時' do
      it 'JSON形式の情報を返す' do
      end
    end
  end
end

次にPOST形式でlocalhost:3000/api/v1/authenticationにアクセス方法に移ります。これは先に記述を見せてから説明します。

require 'rails_helper'

RSpec.describe "Api::V1::Authentications", type: :request do
  describe "POST /api/v1/authentications" do
    let!(:user) { create(:user) }
    let(:password) { 'password' }
    context '正しいパラメータが入力された時' do
      it 'JSON形式の情報を返す' do
        post api_v1_authentication_path, headers: { CONTENT_TYPE: 'application/json', ACCEPT: 'application/json' }
      end
    end
  end
end

post api_v1_authentication_pathならわかると思います。POST形式でlocalhost:3000/api/v1/authenticationにアクセスしています。問題は後半部分のheaders以降になるかと思います。

headers: { CONTENT_TYPE: 'application/json', ACCEPT: 'application/json' }

これを簡単に説明している記事を見つけましたので共有します。

qiita.com

ACCEPTとは「クライアント側がどんなデータを処理できるか」を表しています。つまり受け取るファイル形式であるJSONを許容(ACCEPT)しているということです。受け取るファイルはapplication/jsonを指定しています。そもそもこの記述がないとテストでJSON形式のファイルを取得できないという意味にも取れます。

CONTENT_TYPEとは「実際にどんな形式のデータを送信したか」を表しています。サーバー側からクライアント側へ渡すファイル形式がJSONであるということです。こちらもapplication/jsonを指定しているため、このJSONファイルを送信することを指定しています。

つまり、ACCEPTとCONTENT_TYPEに同じファイル(application/json)指定することで、JSONファイルの送受信を可能にしているということです。JSONデータを取得するリクエストスペックでは必須の記述になると思います。

次に、正しいパラメータを入力する方法です。下記画像のQuery Paramsの入力部分になります。

https://i.gyazo.com/ecf0946222a64cc5d186bc6590f59ac2.png

これはparamsオプションを使って指定することができます。

RSpec.describe "Api::V1::Authentications", type: :request do
  describe "POST /api/v1/authentications" do
    let!(:user) { create(:user) }
    let(:password) { 'password' }
    context '正しいパラメータが入力された時' do
      it 'JSON形式の情報を返す' do
        post api_v1_authentication_path, headers: { CONTENT_TYPE: 'application/json', ACCEPT: 'application/json' }, params: { email: user.email, password: user.password }.to_json
      end
    end
  end
end

パラメータ取得部分

params: { email: user.email, password: password }.to_json

こちらもJSON形式でのリクエストという理由でto_jsonを指定しています。また、passwordのパラメータはuser.passwordでは入力できません。user.passwordは'password'をハッシュ化したものだからです。

crypted_password: "$2a$10$HO18dOf1JpzNaQ2Cq7JwHOdNe/KMEurfmt7e6sYZ.AxkfnFvF7jBa"

したがって、let(:password) { 'password' }で変数passwordをパラメータ部分に指定することでハッシュ化前の正しいパスワードを入力しています。

これでPOST形式でlocalhost:3000/api/v1/authenticationにアクセスできるようになりましたが、postメソッド部分がオプションのせいで少し頭でっかちですので、変数request_hashにまとめてしまいましょう。

require 'rails_helper'

RSpec.describe "Api::V1::Authentications", type: :request do
  describe "POST /api/v1/authentications" do
    let!(:user) { create(:user) }
    let(:password) { 'password' }
    context '正しいパラメータが入力された時' do
      it 'JSON形式の情報を返す' do
        request_hash = { headers: { CONTENT_TYPE: 'application/json', ACCEPT: 'application/json' }, params: { email: user.email, password: password }.to_json }
        post api_v1_authentication_path, request_hash
      end
    end
  end
end

リクエストスペックのマッチャ

それでは実際にマッチャを使って検証してみましょう。

まず、POST形式でのアクセスは成功しているはずですので、HTTPステータスコードは200を返しているハズです。よって上述したhave_http_status(200)を使用してみましょう。

require 'rails_helper'

RSpec.describe "Api::V1::Authentications", type: :request do
  describe "POST /api/v1/authentications" do
    let!(:user) { create(:user) }
    let(:password) { 'password' }
    context '正しいパラメータが入力された時' do
      it 'JSON形式の情報を返す' do
        request_hash = { headers: { CONTENT_TYPE: 'application/json', ACCEPT: 'application/json' }, params: { email: user.email, password: password }.to_json }
        post api_v1_authentication_path, request_hash
        expect(response).to have_http_status(200)
      end
    end
  end
end

テストはパスしたハズです。しかし、これだけだとアクセスは成功していることはわかりますが、期待するJSONファイルを取得できているのかがわかりません。

どのようにしてJSONファイルを取得するのかというと、JSON.parse(response.body)で取得することができます。テストをbinding.pryで止めて確認してみましょう。

JSON.parse(response.body)
=> {"data"=>{"id"=>"1", "type"=>"user", "attributes"=>{"name"=>"MyString1", "email"=>"MyText1"}}}

確かにJSON形式のファイルを取得できました。この中身を検証するためのテストを書きましょう。各キーに対する値が正しければ期待するJSONファイルが取得できているということを検証できます。

require 'rails_helper'

RSpec.describe "Api::V1::Authentications", type: :request do
  describe "POST /api/v1/authentications" do
    let!(:user) { create(:user) }
    let(:password) { 'password' }
    context '正しいパラメータが入力された時' do
      it 'JSON形式の情報を返す' do
        request_hash = { headers: { CONTENT_TYPE: 'application/json', ACCEPT: 'application/json' }, params: { email: user.email, password: password }.to_json }
        post api_v1_authentication_path, request_hash
        expect(JSON.parse(response.body).dig('data', 'id').to_i).to eq(user.id)
        expect(JSON.parse(response.body).dig('data', 'attributes', 'name')).to eq(user.name)
        expect(JSON.parse(response.body).dig('data', 'attributes', 'email')).to eq(user.email)
        expect(response).to have_http_status(200)
      end
    end
  end
end

digメソッドについて簡単に説明します。digメソッドはネストしたハッシュから安全に値を取り出すことができるメソッドです。例えば、expect(JSON.parse(response.body).dig('data', 'id')の部分ですが、dig('data', 'id')とすることで、最初のキーである'data'の値のハッシュのキーである'id'の値である'1'を取り出しています。

{"data"=>{"id"=>"1", "type"=>"user", "attributes"=>{"name"=>"MyString1", "email"=>"MyText1"}}}

ようは、深い階層にあたるハッシュの値を取得するメソッドということになります。下記2つの文もそれぞれ深い階層にある値を取り出し、その値が期待しているものかを検証しているのです。

expect(JSON.parse(response.body).dig('data', 'attributes', 'name')).to eq(user.name)
expect(JSON.parse(response.body).dig('data', 'attributes', 'email')).to eq(user.email)

テストを実行するとパスするかと思います。

終わりに

2つの記事に分けましたがやはりボリュームが大きかったですね。

リクエストスペックは1つ目の記事にも書きましたが、APIのための統合テストです。ですので、基本的にAPIを使用しない場合はメジャーなシステムスペックを使用しましょう!

以上、大ちゃんの駆け出し技術ブログでした!

フラッシュメッセージの実装

フラッシュメッセージとは

こんにちは!大ちゃんの駆け出し技術ブログです。

ユーザー側の入力に基づいて処理が行われた際に、その処理の結果をユーザー側にわかるように表示されるメッセージのことです。例えば、アカウント登録、アカウント情報の更新の処理などに使用されています。

実装はさほど難しいものではありませんので、仕組みをしっかり理解しておきましょう。

実装方法

本記事ではアクションコントローラーでユーザ登録機能を実装することにします。 登録フォームは下の画像のようなシンプルなフォームです。

image.png

登録ボタンを押すことで新規登録のアクションであるcreateアクションが実行されます。 (フラッシュメッセージ記述済み)

class UsersController < ApplicationController
 
  def create
    @user = User.new(user_params)

    if @user.save
      flash[:success] = 'ユーザー登録が完了しました'
      redirect_to login_path 
    else
      flash.now[:danger] = 'ユーザー登録に失敗しました'
      render :new 
    end
  end

  private

  def user_params
    params.require(:user).permit(:last_name, :first_name, :email, :password, :password_confirmation)
  end
end

createアクションの最初の行でインスタンス変数@userに入力した内容が渡されます。

@user = User.new(user_params)

補足で説明すると、user_paramsはprivateメソッドとして下の行に定義されています。

private

def user_params
  params.require(:user).permit(:last_name, :first_name, :email, :password, :password_confirmation)
end

これはストロングパラメーターというもので、簡単に説明すると、permitの()内で括られた値以外は取得することを何人たりとも許可しないという意味になります。これは悪意のあるユーザーがユーザーが入力した以外の情報を取得することを防ぐセキュリティ対策で、Railsでは必須の知識です。

インスタンス変数@userは条件分岐でsaveメソッドにかけられます。

if @user.save

このsaveメソッドでユーザ登録に必要な情報が正しく入力されているかを判断し、情報に問題がなければtrueを返します。逆に、入力欄が空白であったり、確認用パスワードが入力したパスワードと違っていたりするとfalseを返します。

いよいよ本記事のトピックであるflashメッセージとのご対面です。 情報が正しく入力されていれば登録完了の分岐となります。

flash[:success] = 'ユーザー登録が完了しました'
redirect_to login_path 

flash[:success]に文字が格納され、リダイレクト先であるlogin_pathに「ユーザー登録が完了しました」、というメッセージが表示されます。本当にたったこれだけです!

ちなみに上記の文章は一文で記述することもできます。 コードはプログラマーがコードを理解する速さが重視されるのでこちらの記述が好まれるでしょう。 (しかし、本記事の最後の節に記載するadd_flash_typesを実装しないとできませんのでご注意ください、、、。)

redirect_to login_path, flash: 'ユーザー登録が完了しました'

反対に、情報が正しく入力されていなかった場合、else以降が実行されます。

flash.now[:danger] = 'ユーザー登録に失敗しました'
render :new 

こちらはレンダー先である新規登録ページでメッセージが表示されます。

flash.nowとflashの使い分け

結論から先に行ってしまうと、

成功時(リダイレクト)にはflash 失敗時(レンダー)にはflash.now

詳しく説明すると、基本的に新規登録や編集機能などではif文を使って、trueの場合はredirect_tofalseの場合はrenderが使われます。

redirect_torenderには大きな違いがあります。

redirect_to・・・アクションを経由して画面遷移 render・・・アクションを経由しないで画面遷移

そしてflashflash.nowにも違いがあります。

flash・・・1回目のアクションの経由では消えず、次のアクションまで表示させる。 flash.now・・・次のアクションに移行した時点で消える。

renderはアクションを経由せずページだけを表示させるため、もしflashを使ってしまうと表示が消えるまでに2回のアクションが必要となります。 つまり、今回の例で言えば、正しい情報が入力されログインページにて、「ユーザー登録が完了しました」、というメッセージが次に遷移するログイン後のページにも元気よく、「ユーザー登録が完了しました」と表示されてしまうのです。

逆に、redireict_toは次のアクションを経由するのでFlash.nowでは表示すらされずに、結果そのメッセージを見ることは一生ないでしょう、、、。

少しややこしかったかもしれませんが、まとめると redirect_toにはflash、renderにはflash.nowを使用する!

add_flash_typesでメッセージの色を変える

最後に補足でadd_flash_typesに触れておきます。 これはBootstrapに定義されているスタイルを読み込むことが可能になります。

実装方法は簡単でrailsにデフォルトで用意されているapplication_contrller.rbに設定を追加するだけです。

class ApplicationController < ActionController::Base
  add_flash_types :success, :info, :warning, :danger
end

これにより、メッセージの種類に合わせてメッセージの色を変えることができます。 (successは成功した感じの柔らかい色、dangerは失敗した時の感じの刺々しい色(笑))

また、後述したように、成功時のフラッシュメッセージを表示するためのredirect_toが1行で記述できるようになります。

redirect_to login_path, flash: 'ユーザー登録が完了しました'

以上、大ちゃんの駆け出し技術ブログでした!

【リクエストスペック①】APIの取得

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

今回はタイトルにもある通り、リクエストスペックについて書きたいと思います。

しかし、リクエストスペックを説明するためにはAPIの説明が必要不可欠となるため、今回はAPI情報の取得方法、次回はAPI情報が取得できているかをテストするリクエストスペックという感じで2回構成で説明していきたいと思います!

他のテストタイプとの違い

「リクエストスペックってなんぞや?」

自分も最初はこんな感じでした。

自分がRSpecを学習する上で1番最初に学習したのはそのモデルをテストするモデルスペックです。主にテストの検証内容はそのモデルのバリデーションが効いているのかをテストすることです。

例: タスクモデルのモデルスペック

# spec/models/task_spec.rb
require 'rails_helper'

RSpec.describe Task, type: :model do
  describe 'validation' do
    it 'is valid with all attributes' do
      task = build(:task)
      expect(task).to be_valid
      expect(task.errors).to be_empty
    end

    it 'is invalid without title' do
      task_without_title = build(:task, title: nil)
      expect(task_without_title).to be_invalid
      expect(task_without_title.errors[:title]).to eq ["can't be blank"]
    end
  end
end

次に学習した内容はシステムスペックでした。主にこちらのテストは自分の期待通りに画面が遷移し対象の要素が画面に表示されているのか、実装した機能が正しく動くかを検証します。圧倒的にこちらのテストを書く機会が多いですね。

例: ログイン機能のシステムスペック

require 'rails_helper'

RSpec.describe 'UserSessions', type: :system do

  let(:user) { create(:user) }

  describe 'before login' do
    before { visit login_path }
    context 'when input values in the form are all valid' do
      before do
        fill_in 'Email', with: user.email
        fill_in 'Password', with: 'password'
        click_button 'Login'
      end
      it 'login successfully' do
        expect(page).to have_content 'Login successful'
        expect(current_path).to eq root_path
      end
    end
  end
end

これら2つのテストは何度も書いたことがありました。しかし、新たに今回リクエストスペックという言葉が出てきました。これは一体何のテストなんでしょうか。

ある記事を参照すると、リクエストスペックはどうやら以下のようなテストがリクエストスペックと言えるそうです。

基本、統合テストはシステムスペックを書きましょう API系のテストがあるなら、別でリクエストスペックを書きましょう

qiita.com

リクエストスペックとはシステムスペックと同じ統合テストにあたるようです。統合テストとは、モデルスペックのように個々に対して対してテストするのではなく、システムスペックのように集合的にテストする形式です。しかし、その以外はAPI系のテストの有無にあります。

APIってなんぞや?」

みたいになるかと思いますが、詳しく説明すると大変ボリューミーになるので、詳しく知りたい方は他記事をあたってみてください。簡単に引用して説明すると下記のようなものがAPIです。

APIを、簡単に説明すると「決まった方法でアクセスをすれば決まった結果を返してくれるもの」です。先のケーススタディでいうと、URLの末尾にzipcode=郵便番号でアクセスすると、住所を決まった形式で返してくれるのです。一般的にはJSONという形式で結果が返ります。郵便番号の例もJSON形式です。どこからでもアクセスできる口を開けておき、決まった形式でアクセスを受け付けて、仕様通りの結果を返すインターフェースとなるのがAPIです。

products.sint.co.jp

よくTwitterAPIだったり、ぐるなびAPIだったりとAPIという単語自体は聞いたことはあるという人も多いのではないでしょうか。APIとはまさにそれらアプリが公開している情報のことであることを示しています。そして、その情報にアクセスすると一般的にはJSONの形式で返すようですね。例えば、下記の情報をぐるなびAPIが公開しているお店の情報に見立てることもできます。いろいろな情報がこちらで調べることなくJSON形式で手に入ります。

https://i.gyazo.com/d1913f65c35122a2d33b463d0bceece2.png

API情報の公開とは

自分が使用しているアプリを例にどのようにAPI情報へアクセスするか試してみましょう。

sorceryのログイン機能に関してアプリ内で行っているのはログインユーザーのAPI情報の公開です。詳しくみてみると下記のような状態です。

# app/controllers/api/v1/authentication_controller.rb
module Api
  module V1
    class AuthenticationController < BaseController
      def create
        @user = login(params[:email], params[:password])
        raise ActiveRecord::RecordNotFound unless @user
        json_string = UserSerializer.new(@user).serialized_json
        render json: json_string
      end
    end
  end
end

何かいつものログイン機能のファイルとは違いますね。。。

いつものsorceryのログイン機能のファイルは以下のような感じかと思います。

def create 
  @user = login(params[:email], params[:password]) 
  if @user 
    redirect_back_or_to root_path 
  else 
    render :new 
  end 
end

共通部分は下記の部分のみです。

@user = login(params[:email], params[:password])

なぜ今回使うファイルがいつものsorceryのログイン機能のファイルとは違うのかというと、今回テストするファイルはログインユーザーのAPI情報を返す仕様になっているからです。ここはかなりややこしいですね。

言い直すのならば、いつもsorceryでログイン機能を実装しているcreateアクションは、アプリ内でログインするための機能です。しかし、今回テストするcreateアクションはログインしたユーザーのデータをJSON形式で外部に公開する機能です。いつものsorceryのcreateアクションとは違い、API情報を公開する機能を持っているということです。

具体的にJSON形式で公開する方法を説明します。

まず、共通部分では同じように登録されているユーザーを取得します。

@user = login(params[:email], params[:password])

もし、ユーザーがいないのであれば例外を発生させます。

raise ActiveRecord::RecordNotFound unless @user

次の箇所は少し歪に見えるかと思います。

json_string = UserSerializer.new(@user).serialized_json

これはfast_jsonapiのgemで生成したUserSerializerを用いて、取得したユーザー情報をJSON形式に変更しています。json_stringの中身を見てみると、user情報がJSON形式で格納されているのがわかります。

5: def create
6:   @user = login(params[:email], params[:password])
7:   raise ActiveRecord::RecordNotFound unless @user
8:   json_string = UserSerializer.new(@user).serialized_json
9: 
10:   binding.pry
11:   
12:   render json: json_string
13: end

[1] pry(#<Api::V1::AuthenticationController>)> json_string
=> "{\"data\":{\"id\":\"1\",\"type\":\"user\",\"attributes\":{\"name\":\"MyString1\",\"email\":\"MyText1\"}}}"

最後にJSONファイルをレンダリングしてます。

render json: json_string

このレンダリングを行うことで、ユーザー情報を外部からのアクセスで表示させることができるのです。

API情報へのアクセス方法

実際にアクセスして指定のユーザー情報を取得してみましょう。

このAPI情報をどのように取得するのかというと、上述したようにJSON形式のデータを取得するためには決まった形式のアクセスが必要です。今回のログインユーザーの情報にアクセスするためには、ルーティングを見るとわかります。

$ rails routes
api_v1_authentication POST   /api/v1/authentication(.:format)         api/v1/authentication#create {:format=>/json/}

Post形式で/api/v1/authenticationにアクセスする必要があるようです。

実際に指定のブラウザ上でも試せますが、Postmanというアプリを使うとAPI情報へのアクセスがわかりやすくなるので、Postmanを使います。

www.postman.com

Post形式で/api/v1/authenticationを指定した画面が下記になります。

https://i.gyazo.com/11cf3d9b106db0da9ff611bfb34d7b6e.png

しかし、このままではアクセスできません。今回はログイン機能ですので、アプリで登録されているユーザーの指定のパラメーター情報を/api/v1/authenticationに加える必要があります。

@user = login(params[:email], params[:password])

Postamanで送る方法はとても簡単で、Query Paramsという項目に登録されてるユーザー情報に合致するパラメータを記載します。取得パラメーターはemailpasswordです。

gyazo.com

するとURIの部分が自動で切り替わりました。

localhost:3000/api/v1/authentication?email=MyText1&password=password

これで指定のパラメーターを送ることが可能になったので、実際に送信ボタン(Send)を押してみましょう。するとJSON形式のデータが取得できたはずです。

{
    "data": {
        "id": "1",
        "type": "user",
        "attributes": {
            "name": "MyString1",
            "email": "MyText1"
        }
    }
}

ちなみにここで間違ったパラメーター渡してみると、ユーザー情報が取得できなかったために、raise ActiveRecord::RecordNotFoundの例外処理が実行され、下記のエラーメッセージのデータがJSON形式で表示されます。エラーハンドリングされているということですね。

{
    "message": "Record Not Found",
    "errors": [
        "ActiveRecord::RecordNotFound"
    ]
}

終わりに

上述したように今回はあくまでAPIの概要や取得方法についての説明であり、本当に説明したのは次回説明するリクエストスペックについてです。今回のようにJSON形式のファイルが表示されているのかをテストする方法について発信していきますのでお楽しみに!

以上、大ちゃんの駆け出し技術ブログでした!

【RSpec②】have_selectorの画像検証

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

RSpecの紹介シリーズ第二弾!今回はhave_selectorの画像検証とあるようにhave_selectorについてです!

応用編が終わり個人的にRSpecを最近書く機会が少なくなってきたので、RSpecについてはなるべく多くアウトプットしたいと思います!!

have_selector超基礎

1つ目のRSpechave_selectorです。have_selectorは使用頻度がとても高いRSpecのマッチャだと思います。その理由はhave_selector特定のタグやCSS要素に特定の文字列が表示されていることを検証するといった、かなり使い勝手のよいマッチャだからでしょう。

例えば、タスク一覧画面にはh1タグで文字列"タスク一覧"が表示されています。

https://i.gyazo.com/5eda388a39a182f50a649cd922a32ee4.png

文字列"タスク一覧"が表示されていることを検証する場合、have_contentを使用すれば簡単に検証できることはわかると思います。

expect(page).to have_content 'タスク一覧'

しかし、ここで「h1タグでテキストが"タスク一覧"」である要素が画面にあることを検証する場合、have_contentでは検証できません。

そこでhave_selectorの出番です。

expect(page).to have_selector 'h1', text: 'タスク一覧'

これにより「h1タグでテキストが"タスク一覧"」条件を満たす要素があるか検証を行ってくれます。

画像検証の方法

上述したのはhave_selectorの基本的な使い方です。このhave_selectorで紹介したい記法は下記のような記法です。

expect(page).to have_selector("img[src$='画像ファイル名']")

これはどのような検証を行っているのかというと、対象の画像ファイルが表示されていることを検証してくれます。少し奇妙な書き方をしていますね。

例えば、下記のような画像が表示されている時に使用するとします。

https://i.gyazo.com/3bdd5eb2fb9ca8bee8145d389c48cfb8.png

ここで ディベロッパーツールを使うと表示されている画像の部分(img要素)は以下のようになります。見やすいようにわざと改行しています。

<img class="swiper-slide swiper-slide-next" 
         src="/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBHZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--b7f974b228479bd43c7aaba23cd913d9e88c77e2/haikyu.jpg" 
         role="group" 
         aria-label="3 / 3"
         style="width: 300px; transform: rotateX(0deg) rotateY(180deg) translate3d(300px, 0px, 300px); transition-duration: 0ms;">

ここである画像が表示されていることを検証する場合、その画像を表す部分はsrc属性からわかると思います。しかし、src属性は画面上ではかなり複雑なハッシュにより表されています。

src="/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBHZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--b7f974b228479bd43c7aaba23cd913d9e88c77e2/haikyu.jpg"

このsrc属性を正確に検証しようとすることはかなり手間だと思いませんか。実際にhave_selectorを使って表現すると以下のようになります。

expect(page).to have_selector("img[src$='/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBHZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--b7f974b228479bd43c7aaba23cd913d9e88c77e2/haikyu.jpg']")

長すぎますよね笑

そこで$=を記載することでこの長いハッシュを書かずに済むようにしています。

expect(page).to have_selector("img[src$='画像ファイル名']")

先ほどのsrc属性の後ろ側をみると、haikyu.jpgとあるように画像の名前はハッシュ化されずそのままです。あくまでもハッシュとして表現されているのはディレクトリの部分なのですね。なので、その長いハッシュ化されたディレクトリを無視できれば、画像ファイルが表示されているかを検証できるのです。そしてその方法が$='画像ファイル名'と書くことによって可能になります。

expect(page).to have_selector("img[src$='haikyu.jpg']")

終わりに

src$=の記法を使えば画像ファイルの検証はとても簡単に行うことができますね!ハッシュ化されたディレクトリをそのまま書かずに済むのでありがたい!

以上、大ちゃんの駆け出し技術ブログでした!

【RSpec】supportディレクトリ

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

supportディレクトと聞かれてRSpecについてのこととピンとくる方はいらっしゃいますか?supportディレクトリを一言で表すと、「RSpecの設定だったりmoduleファイルを一箇所にまとめておくための場所」です。

テストは機能、モデルの数が増えれば増えるほど数が増え複雑になります。そのため、RSpecのテスト方法をテストによって分けたり、どのテストでも必要となる機能(ログイン)などが増えます。それらをsupportディレクトリ配下に管理する取り決めをしておくことで、設定やモジュールファイルを探す手間がすごく省けるのです。実際に試してみましょう!

なお、今回使用するアプリケーションは、毎度お馴染みの現場Railsのchapter7まで終えている状態のアプリケーション「taskleaf」です。同じ状態でハンズオンで試したいという方は現場Railsのchapter7までを終わらせてから取り組んでください。ただし、今回はchapter5まででも問題ないはずです!

実装の概要

上述しましたように、supportディレクトリを作る理由はテストを行う上で必要である設定やmoduleをまとめるためにあります。

都合の良いことに、現在のtaskleafアプリケーションではテスト形式であるcapybaraの設定がspec_helper.rb内に書かれています。

# spec/spec_helper.rb
require 'capybara/rspec'
RSpec.configure do |config|
  config.before(:each, tipe: :system) do
    driven_by :selenium_chrome_headless
  end
・
・
・
・

spec_helper.rb内は現在ちょうど100行もあるファイルです。しかも、設定を追加するために追記することもあると思います。そうなった時、capybaraの設定を変更する時に上記のcapybaraの設定コードを変更するために探すのは少し手間かと思います。例えば、現在はオプションで:selenium_chrome_headlessを設定していますが、これを変更するとなると100行もあるファイルの中からcapybaraの設定を見つけなければならないので手間ですね。

このcapybaraのようなテストの設定をsupportディレクトリ配下に移しておくことで、後々変更するとなった時に、supportディレクトリ配下を探すということがわかっているので、手間はだいぶ省けることになります。

実装方法

実装方法自体はさほど難しくありません。

まず、supportディレクトリを手動で作成し、その配下にcapybara.rb(ファイル名はrubyファイルであれば任意)を手動で作成します。

# spec/support/capybara.rb

手動で作成したので何もない状態です。

ここに下記内容を記載します。RSpec.configureはspec_helper.rbファイルにも記載されているRSpecの設定を記述する箇所です。

# spec/support/capybara.rb
RSpec.configure do |config|

end

ここにspec_helper.rbに記載されているcapybaraの設定をcapybara.rbに移します。spec_helper.rb内のcapybaraの設定箇所は削除しておきましょう。

# spec/support/capybara.rb
RSpec.configure do |config|
  config.before(:each, tipe: :system) do
    driven_by :selenium_chrome_headless
  end
end

これで実装は完了ではありません。

spec/rails_helper.rbコメントアウトで記載されている下記部分のコメントアウトを外します。

# spec/rails_helper.rb
・
・
# require only the support files necessary.
#
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }
・
・

コメントアウトすることでsupportディレクトリ配下にあるRSpecの設定ファイルが読み込めるようになりました。これで実装は完了です。

試しに新しく作成したcapybara.rbの設定を変更しましょう。:selenium_chrome_headless:selenium_chromeと変更してください。

# spec/support/capybara.rb
RSpec.configure do |config|
  config.before(:each, tipe: :system) do
    driven_by :selenium_chrome
  end
end

これで実際にテストを実行すると、chromeが開いた状態でテストが実行されるかと思います。supportディレクトリ配下の設定が適用されている証拠です!

終わりに

現場Railsでは学習しない内容ですが、RUNTEQで使用するアプリではこの設定は必ずされていました。自分で今回の実装をすることは少ないかと思いますが、実装方法を把握しておくだけで、設定ファイルの所在などのspecディレクトリは配下で記述を探す手間がかなり省けるかと思います!

以上、大ちゃんの駆け出し技術ブログでした!

【バリデーション】acceptance

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

以前、バリデーションヘルパーであるnumericalityについて記事を書きました。

sakitadaiki.hatenablog.com

このバリデーションはRUNTEQのカリキュラムの応用編で使用したのですが、実際調べればすぐに導入できたバリデーションです。ですので、応用編カリキュラムを修了していたのでそれなりの知識があるだろうというおごりがあり、バリデーション自体は割と調べればすぐに出てきて導入できるのでわざわざ記事にする必要がないんじゃないの?と思っていました。

しかし、先日numericalityについて記事を書いているときにRailsガイドを参考にしていた時のこと。

「あれ?このバリデーション見たことない!」

とか

「このバリデーションそもそも知らない、、、」

という感じで、まあ、 バリデーションへの知識不足が判明してしまったというわけですwww

しかも、ネットにある記事が意外にもバリデーションの記載方法のみを紹介しており、実際にバリデーションした時にどのようなエラーメッセージが出るかだとか、正常に動いていたテストが失敗するようになるのだとかが紹介されている記事はほとんど見受けられませんでした。

これから0→1でPFを作っていくわけですから、バリデーションは当然自分で設定していきます。複雑なモデルを設定する可能性もあるので、バリデーションを使うタイミングはそれ相応にあるでしょう。こういった知識は毎回調べて導入していたらすごい手間ですから、PFの作成段階に入る前になるべく先に勉強しておいた方がいいですね。

前置きが長くなりましたが、これからの記事ではRUNTEQのカリキュラム内で自分で使用することのなかったバリデーションを他の記事に比べてかなり詳しく紹介していこうと思います。あとは記事数稼ぎです!(笑)

今回もいつもと同様に現場Railsのchapter7まで終えている状態のアプリケーション「taskleaf」です。同じ状態でハンズオンで試したいという方は現場Railsのchapter7までを終わらせてから取り組んでください。

acceptanceの概要

Railsガイドでは以下のように説明しています。

このメソッドは、フォームが送信されたときにユーザーインターフェイス上のチェックボックスがオンになっているかどうかを検証します。ユーザーによるサービス利用条項への同意が必要な場合や、ユーザーが何らかの文書に目を通したことを確認させる場合によく使われます。 (引用: Active Record バリデーション - Railsガイド)

Railsガイドは初心者が読むと何を言っているのかよくわからないことが多いと個人的には思っているのですが、この説明はめちゃくちゃわかりやすいと思っています!特に以下の部分!

ユーザーによるサービス利用条項への同意が必要な場合

よくWebサイトで利用規約へのチェックボックスを付けるのを忘れて次の画面に遷移しようとすると、次の画面に遷移することなくエラーメッセージが表示されると思います。


(画像を差し込む!!)

この利用規約に同意させてからアプリを使用するようにする設計は、小さな個人開発サービスを除けば、必ずと言っていいほど使用するかと思います!利用規約に同意させることでトラブルがあった際にアプリ開発側への責任を担保できますからね。

おそらくPF開発するときは使用しないと思っていますが(PFは企業の人が見るので利用規約への同意フローは時間を浪費させる無駄な機能なため)、将来的には個人でサービスも開発したいと思っているので、この際試して将来の自分に使い方を教えるために記事にしておきたいと思います。(あと、ユーザーが何らかの文書に目を通したことを確認させるといったフローはPFに組み込まないとは断言できないので)

acceptanceの使用例

それでは実際に使用してみましょう。

まずチェックボックスを付けるためのカラムを用意しておきましょう!!

と思ったのですが現場Railsのアプリでは既にチェックボックスの付いたカラムが存在するではありませんか!なんと素晴らしい!

https://i.gyazo.com/15242f5e5dd90c3611001fbc45ddfb52.png

表示しているページはユーザー編集ページです。

https://i.gyazo.com/4d5500b1563a8ae82184775772987343.png

表示しているビュー

.form-check
  = f.label :admin, class: 'form_check_label' do
    = f.check_box :admin, class: 'form-check-input'
    | 管理者権限

これを利用してこのカラムにacceptance: trueをつけてみましょう!

# app/models/user.rb
validates :admin, acceptance: true

あとやることは、、、、、、

以上です!!!!!!!wwwwww

これで実装できてます笑こんな実装をすることはありませんが、、

ユーザーが必ず管理者権限を付与しないとユーザーが更新されないので全ユーザー管理者権限のトンデモ状態になってしまいます笑

実際に試してみましょう。チェックをしないでユーザーを作成しようとすると、

https://i.gyazo.com/d5a027168f38b9c9de20c7af20a76710.gif

"Admin must be accepted"と表示されています。これによって、チェックなしではユーザー登録が完了しない奇妙な仕様となってしまいました。

ちなみに、Admin must be acceptedのエラーメッセージはバリデーションの設定で容易に変えられます。

# app/models/user.rb
validates :admin, acceptance: { message: 'でないとユーザーは登録できません' }

表示されるエラーメッセージが変わることを確認してみてください。

https://i.gyazo.com/eba407092b45a2eccc53c37c6e649523.png

以上でacceptanceの紹介を終わります!

ほんとうに簡単な実装で終わってしまいましたね💦

終わりに

本記事で紹介したバリデーションは知っていて損はないと思います。バリデーションは調べればすぐにどのようなバリデーションなのかをすぐに把握することができますが、理解が難しくないことを理解しないまま放置しておくのはあまり賢明ではないですね。今回実装した内容をもとに自分も0→1でPFを作る際は自分の手で実装できるようになった気がします!といっても、念のため確認するためにどうせRailsガイドは開くとは思うんですけどね(笑)

以上、大ちゃんの駆け出し技術ブログでした!

【RSpec】within

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

本日はかなりの短いアウトプットとなります。ど平日で仕事終わりに書いておりますが、少し体調が悪く疲れているのです!!!(言い訳ですが、、、)

本日はRUNTEQのカリキュラムの中で、これまでに学んだRSpecのマッチャやテスト方法を紹介します!というか、これから出すいくつかの記事でRSpecで使えると思ったものを紹介しようかなと思ってます。ただ、visitfill_inなどはよく使われており調べれば簡単に出てくるため、本記事で紹介はしません。これからのRSpecの記事で紹介するものは1つのテストを書いているときに使うタイミングもあれば使わないタイミングもあると思うようなものに限定します。

今回使うアプリケーションは、現場Railsのchapter7まで終えている状態のアプリケーション「taskleaf」です。同じ状態でハンズオンで試したいという方は現場Railsのchapter7までを終わらせてから取り組んでください。

使うタイミング

第1回目は記事のタイトルにあるとおりwithin!これはかなり使えるなと思いました。特に、画面にたくさんのボタンやリンクが表示されている画面でテストをする際はおそらく必須になると思います。つまり、シンプルな設計がなされているページでは使う必要がありません。

どういうタイミングで使えるのかというと、本アプリのタスク一覧画面のような設計で使えます。

https://i.gyazo.com/6c57d45629104770c080f10f7bfa412e.png

ん?すごくシンプルな画面じゃん。

そうですね。チュートリアルの画面として最適のような画面です。ではこのタスク一覧画面の中で「指定のタスクを削除する」というスペックを書きましょう。タスク一覧画面のテストなので、既に作成されているspec/system/tasks_spec.rbを使用しましょう!

# spec/system/tasks_spec.rb
describe "タスク管理機能", type: :system do
  let(:user_a) { FactoryBot.create(:user, name: 'ユーザーA', email: 'a@example.com') }
  let(:user_b) { FactoryBot.create(:user, name: 'ユーザーB', email: 'b@example.com') }
  let!(:task_a) { FactoryBot.create(:task, name: '最初のタスク', user: user_a) }
  before do
    # 作成者がユーザーAであるタスクを作成しておく
    FactoryBot.create(:task, name: '最初のタスク', user: user_a)
    # 共通ログイン
    visit login_path
    fill_in 'メールアドレス', with: login_user.email
    fill_in 'session[password]',   with: login_user.password
    click_button 'ログインする'
  end

  describe 'タスク削除機能' do
    let(:login_user) { user_a }
    context '削除ボタンを押したとき' do
      binding.irb
      before { click_on '削除' }
      it 'タスクが削除される' do
        expect(current_path).to eq(root_path)
                expect(Task.all.count).to eq(1)
      end
    end
  end
・
・
・
・
end

タスク削除機能のテストではuser_aとしてログインしているため、ログイン後の画面であるタスク一覧が表示されています。

ログイン時画面

f:id:SakitaDaiki:20210218185506p:plain

削除ボタンが見えますね。ですのでタスクを1つ削除するとタスクの数は一つとなるのでexpect(Task.all.count).to eq(1)がパスするという想定です。ですが、、、

ターミナル

$ bundle exec rspec spec/system/tasks_spec.rb
2021-02-18 18:12:42 WARN Selenium [DEPRECATION] Selenium::WebDriver::Chrome#driver_path= is deprecated. Use Selenium::WebDriver::Chrome::Service#driver_path= instead.
Capybara starting Puma...
* Version 3.12.6 , codename: Llamas in Pajamas
* Min threads: 0, max threads: 4
* Listening on tcp://127.0.0.1:61796
F

Failures:

  1) タスク管理機能 タスク削除機能 削除ボタンを押したとき タスクが削除される
     Failure/Error: before { click_on '削除' }
     
     Capybara::Ambiguous:
       Ambiguous match, found 2 elements matching visible link or button "削除"
     
     [Screenshot]: tmp/screenshots/failures_r_spec_example_groups_nested_nested_nested_タスクが削除される_422.png

     
     # ./spec/system/tasks_spec.rb:20:in `block (4 levels) in <top (required)>'

Finished in 5.21 seconds (files took 2.19 seconds to load)
1 example, 1 failure

テストが失敗していますね。何故でしょうか。

その理由は同じ文字のボタンが複数個表示されているからです。

ログインすると2つのタスクが作成されていることは上述しました。しかし、そのタスク削除ボタンの文字はお互いに「削除」です。エラーメッセージを見てみると、

Ambiguous match, found 2 elements matching visible link or button "削除"

Ambiguousとあまり見ない英単語ですね。意味は「曖昧な」。つまり、Ambiguous matchとは「曖昧な一致状態」というエラーになります。そこから表示されているエラーメッセージは、「表示されているリンクまたはボタンに文字列”削除”と一致する2つの要素が見つかりました」。

この文から分かるとおり、「"削除"という文字のボタンが2つあるので、どっちのボタンを指定しているのかが曖昧です」とエラーメッセージを返しているわけです。

Withinの使用方法

では上記エラーを解消しましょう。解消する方法はわりとシンプルです。

before do
  within '#task-1' do
    click_on '削除'
  end
end

各タスクのtrには固有のidが設定されています。

https://i.gyazo.com/f66bb0f60b18088487a2f0f295f8aee7.png

つまり、そのタスクのidを指定すればその中の削除ボタンを「idが〜の削除ボタン」と固有のものにできます。within '#task-1'で指定している#task-1の箇所はCSSを指定する時と同じように、idまたはクラスを指定することができます。そして、指定した要素の内部にある要素のみにRSpecのマッチャの対象を指定することができます。

これにより2つの削除ボタンを差別化することができ、テストをパスすることができるというわけです!

終わりに

かなり短めで申し訳ないです!もっと時間を取れるときにたくさん抹茶を紹介できればと思いますので多めに見てくれるとありがたいです!

以上、大ちゃんの駆け出し技術ブログでした!

【バリデーション】numericality

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

本記事ではnumericalityについて説明します。

これは数値に対して使われるバリデーションです。例えば、整数しか入力できないとか、〇〇以上〇〇未満しか入力できない、といった制約をつけることができます。

特に難しいということはないので、早速やってみましょう。

また、今回は数値型のバリデーションがしっかりと反映されているかを確認するために、RSpecを使用しモデルスペックを作成します。

テキトーにカラムを追加

アプリケーション自体今回は関係ないのでテキトーなモデルに対し、数値型カラムを追加してくださいww

カラムの追加の方法は下記のとおりです。

 $ rails g migration Addカラム名Toテーブル名 カラム名:型

私は既存のUserモデルに対して、test_numericalityカラムをinteger型で追加します。

$ rails g migration AddTestNumericalityToUsers test_numericality:integer
      invoke  active_record
      create    db/migrate/20210217081751_add_test_numericality_to_users.rb

マイグレーションファイルを念のため確認。

class AddTestNumericalityToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :test_numericality, :integer
  end
end

問題なさそうですね。DBレベルに反映しましょう。

$ rails db:migrate
== 20210217081751 AddTestNumericalityToUsers: migrating =======================
-- add_column(:users, :test_numericality, :integer)
   -> 0.0024s
== 20210217081751 AddTestNumericalityToUsers: migrated (0.0026s) ==============

モデルスペックの作成

モデルスペックを作成します。Rspecはインストール済みであるという認識で進めていきます。

モデルスペックファイルの作成

$ rails generate rspec:model user
      create  spec/models/user_spec.rb
      invoke  factory_bot
      create    spec/factories/users.rb

FactoryBotにはtest_numericalityにとりあえず10を入れておきましょう。なお、nameカラムに関しては自分のUserモデルにあるカラムなので注意してくださいね!

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    name { 'test_name' }
    test_numericality { 10 }
  end
end

あと、FactoryBot作成時に省略記法を使用したいので、rails_helper.rbに下記内容を記載します。

# spec/rails_helper.rb
config.include FactoryBot::Syntax::Methods

モデルスペックファイルに下記内容を記載

# spec/models/user_spec.rb
RSpec.describe User, type: :model do
  describe "バリデーションのテスト" do
    it '全てのカラムが正常値の時、Userモデルはvalidである' do
      user = build(:user)
      expect(user).to be_valid
      expect(user.errors).to be_empty
    end
  end
end

これでテストを実行すると当然通ります。バリデーションがないためです。

$ bundle exec rspec spec/models/user_spec.rb
.

Finished in 0.02555 seconds (files took 1.75 seconds to load)
1 example, 0 failures

numericalityを試す

大変お待たせいたしました笑。

指定のモデルに対してnumericalityを使用してバリデーションをかけてみましょう。

numericalityは基本的に以下のように記述します。

validates :カラム名, numericality: { オプション: オプションに対応した値 }

では最初に簡単なものから紹介します。

only_integer

これは値を整数のみ入力できるようにするバリデーションです。

# app/models/user.rb
validates :test_numericality, numericality: { only_integer: true }

現状はFactoryBotにデフォルトで10が格納されているためテストは通るはずです。この値を小数点ありの10.5にしてみましょう。

RSpec.describe User, type: :model do
  describe "バリデーションのテスト" do
    it '全てのカラムが正常値の時、Userモデルはvalidである' do
      user = build(:user, test_numericality: 10.5)
      expect(user).to be_valid
      expect(user.errors).to be_empty
    end
  end
end

これでテストを実行すると、

$ bundle exec rspec spec/models/user_spec.rb
F

Failures:

  1) User バリデーションのテスト 全てのカラムが正常値の時、Userモデルはvalidである
     Failure/Error: expect(user).to be_valid
       expected #<User id: nil, name: "test_name", created_at: nil, updated_at: nil, test_numericality: 10> to be valid
             , but got errors: Test numericality must be an integer
     # ./spec/models/user_spec.rb:8:in `block (3 levels) in <top (required)>'

Finished in 0.03144 seconds (files took 1.03 seconds to load)
1 example, 1 failure

エラー内容の一部に、「Test numericality must be an integer」とあります。

user.errors[:test_numericality]
=> ["must be an integer"]

次のオプションに移る前に作成するFactoryBotを元の状態に戻しておいてください。

RSpec.describe User, type: :model do
  describe "バリデーションのテスト" do
    it '全てのカラムが正常値の時、Userモデルはvalidである' do
      user = build(:user)
      expect(user).to be_invalid
      # binding.irb
      expect(user.errors).to be_empty
    end
  end
end

greater_than、greater_than_or_equal_to

次は「〜より大きい」、「〜より以上」を表すバリデーションです。「〜より大きい」はgreater_thanです。さきほどのオプションであるonly_integerではboolean型であるtrueを入れましたが、今回は数値型を入れます。数値は10を入れてみましょう。

# app/models/user.rb
validates :test_numericality, numericality: { greater_than: 10 }

これでテストを実行すると失敗します。

$ bundle exec rspec spec/models/user_spec.rb
F

Failures:

  1) User バリデーションのテスト 全てのカラムが正常値の時、Userモデルはvalidである
     Failure/Error: expect(user).to be_valid
       expected #<User id: nil, name: "test_name", created_at: nil, updated_at: nil, test_numericality: 10> to be valid
             , but got errors: Test numericality must be greater than 10
     # ./spec/models/user_spec.rb:7:in `block (3 levels) in <top (required)>'

Finished in 0.02056 seconds (files took 0.95972 seconds to load)
1 example, 1 failure

エラー内部には以下の文字列がありますね。

"Test numericality must be greater than 10"

「10より大きい数」と制約しているので、10は含まれないということですね。10を含ませるためには「10以上の数」と制約をかけます。greater_than_or_equal_toを使用すると「〜以上の数」という制約をかけることができます。

# app/models/user.rb
validates :test_numericality, numericality: { greater_than_or_equal_to: 10 }

これでテストが通ります。

$ bundle exec rspec spec/models/user_spec.rb
.

Finished in 0.02247 seconds (files took 1.64 seconds to load)
1 example, 0 failures

「〜より大きい」、「〜より以上」の反対として「〜より小さい」、「〜より以下」もあります。less_thanless_than_or_equal_toです。この2つについては皆さんの手で試してみてください。

equal_to, other_than

指定した値以外が格納されるとエラーが出るようにしたい場合はequal_toです。

validates :test_numericality, numericality: { equal_to: 100 }

これでは現在のテストは当然通りません。

$ bundle exec rspec spec/models/user_spec.rb
F

Failures:

  1) User バリデーションのテスト 全てのカラムが正常値の時、Userモデルはvalidである
     Failure/Error: expect(user).to be_valid
       expected #<User id: nil, name: "test_name", created_at: nil, updated_at: nil, test_numericality: 10> to be valid, 
             but got errors: Test numericality must be equal to 100
     # ./spec/models/user_spec.rb:7:in `block (3 levels) in <top (required)>'

Finished in 0.05692 seconds (files took 1.78 seconds to load)
1 example, 1 failure

Test numericality must be equal to 100」とあるように値が必ず100でなければなりません。

反対に指定の数値だけ絶対に格納されてはいけないという制約もあります。other_thanを使います。

validates :test_numericality, numericality: { other_than: 100 }

テストが通りました。

$ bundle exec rspec spec/models/user_spec.rb
.

Finished in 0.02088 seconds (files took 1.72 seconds to load)
1 example, 0 failures

odd、even

oddは奇数、evenは偶数を制約します。only_integerと同様にboolean型で指定します。

oddtrueとしておけば、

validates :test_numericality, numericality: { odd: true }

テストは通りませんが、

$ bundle exec rspec spec/models/user_spec.rb
F

Failures:

  1) User バリデーションのテスト 全てのカラムが正常値の時、Userモデルはvalidである
     Failure/Error: expect(user).to be_valid
       expected #<User id: nil, name: "test_name", created_at: nil, updated_at: nil, test_numericality: 10> to be valid, but got errors: Test numericality must be odd
     # ./spec/models/user_spec.rb:7:in `block (3 levels) in <top (required)>'

Finished in 0.04629 seconds (files took 1.79 seconds to load)
1 example, 1 failure

evenをtrueにすると、

validates :test_numericality, numericality: { even: true }

テストはパスしました。

$ bundle exec rspec spec/models/user_spec.rb
.

Finished in 0.02802 seconds (files took 1.8 seconds to load)
1 example, 0 failure

終わりに

本記事では雑にカラムを追加しているので具体的な用途がわからない方もいるのかもしれませんが、数値の制約をすることで、入れたい値、入れたくない値を指定できるので安心できます。

格納されるべき値を簡単に制限できるので、使うタイミングがあればすぐに導入できるので本当に便利で使い勝手が良いと思います。

以上、大ちゃんの駆け出し技術ブログでした!

N + 1問題

はじめに

Ruby on RailsなどDB(データベース)を使用するサーバーサイド言語で必ず上がる「N + 1問題」。

主に1対多のアソシエーション関係がある時に起こる問題です。 Railsでは一覧表示機能を含む1対多の関係が不可欠な機能が実装されるので、N + 1問題は理解しておくべきかと思います。

対象者

rails tutorialを学習済みの方 SQL初学者の方

使用環境

ruby 2.6.4 ・Rails 5.2.3 ・MySQL 5.4

N+1問題とは

一言で表すと、

「テーブル参照のSQLが大量に発行されてしまうこと」

実際に見ていきましょう。 今回は掲示板投稿機能を実装するためのrailsのファイルを使用します。

まず、N + 1問題を引き起こす投稿機能モデルのコントローラーとビューのファイルの中身です。

app/controllers/boards_controller.rb(コントローラー)

class BoardsController < ApplicationController

  def index
    @boards = Board.all
  end
end

app/views/boards/index.html.erb(ビュー)

  <div class="row">
    <div class="col-12">
      <div class="row">
        <% if @boards.present? %>
        <%= render @boards %>
        <% else %>
        <p><%= t('.no_board') %></p>
        <% end %>
      </div>
    </div>
  </div>

/render @boards/ これによってrailsはファイルの同じディレクトリにある_board.html.erbファイルをパーシャルとして読み込んでくれる仕様になっています。 (よしなにやってくれるrailsの特徴ですね)

同時にコントローラーで取得した@boardsを繰り返し処理で1つずつboardとしてパーシャルに渡します。 (長いですが、一言で言うと掲示板の投稿が1つずつ作成されているコードです)

app/views/boards/_board.html.erb(ビュー)

<div class="col-sm-12 col-lg-4 mb-3">
  <div id="board-id-<%= board.id %>">
    <div class="card">
      <%= image_tag 'board_placeholder.png', class: 'card-img-top', width: 300, height: 200 %>
      <div class="card-body">
        <h4 class="card-title">
          <%= link_to  board.title, "#" %>
        </h4>
        <div class='mr10 float-right'>
          <%= link_to '#', id: 'button-edit-#{board.id}' do %>
          <%= icon 'fa', 'pen' %>
          <% end %>
          <%= link_to '#', id: 'button-delete-#{board.id}', method: :delete, data: {confirm: ''} do %>
          <%= icon 'fas', 'trash' %>
          <% end %>
        </div>
        <ul class="list-inline">
          <li class="list-inline-item"><i class="far fa-user"></i>
            <%= board.user.decorate.full_name %>
          </li>
          <li class="list-inline-item"><i class="far fa-calendar"></i>
            <%= l board.created_at, format: :short %>
          </li>
        </ul>
        <p class="card-text">
          <%= board.body %>
        </p>
      </div>
    </div>
  </div>
</div>

この掲示板一覧を表示するタイミングで発行されるSQL文が以下になります。

 Rendering boards/index.html.erb within layouts/application
  Board Load (1.6ms)  SELECT "boards".* FROM "boards"
  ↳ app/views/boards/index.html.erb:16
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]

めちゃくちゃ長い、、、 これrails tutorialではマイクロポストで投稿一覧を表示しますが、同じように長いSQL文が発行されます。

なぜこのようなことが起きるのか? 諸悪の根源はたった一文です

@boards = Board.all

ん?この部分はrails tutorialで習った通りじゃないですか?

何が問題かというと、@boardsに格納されている投稿(board)が呼び出される度に、そのboardがどのuserのものであるかを検索してい状態です。

つまり、投稿画面に遷移する時に、掲示板の全ての投稿を取得するために

Boardテーブル全体を参照するSQL文が1回発行される

Board Load (1.6ms)  SELECT "boards".* FROM "boards"

そして、のUsersテーブルを参照するSQLの文章が

掲示板の投稿の数(N回)だけ発行されている状態

  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]

この投稿数の数(N) + 最初の全体参照(1)が"N + 1問題"です。

解決方法

しかし、このN + 1問題は問題の文を以下のように書き換えるだけで簡単に解決できてしまいます。

@boards = Board.all.includes(:user)

includesメソッドによりBoardテーブル参照時に、(boardの外部参照のためのuser_idカラムを元に)Userテーブルも同時に参照するようにしています。 よってテーブル参照回数も2回となります!! (たとえ掲示板の投稿が増えたとしても)

  Board Load (3.0ms)  SELECT "boards".* FROM "boards" ORDER BY "boards"."created_at" DESC
  ↳ app/views/boards/index.html.erb:16
  User Load (0.9ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?)  [["id", 2], ["id", 1], ["id", 3]]
  ↳ app/views/boards/index.html.erb:16

SQL文の発行も2回で済みます。 1対多のアソシエーション関係を持つモデルを扱う場合は是非覚えておきましょう!