kikeda1104's blog

備忘録・技術に関することを書いています。(webエンジニア)

Rspec callbackのテスト(rails)

callback(before_actionなど)で利用するメソッドのテスト方法を書いていきます。

Controller

class DashBoardController < ApplicationController
  before_action :set_user

  def index
    # ...
  end

  private
  def set_user
    @user = User.where(id: session[:user_id])
  end
end

spec

今回は指定しませんが、infer_base_class_for_anonymous_controllers = falseにすると Anonymous(匿名)クラスのデフォルト親クラスが、ApplicationControllerになります。

rspec

DashBoardController#indexのコードを上書きして、callbackにより呼ばれるメソッドの処理のみに集中できるように変えます。

RSpec.describe DashBoardController, type: :controller do
  describe 'GET #index' do
     # ...    
  end

  describe '#set_user' do
     controller do
       def index
         render text: 'success'
       end
     end  
     
     it '@userにユーザ情報がある時' do
       get :index
       expect(assigns[:user].present?).to be_truthy
     end

     it '@userにユーザ情報がない時' do
       get :index, session: { user_id: nil }
       expect(assigns[:user].blank?).to be_truthy
     end
  end
end

anonymous controller - Controller specs - RSpec Rails - RSpec - Relish

認証機能のユニットテスト(controller)

今日は、自作のアプリでユニットテストを作っていたので、このあたりを書いていきたいと思います。

自作アプリでは認証のgemは使っていないんですが、記事のためにsorceryを使って書いていきます。

  • gemの導入手順は省いています。

準備

# Gemfile
gem 'sorcery'

group :development, :test do
  ...
  gem 'rspec-rails'
  gem 'factory_girl_rails'
end

group :test do
  gem 'capybara'
  gem 'database-clearer'
  gem 'rails-controller-testing' # render_templateを利用するため
end

controller

# admin/sessions_controller.rb

class Admin::SessionsController < Admin::BaseController
  def new
  end

  def create
     user = login(
                  user_params[:email],
                  user_params[:password],
                  user_params[:password_confirmation]
                 )
     if user
       redirect_to dashboard_path, notice: 'ログインに成功しました'
     else
       redirect_to root_path, alert: '認証に失敗しました'
     end
  end

  ...

  def user_params
    params.require(:user).permit(User.column_names)
  end
  private :user_params
end
# admin/dashboard_controller.rb

class DashBoardController < Admin::BaseController
  before_action :require_login
  
  def index
  end
end

models

 # app/models/user.rb
class User < ApplicationRecord
end

spec

# spec/rails_helper.rb

Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } # コメントアウトから戻す

Rspec.configure do |config|
  ...
  # sorceryのwikiより
  config.include Sorcery::TestHelpers::Rails::Controller, type: :controller
  config.include Sorcery::TestHelpers::Rails::Integration, type: :feature

  config.include Capybara::DSL
  # FactoryGirlの記載を省くために、追加
  config.include FactoryGirl::Syntax::Methods
  ...
end

spec/factories/user.rb

FactoryGirl.define do
  factory :user do
    email 'gankai1104@gmail.com`
    password 'secret'
    password_confirmation 'secret'
    ...
  end
end

spec/controllers/admin/sessions_controller_spec.rb

require 'rails_helper'
Rspec.describe Admin::SesssionsController, type: :controller do
  describe 'GET #new' do
    it 'ログイン画面へのアクセス時' do
     get :new
     expect(:response).to render_template :new
    end
  end

  describe 'POST #create' do
    it 'ログイン成功時' do
      create(:user)
      post :create, { user: attributes_for(:user) } }
      expect(response).to redirect_to dashboard_path
    end
  
    it 'ログイン失敗時' do
      post :create,  { user: attributes_for(:user) } }
      expect(:response).to render_template :new
    end
  end

  ...
end

spec/controllers/admin/dashboard_controller.rb

RSpec.describe Admin::DashboardController, type: :controller do
  describe 'GET #index' do
    it 'ログイン後の時' do
      login_user(create(:user))
      get :index
      expect(response).to render_template :new
    end

    it 'ログイン前の時' do
      get :index
      expect(response).to redirect_to login_path    
    end
  end
end

ここまでです。 FactoryGirlのtrait, association, Rspecanonymous controllerを利用したので、次はそれを書きます。

参考

Testing Rails · NoamB/sorcery Wiki · GitHub

Cucumber + CapybaraでUserAgentを設定してテストを行う - tech-kazuhisa's blog

whereの指定方法

ActiveRecordを利用していて、どうやってwhereを書くのかわからないことがありますし

良く聞かれたりもしますので、書き方について、まとめておきます。

where

ActiveRecordの条件を記述する際に利用します。

model

# app/models/user.rb

class User < ApplicationRecord
end

Userを利用して書いていきます。

単一modelの条件の書き方

$ rails c 

admins = User.where(admin: true, name: 'hoge')
# admins = User.where("admin = 1 and name = 'hoge'") こちらも同じです。
p admins

複数modelの条件の書き方

modelを書き換えます。

class User < ApplicationRecord
  belongs_to :role
end

class Role < ApplicationRecord
  has_many :users
end
$ rails c

admins = User.joins(:role).where(role: { name: 'admin' })
p admins

ヒアドキュメントを利用する。

# admins = User.where("admin = 1 and name = 'hoge'")
admins = User.where(<<-SQL)
           admin = 1
           and name = 'hoge'
         SQL

参考

Active Record クエリインターフェイス | Rails ガイド

以上になります。

サービス層の導入

業務で、サービスクラスをmodelsに入れていたので、そのファイルの移動と自作でサービス層を追加しました。

serviceクラス

Modelに書くにはコードの量が多い機能で、Fat modelになる恐れがあり、concernに移す事も考えましたが、共通で利用することもないため サービス層にロジックを移すことにしました。

ファイルの設置

app下に、ファイルを作ります。

app/services/select_info_service.rb

class SelectInfoService
  def initialize(info)
    @title = info[:title]
    @body = info[:body]
  end

  def search
    ...
  end

  private
  attr_accessor :title、:body
end

autoload_pathに追加

  # config/application.rb
  config_autoload_path += "#{config.root}/app/services/**/*.rb"

Rails 5では、config/initialize/..にファイルを作成すること推奨しています。

./bin/rails c

si = SelectInfoService.new
si.search

module化して、/libに置くか。共通処理であれば、controllers/concerns または、 models/concernsに置くか。迷うが、割とappの下にファイルを設置することが多く、それに見習った。

参考

Railsでサービスとフォームを導入してみる話 - assertInstanceOf('Engineer', $a_suenami)

ActiveRecord::Relation#orのworkaround

先日書いた記事の付け足しになりますね。orを利用して検索ロジックを書いていましたが mergeや、whereを利用することで構造に不一致になるケースがあり、HACK,WORKAROUNDで対処しました。

原因はなんとなくイメージできているんですが、詳しい方いましたら、教えて頂きたいです。

  • 完全に再現できるソースではなく、イメージです。

models

まず、モデルはこの親子関係になります。

# app/models/user.rb

class user < ApplicationRecord
  has_many :shops
end


# app/models/shop.rb
class shop < ApplicationRecord
  belongs_to :user
end

controller

app/controllers/users.rb

class Users < ApplicationController
  def search
    relations = user.joins(:shop).where(name: 'hoge')
    
    relations = if params[:conditions] == 'or'
                  relations.or(relations.where(age: 19)
                else
                  # relations.joins_valuesが更新される
                  relations.merge(relations.where(age: 19))
                end
    
    render json: relations.to_json({ include: :shops })
  end
end

relations.joins_values

これで、引数とレシーバーの構造が一致しているか検証しています。 joinsの引数(上記のサンプルコードでは、:shop)が入っていますので、これが一致するか見ています。

捕捉ですが、limit, offset, distinctも見ています。

WORKAROUND

修正したのは下記。

app/controllers/users.rb

class Users < ApplicationController
  def search
    relations = user.joins(:shop).where(name: 'hoge')
    
    relations = if params[:conditions] == 'or'
                  relations.joins_values = relatoins.joins_values.uniq # HACK:
                  relations.or(relations.where(age: 19)
                else
                  relations.merge(relations.where(age: 19)
                end
    
    render json: relations.to_json({ include: :shops })
  end
end

joins_valuesを上書きすることで対応した。これmergeをしているからjoins_valuesの値も追加されているのかと思い、whereに変えてみたが、 joins_valuesの値は変わらず。githubで調べるのはこのぐらいにして一時的な対応です。

参考

https://github.com/rails/rails/blob/0f80cb1b2b12f3c220391784d3a16473d7b87669/activerecord/lib/active_record/relation.rb#L3

to_json (Hash) - APIdock

関係ないけど後輩に教わった

vim正規表現\@=を教わった。vim情報はありがたい。

Railsでmysqlのviewsを利用する。

弊社のプロジェクトで、viewを利用していたので、覚え書き。

views

CREATE VIEW user
  (  
    id,
    name,
    age
  )
AS
  SELECT
    id,
    name,
    age
  FROM 
    A
  UNION 
  SELECT
    id,
    name,
    age
  FROM
    B;

ここは、migrationに置き換えることもできる。

model

モデルは、Railsmodelと同様です。

class User < ApplicationRecord
end

Rails 4系で、mysql view 自体は利用できる。

参考

http://dev.classmethod.jp/server-side/db/mysql_practice_1/

ActiveRecord::Relation#or

弊社の新規プロジェクトでは、Rails5が使われていてそのおかげで、orが利用できた。 今日は、その辺りをまとめてみる。

ActiveRecord::Relation#or

Rails 4系では、sqlorをアプリで実装しようとするとfind_by_sqlwherejoinsなどに直接sqlを書くか。Arelを利用して実装を進めていた。 Rails 5系からorが利用できるようになっているので、直接sqlを記述することなく、ActiveRecordの操作で完結できる。

# A || B

 Model.where(name: 'hoge').or(Model.(name: 'fuga'))

# A && B || C
 Model.where(name: 'hoge').where(age: 10).or(Model.where(name: 'fuga')) 

# A ‖ B && C
 Model.where(name: 'hoge').or(Model.where(age: 10)).where(name: 'fuga'))

という結果が返ってくる。

must be structurally compatible

構造互換性が必要なので、orに指定するActiveRecord::Relationとレシーバの構造は統一する。

互換性がなければ、下記のArgumentErrorが発生する。

ArgumentError: Relation passed to #or must be structurally compatible. Incompatible values: [:limit]

サンプル

Model.joins(:hoges)
     .merge(Model.where(name: nil)
     .or(Hoge.where(fuga: nil)
=> 
SELECT "model".* FROM "model" INNER JOIN "hoges" ON "hoges"."model_id" = "models"."id" WHERE ("models"."name" IS NULL OR "hoges"."fuga" IS NULL)

これで、andorの条件を切り換えられるロジックを書くことが容易になった。

参考

ActiveRecord::Relation

Rails 5 adds OR support in Active Record | BigBinary Blog

Rails 5: ActiveRecord OR query - Stack Overflow

form_tag、form_forからutf8というパラメータをはずす

検索の機能で、HTTP methodsをpostからgetに切り替えた際に、utf8というパラメータが含まれていたので、これを取り除いた。

form_for, form_tagから自動で生成されるinputタグだが、不要だったので、自動生成されないように変更した。

モンキーパッチ

module ActionView
  module Helpers
    module FormTagHelper
      def utf8_enforcer_tag
        ''.html_safe
      end
    end
  end
end

上記で、form_forform_tagから、utf8のinputタグは生成されない。 オプションで指定することもできるようだが、今回一部対応ではなく、プロジェクト全体から取り除いた。

参考

Rails の `utf8=✓` の歴史と消し方と snowman ☃ - Qiita

vim snippetの利用

vimからsnippetは以前から利用していますが、環境毎に設定していることがあり、再設定していたので こちらにまとめておく。

環境

Mac OS

vim

.vimrc

.vimrcに追加

...
if &compatible set nocompatible               " Be iMproved endif

execute 'set runtimepath^=~/.vim/repos/github.com/Shougo/dein.vim'
" Required:
 call dein#begin('~/cache/dein')

 call dein#add('Shougo/dein.vim')
 call dein#add('Shougo/neocomplete.vim')

" ここから
 call dein#add('Shougo/neosnippet')
 call dein#add('Shougo/neosnippet-snippets')
" ここまで
call dein#end()

filetype plugin indent on

" ここから
" Plugin key-mappings.  " <C-k>でsnippetの展開
imap <C-k>     <Plug>(neosnippet_expand_or_jump)
smap <C-k>     <Plug>(neosnippet_expand_or_jump)
xmap <C-k>     <Plug>(neosnippet_expand_target)
let g:neosnippet#snippets_directory = '~/.vim/snippets/'

if has('conceal')
  set conceallevel=2 concealcursor=niv
endif
" ここまで
...

dein#install()

vimを起動して、:call dein#install()

もしくは、vimrcに下記を記述する。

" pluginsがインストール済みか確認
if dein#check_install()
  call dein#install()
endif

使い方

Ctrl + kで利用する。

snippetの追加

独自のsnippetを利用する場合には、snippets_directoryで指定したフォルダにsnipファイルを作成して、追加していくか。 vimを起動してNeoSnippetEditコマンドを叩く。

vim hoge.rbrubyのファイルを開いているなら、NeoSnippetEditでは、ruby.snipが開かれる。 ファイルがなければ、新規作成する。

参考

GitHub - Shougo/dein.vim: Dark powered Vim/Neovim plugin manager

GitHub - Shougo/neosnippet.vim: neo-snippet plugin contains neocomplcache snippets source

Vimプラグイン初心者がスニペット機能を導入して、独自スニペットを追加できるまでの流れ - Qiita

Rubyのモジュール

コードを整理する際に、classではなく、moduleで整理したいと思う場面がありました。

Railsで書いていることもあり、ActiveSupport::Concernにまとめる形にします、 moduleクラスに機能を混ぜ合わせることで複数のクラスで機能を共有する(Mix-in)という機能を提供しています。

さらにActiveSupport::Concernは、関心事を分離するために独立した機能を定義したモジュールが配置されますので、これを利用することにします。

ActiveSupport::Concern

module DataAble
  extend ActiveSupport::Concern
  
  included do
    scope :with_deleted -> { where.not(deleted_at: nil) }
    scope :without_deleted -> { where(deleted_at: nil) }
  end

  def set_data
    @data = Data.pluck(:id, :name)
  end
end
class Hoge
  include SetDataAble
end

これで, できるようになります。

@hoge = Hoge.new
@hoge.set_date

Hoge.with_deleted

参考

ruby on rails - なぜRailsのモジュールでは"able"をサフィックスとして付けるのでしょうか? - スタック・オーバーフロー