2015/12/16

Railsのテストで定数のスタブが欲しい場合

Railsの ActiveSupport::TestCase は minitest を使っていて、minitest/mock には定数をスタブ化する機能はない。
adammck/minitest-stub-const ってのはあるけど、外部ライブラリ入れるほどじゃないよねって場合があるかもしれない。
class Foo
BAR = 'bar'
end
class FooTest < ActiveSupport::TestCase
setup do
@original = Foo.send(:remove_const, :BAR)
Foo.const_set(:BAR, 'new_value')
end
teardown do
Foo.send(:remove_const, :BAR)
Foo.const_set(:BAR, @original)
end
test 'Foo::BARのテスト' do
assert_equal 'new_value', Foo::BAR
end
end
そんな時はこんな感じのコードでいいんじゃないかな?
こういうトリッキーなのは最終手段的なもので、定数を環境ごとに設定ファイルに定義できるような gem を使うべきだとは思う。
やってみたらできたというお話

assert_change

ユニットテスト時によく使うメソッドの1つに assert_difference がありますね。
でもこれ差分を取るので数値を返す式にしか使えません。使おうとしたら + なんて演算子ないよってエラーが出ます。
仕方なしにこんなテストコードを書いてません?
test 'ユーザーが確認済みになること' do
  assert_not user.confirmed?
  # ここに承認処理
  assert user.confirm?
end
それならば、こんなメソッドを test_helper あたりに定義しておいて
def assert_change(expression, message = nil, &block)
exp = expression.respond_to?(:call) ? expression : ->{ eval(expression, block.binding) }
before = exp.call
yield
error = "#{expression.inspect} didn't change"
error = "#{message}.\n#{error}" if message
assert_not_equal before, exp.call, error
end
def assert_not_change(expression, message = nil, &block)
exp = expression.respond_to?(:call) ? expression : ->{ eval(expression, block.binding) }
before = exp.call
yield
error = "#{expression.inspect} changed"
error = "#{message}.\n#{error}" if message
assert_equal before, exp.call, error
end
test 'ユーザーが確認済みになること' do
  assert_change 'user.confirmed?' do
    # ここに承認処理
  end
end

# 実際の値を気にしなくていいならこんな使い方も
test '保存したら updated_at が更新される' do
  assert_change 'user.updated_at' do
    user.save
  end
end
と書くとちょっとスッキリしますね。コードはほぼほぼ assert_difference のパクリです。
expression の指定方法は必要になったら広げる感じでいいかな。

2015/12/14

jQuery使ってAJAXでファイルアップロード

なんか毎回毎回うろ覚えでちょろちょろ調べながら書いている気がするのでメモとして
<div id="drop-area" class="file-drop-area">
<input id="upload-file" type="file" class="hidden"/>
<button id="select-file" class="btn btn-primary">ファイルを選択</button>
</div>
jQuery(document).ready(function($) {
var cancel = function(e) {
e.preventDefault();
e.stopPropagation();
return false;
};
$('.file-drop-area').bind('dragenter', function(e) {
$(this).addClass('drag-enter');
return cancel(e);
}).bind("dragleave", function(e) {
$(this).removeClass('drag-enter');
return cancel(e);
}).bind('dragover', function(e) {
return cancel(e);
}).bind('drop', function(e) {
$(this).removeClass('drag-enter');
return cancel(e);
});
});
jQuery(document).ready(function($) {
var uploadFile = function(file) {
formData = new FormData();
formData.append('file', file);
$.ajax({
url: '/upload',
type: 'POST',
data: formData,
processData: false,
contentType: false
}).done(function(data, status, response) {
console.log('success');
}).fail(function(response, status, thrown) {
console.log(response.responseText);
});
};
$('#drop-area').bind('drop', function(e) {
if (e.originalEvent.dataTransfer.files.length > 0) {
uploadFile(e.originalEvent.dataTransfer.files[0]);
}
});
$('#upload-file').bind('change', function() {
if (this.files.length > 0) {
uploadFile(this.files[0]);
}
});
$('#select-file').bind('click', function() {
$('#upload-file').click();
});
});
今回はdragoverを補足してキャンセルしておかないとdropイベントを補足してくれなかったのにハマった
スタイル変えたり、イベント伝播のキャンセル処理は共通なので class で処理して共通jsに置く。
ドロップされた時の処理は、アップロード先やアップロード後の処理など画面ごとになる可能性が高いので id を使って個別jsに置くイメージ

2015/12/10

Deviseの有効期限設定をテストする

設定ファイル config/initializers/devise.rb はこんな感じとする。
Devise.setup do |config|
  # 省略
  config.reset_password_within = 30.minutes
  config.confirm_within = 30.days
  # 省略
end
テストコードはこんな感じでかけた。
require 'test_helper'
class Users::PasswordsControllerTest < ActionController::TestCase
setup do
@request.env['devise.mapping'] = Devise.mappings[:user]
@user = create(:user)
end
test 'メールを送信して30分後まではトークンは有効' do
post :create, user: { email: @user.email }
before = @user.reload.encrypted_password
Time.stub(:now, @user.reset_password_sent_at + 30.minutes) do
put :update, user: { password: 'new-password', password_confirmation: 'new-password', reset_password_token: reset_password_token }
end
assert_not_equal @user.reload.encrypted_password, before
end
test 'メールを送信して30分を超えたらトークンは無効' do
post :create, user: { email: @user.email }
before = @user.reload.encrypted_password
Time.stub(:now, @user.reset_password_sent_at + 30.minutes + 1.second) do
put :update, user: { password: 'new-password', password_confirmation: 'new-password', reset_password_token: reset_password_token }
end
assert_equal @user.reload.encrypted_password, before
end
end
require 'test_helper'
class Users::ConfirmationsControllerTest < ActionController::TestCase
setup do
@request.env['devise.mapping'] = Devise.mappings[:user]
@user = create(:user)
end
test '登録して30日以内なら確認可' do
assert_not @user.confirmed?
Time.stub(:now, @user.confirmation_sent_at + 30.days) do
get :show, confirmation_token: @user.confirmation_token
end
assert user.reload.confirmed?
end
test '登録して30日以降なら確認不可' do
assert_not @user.confirmed?
Time.stub(:now, @user.confirmation_sent_at + 30.days + 1.second) do
get :show, confirmation_token: @user.confirmation_token
end
assert_not @user.reload.confirmed?
end
end
Recoverable では reset_password_sent_at reset_password_within.ago を比較している。
ActiveSupport::Duration#agoまで遡ると初期値である Time.current に対して演算していたので、最初は Time.stub(:current, ... のようにしていた。
しかし、Confirmable では、合算値を Time.now と比較している。そのため、Time.current は通らないのでこのやり方ではだめだった。
結局は Time.now をスタブ化したらよかったわけだが、こういう同質的な処理は同じ書き方をしてほしいなーとおもった昼下がり。

2015/12/09

Deviseにてシステムからユーザーのメールアドレスを変更してもメールを送信しない

Devise を使用していて、手順を踏まずにシステムにてメールアドレスを変更すると保存時にメールが送信されてしまう。
メールを送信したくない場合は skip_reconfirmation! を使用する。
def change_user_email(user)
user.email = 'new-email@example.com'
user.skip_reconfirmation!
user.save
end
# テストコード
test 'ユーザーのメールアドレスを変更しても、メールが送信されないこと' do
assert_no_difference 'ActionMailer::Base.deliveries.size' do
change_user_email(@user)
end
end
なお、新規作成時に確認手順を飛ばしたい場合は skip_confirmation! を使う。

2015/12/04

ArelでのOR検索ついでにごにょごにょいじってみた

1つのキーワードで複数テーブルの複数カラムをあいまい検索ってよくある話ですね。
Arelを使って OR の LIKE 検索って冗長になりがちだけどそこそこパターン化出来そうだなーとつらつらとコード書いてみた。
# こんなテーブルがあるとします
create_table :users do |t|
t.string :name
t.string :email
end
create_table :belongings do |t|
t.integer :user_id
t.string :company_name
t.string :dept_name
end
view raw 00_tables.rb hosted with ❤ by GitHub
# 1つのキーワードであらゆる属性をあいまい検索したい場合、Arelを使えばこう書けます
# left join も Arel で書けますが、冗長なので eager_load で代用してます
match_key = "%#{keyword}%"
name_matches = User.arel_table[:name].matches(match_key)
email_matches = User.arel_table[:email].matches(match_key)
company_name_matches = Belonging.arel_table[:company_name].matches(match_key)
dept_name_matches = Belonging.arel_table[:dept_name].matches(match_key)
User.eager_load(:belonging).where(name_matches.or(email_matches).or(company_name_matches).or(dept_name_matches))
view raw 01_arel_or.rb hosted with ❤ by GitHub
# or のメソッドチェーンがなんかやだね。こうすれば対応カラムが増えた時 matchers の要素を増やすだけで済むぞ
match_key = "%#{keyword}%"
matchers = [User.arel_table[:name].matches(match_key),
User.arel_table[:email].matches(match_key),
Belonging.arel_table[:company_name].matches(match_key),
Belonging.arel_table[:dept_name].matches(match_key)]
condition = matchers.inject { |cond, matcher| cond.or(matcher) }
User.eager_load(:belonging).where(condition)
# これならシンプルに inject(&:sym) でいけるね
match_key = "%#{keyword}%"
matchers = [User.arel_table[:name].matches(match_key),
User.arel_table[:email].matches(match_key),
Belonging.arel_table[:company_name].matches(match_key),
Belonging.arel_table[:dept_name].matches(match_key)]
User.eager_load(:belonging).where(matchers.inject(&:or))
# matchers も定型的なので冗長だよね
class_columns_set = { User => [:name, :email], Belonging => [:company_name, :dept_name] }
matchers = class_columns_set.map do |cls, cols|
cols.map { |col| cls.arel_table[col].matches("%#{keyword}%") }
end.flatten
User.eager_load(:belonging).where(matchers.inject(&:or))
view raw 04_finally.rb hosted with ❤ by GitHub
# なんかシンプルなパターンなら切り出せそう
class SimpleFinder
def initialize(base_class, class_columns_set)
@base_class = base_class
@class_columns_set = class_columns_set
end
def find(keyword)
matchers = @class_columns_set.map do |cls, cols|
cols.map { |col| cls.arel_table[col].matches("%#{keyword}%") }
end.flatten
foreign_tables = @class_columns_set.keys.reject { |cls| cls == @base_class }.map(&:table_name)
@base_class.eager_load(*foreign_tables).where(matchers.inject(&:or))
end
end
SimpleFinder.new(User, User => [:name, :email], Belonging => [:company_name, :dept_name]).find('foobar')
view raw 05_modulize.rb hosted with ❤ by GitHub
実際は SimpleFinder までやると適用できるパターンが限定されるので、04_finally みたいなところが落とし所な気もする。
人によっては 03_inject_with_symbol ぐらいが一番可読性がいいって意見もありそう。
Arelを使って OR や LIKE をするメリットは scope にして merge した時に壊れないって記述をよく見ますが、こういう風に動的に対応箇所を増やせるように持っていくのも楽というのもメリットですね。文字列で where 内を書いていたらなかなかこうはできない
あ、こんな処理を他にもたくさん書かなければいけない場合は Squeel 入れたほうがいいと思います。

全然関係ないけど、gist って編集時はインスタンス変数に色つけてくれるのに閲覧時には色つけてくれないの何でだろ

[追記] たまたま Ruby on Rails Advent Calendar 2015 が空いてたので飛び入りしました。

2015/11/16

RubyとRailsとminitestのマルチバージョンでのCI環境の話

pinzolo/rails-flog の Rails4.2でのテストが落ちてて、でも動いてるしーで放置してしまってた。
んで、ちゃんとしないとと思って調べてみたら、テストを壊していたのはテストコード(と自分の思い込み)だった。
具体的には mocha というモックライブラリを使用していたんだけど、スタブ作ったらテストケースをまたいで存在し続けるのを、テストケースセーフだと思い込んでいた。以前のバージョンではシーケンシャルにテストが行われていたけど、ランダムになったのでテストが落ちるようになった。
どうりでローカルでテストすると時々によってエラーの数が違うわけだ。
んで、minitest にもモック・スタブ機能があることを知り、ブロックでの影響範囲を限定できるのでこっちのほうがいいじゃん!!と mocha を削除し minitest のスタブに統一した。
ここまで前置き

ローカルでテスト通ったーと喜んで push すると travis でコケる
Ruby1.9&Rails3.2ではstubなんてないよと怒られ、Ruby2.2&Rails3.2では 'minitest/autorun'がないと怒られた。 Ruby1.9に同梱されている minitest には minitest/mock がまだ存在していなかったらしい。

調べてみると明示的に最新の minitest を使うのが良いらしく、gemspec に足してみた。
するとこんどは Rails3.2で軒並みコケた
警告も出てるし、いくつか試行錯誤してRails3.2では minitest のバージョンを 4.7.x となるように指定したら、今度はRuby2.2の時だけtest-unitが足りないと言われる。
そういや削除されたんだったっけ。
これでようやく全部通った。なまじ同梱されてるから環境の差分を調整するのはしんどい。

2015/11/07

Rails3とRails4のhas_manyオプションの差分を吸収する

Rails3とRails4の違いの1つに has_many のオプションがあります。
# Rails3
class Course < ActiveRecord::Base
has_many :students, order: :name, include: [:parents, :brothers], dependent: :destroy
end
# Rails4
class Course < ActiveRecord::Base
has_many :students, ->{ order(:name).includes(:parents, :brothers) }, dependent: :destroy
end
Rails4では orderinclude が使えなくなってるんですね。

しかし、1つのソースでRails3とRails4に対応したい場合どうしようか?
module AssocOpts
extend ActiveSupport::Concern
module ClassMethods
def assoc_opts(options)
return [options] if Gem::Version.new(Rails.version) < Gem::Version.new('4.0.0')
order_option = options.delete(:order)
include_option = options.delete(:include)
if order_option && include_option
[Proc.new { order(order_option).includes(include_option) }, options]
elsif order_option
[Proc.new { order(order_option) }, options]
elsif include_option
[Proc.new { includes(include_option) }, options]
else
[options]
end
end
end
end
view raw assoc_opts.rb hosted with ❤ by GitHub
# Rails3 and Rails4
class Course < ActiveRecord::Base
include AssocOpts
has_many :students, *assoc_opts(order: :name, include: [:parents, :brothers], dependent: :destroy)
end
とまあこんな感じでどうでしょう?

2015/11/06

本番環境での secret_key_base 設定

本番環境に Rails4.2.4 のアプリケーションをデプロイするとこんなエラーが出た。
App 21863 stderr: [ 2015-11-06 12:37:20.3972 21881/0x007f1632a022e8(Worker 1) utils.rb:84 ]: *** Exception RuntimeError in Rack application object (Missing `secret_token` and `secret_key_base` for 'production' environment, set these value
s in `config/secrets.yml`) (process 21881, thread 0x007f1632a022e8(Worker 1)):
config/secrets.ymlによると、本番環境ではENV["SECRET_KEY_BASE"]を読み込むらしい。
でも、複数の Rails アプリを動かしているし、今後も増やすだろうからpassengerの運用ユーザーに対して設定する訳にはいかない。
ENV["FOO_SECRET_KEY_BASE"]のようにアプリごとに環境変数変えたらいいのかな?とおもって調べてみると、apacheの設定ファイルで SetEnv すればよいらしい。
# /etc/apache2/sites-available/foo-app.conf
<VirtualHost *:80>
  ServerName foo-app.mkt-sys.jp:80
  DocumentRoot /var/lib/rails/foo-app/public
  <Directory /var/lib/rails/foo-app/public/>
    SetEnv SECRET_KEY_BASE abcdefg...xyz       #←ココ
    Options FollowSymLinks
    AllowOverride None
    Require all granted
  </Directory>
  PassengerEnabled on
</VirtualHost>
設定するキーは bunde exec rake secret RAILS_ENV=production で出力されたものをコピペ。(多分 RAILS_ENV=productionはいらないだろうけど)

2015/10/30

自作gemのテストを test-unit で行う

久しぶりに自作 gem でも作るかと bundle gem -t とすると自動的に spec フォルダと .rspec が作成された。
いやいや、test-unit 使いたいんだってば。
仕方ないので自分で設定することに
gemspec に spec.add_development_dependency 'test-unit' を追加し、Rakefile にこんな感じに設定を書くだけだった。
require 'bundler/gem_tasks'
require 'rake/testtask'
Rake::TestTask.new do |t|
t.libs << 'test'
t.test_files = FileList['test/my_gem/*_test.rb']
t.verbose = true
end
task default: :test
view raw Rakefile hosted with ❤ by GitHub

2015/10/27

自作クラスの配列同士で引き算を正しく動作させるには

class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def ==(other)
@x == other.x && @y == other.y
end
def to_s
"(#{x}, #{y})"
end
def inspect
to_s
end
end
p [Point.new(1, 1), Point.new(2, 2), Point.new(3, 3)] - [Point.new(3, 3), Point.new(1, 1)]
# => [(1, 1), (2, 2), (3, 3)]
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def ==(other)
eql?(other)
end
def eql?(other)
@x == other.x && @y == other.y
end
def hash
@x + @y
end
def to_s
"(#{x}, #{y})"
end
def inspect
to_s
end
end
p [Point.new(1, 1), Point.new(2, 2), Point.new(3, 3)] - [Point.new(3, 3), Point.new(1, 1)]
# => [(2, 2)]
自作の配列で引き算を行おうとしたら、==のオーバーライドだけでは足りず、eql?hashのオーバーライドが必要だった。