フィーチャーテスト(Rails + Rspec + Capybara)
追記: コメントをいただきまして、一部内容を更新しています。
今朝、rspec 3.5リリースの記事*1をrspec
の公式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
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
, Rspecの anonymous 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で調べるのはこのぐらいにして一時的な対応です。
参考
関係ないけど後輩に教わった
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
モデルは、Rails
のmodel
と同様です。
class User < ApplicationRecord end
Rails 4系で、mysql view
自体は利用できる。
参考
ActiveRecord::Relation#or
弊社の新規プロジェクトでは、Rails5が使われていてそのおかげで、or
が利用できた。
今日は、その辺りをまとめてみる。
ActiveRecord::Relation#or
Rails 4系では、sqlのor
をアプリで実装しようとするとfind_by_sql
、where
、joins
などに直接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)
これで、and
とor
の条件を切り換えられるロジックを書くことが容易になった。
参考
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_for
とform_tag
から、utf8のinput
タグは生成されない。
オプションで指定することもできるようだが、今回一部対応ではなく、プロジェクト全体から取り除いた。
参考
vim snippetの利用
vimからsnippetは以前から利用していますが、環境毎に設定していることがあり、再設定していたので こちらにまとめておく。
環境
.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.rb
でrubyのファイルを開いているなら、NeoSnippetEdit
では、ruby.snip
が開かれる。
ファイルがなければ、新規作成する。
参考
GitHub - Shougo/dein.vim: Dark powered Vim/Neovim plugin manager
GitHub - Shougo/neosnippet.vim: neo-snippet plugin contains neocomplcache snippets source