kikeda1104's blog

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

フィーチャーテスト(Rails + Rspec + Capybara)

追記: コメントをいただきまして、一部内容を更新しています。

今朝、rspec 3.5リリースの記事*1rspecの公式HPから確認した所

Rails 5 では、 assigns と assert_template が soft deprecated になりました。

という内容が書いてあり、今までcontroller specに書いていたテストの一部をrequest specに移すことになりそうです。

controller specでテストする内容は、responseとレコードの新規/更新/削除になるのかと思います。

assignsが使えず*2インスタンス変数にアクセスできなくなっているため、 controller specでテストするのではなく、別のspecでテストを書くことになりそうです。

本題に戻ります。 自作アプリにフィーチャーテストを追加しまして、まとめていきます。 model, controllerと、poltergeistの準備についても省いています。

Gemfile

# Gemfile
...

group :development, :test do
  ...
  gem 'factory_girl_rails'
end
...

group :development do
  gem 'capybara'
  gem 'poltergeist'
  gem 'launchy'
  gem 'database_cleaner'
  # 一部削除しました。
  gem 'simplecov', require: false
end

simplecovは、テストカバレッジを見るgemになるので、外しても良いです。 simplecovを利用する上で必要な設定は引き続きで、記事にのせています。

利用する場合、htmlが作成されますが、rails sからアクセスできる場所にファイルが保存されないため、 rubyからwebrickを起動するスクリプトを書くと使い易くなります。

spec/rails_helper.rb

rails_helperに追加していきます。

# rails_helper.rb

...
require 'simplecov'

SimpleCov.start('rails') do
  add_filter '/vendor/'
end

require 'capybara/rspec'
require 'cabtpara/poltergeist'
Capybara.javascript_driver = :poltergeist

Rspec.configure do |config|
...

  # sample
  # RSpec.configure do |c|
  # c.before(:each)  { } # :each 全てのテストスイート中のそれぞれのexampleの前に実行される
  #  c.before(:all)   { } # :all それぞれのトップレベルのグループの最初のexampleの前に実行される
  #  c.before(:suite) { } # :suite 全てのspecファイルがロードされたあと、最初のspecが実行される前に一度だけ実行される
  #end

  # exampleは、eachのalias
  # contextは、allのalias
  
  # https://github.com/DatabaseCleaner/database_cleaner#rspec-with-capybara-example

  config.before(:suite) do
    if config.use_transactional_fixtures?
      raise(<<-MSG)
        Delete line `config.use_transactional_fixtures = true` from rails_helper.rb
        (or set it to false) to prevent uncommitted transactions being used in
        JavaScript-dependent specs.

        During testing, the app-under-test that the browser driver connects to
        uses a different database connection to the database connection used by
        the spec. The app's database connection would not be able to access
        uncommitted transaction data setup over the spec's database connection.
      MSG
    end
    load Rails.root.join('db', 'seeds.rb')
    DatabaseCleaner.clean_with, :truncation, { except: %w(master_tables) }
  end 

  config.before(:each) do
    DatabaseCleaner.strategy = :transaction
  end

  config.before(:each, type: :feature) do
    driver_shares_db_connection_with_specs = Capybara.current_driver == :rack_test

    if !driver_shares_db_connection_with_specs
      DatabaseCleaner.strategy = :truncation
    end
  end

  config.before(:each) do 
    DatabaseCleaner.start
  end

  config.append_after(:each) do
    load Rails.root.join('db', 'seeds.rb')
    DatabaseCleaner.clean
  end
...
  config.include LoginMacro
end

factories

最低限のfactoryを準備します。

# spec/factories/users.rb

FactoryGirl.define do
  factory :user do
    email: 'user@example.com'
    password 'secret'
    password_confirmation 'secret'
  end
end

spec/support/login_macro.rb

# spec/support/login_macro.rb
module LoginMacro
  ...
  
  def sign_in(user)
    visit admin_sessions_new_path
    fill_in 'user_email', with: user.email
    fill_in 'user_password', with: 'secret'
    click_on 'Submit'
  end
end

spec/support/shared_db_connection.rb

コメントをいただきまして、最新のdatabase_cleanerを利用する場合、不要になっています。

feature

featureテストを追加します。

# spec/features/user_login_spec.rb
require 'rails_helper'

Rspec.feature 'Authenticate', type: :feature do
  scenario 'login user', js: true do
    user = create(:user)
    sign_in(user)
    expect(page).to have_content user.company.name
  end
end

追記: scenarioに追加されているオプションjs: trueにより、javascriptを動作させることができます。

テスト実行

$ bundle exec rspec spec/features/admin_login_spec.rb
.

Finished in 7.05 seconds (files took 2.76 seconds to load)
1 example, 0 failures

Coverage report generated for RSpec to /home/user_name/application_name/coverage. 130 / 448 LOC (29.02%) covered.

Coverage...は、simplecovが出力するログです。

以上になります。

参考

Feature spec - Feature specs - RSpec Rails - RSpec - Relish

使えるRSpec入門・その4「どんなブラウザ操作も自由自在!逆引きCapybara大辞典」 - Qiita

RSpec 3の重要な変更 - 有頂天Ruby

CentOS6にyumでphantomjs 2.1.1をインストール - Qiita

[Ruby] よく使うRspecのレシピ集(Rspec3.3) | Developers.IO

*1:http://rspec.info/ja/blog/2016/07/rspec-3-5-has-been-released/

*2:gemを入れることで利用することはできるが新規アプリでは非推奨

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