条件に日時を使うような境界値テストのrequest specにrspec-parameterizedとRailsのfreeze_timeまたはTimecopを組み合わせると落ちる

はい。

皆様ご存知、rspec-parameterizedというgemがあります。
複数入力の組み合わせで出力が決まるような、素のまま書くとcontext地獄になってしまうテストを短く書けて便利なやつです。

github.com

なんですが、仕事のプロダクトのrequest specにおいて、事前状況セットアップに使う値とその結果のレスポンス内容として期待する値がセットになっているような境界値テストをrspec-parameterizedを使って書いたところ、通ったり通らなかったりする。

数人がかりで2時間弱ほど格闘したところ、下記のことがわかりました。

  • 通ったり通らなかったりしたのはDBのDATETIME型が秒単位だったせい
    • なのでrspec-parameterizedのせいではない
  • rspec-parameterizedはwhereの中身をその場で評価してテストケースの組み合わせを生成していそう
    • 遅延評価ではない?

最小の再現サンプルがこちら。 github.com

上記の再現サンプルではコード量を最小にするためにTimecopを使っていますが、この現象を発見した時はRailsActiveSupport::Testing::TimeHelpersのfreeze_timeとtravel_backを使っていました。
また、before :eachを:suiteにしても変わりませんでした。

ruby-jpのslackにて質問したところ、コミッタのsue445さんより

一応これで回避はできるのですが、自分で書いといてなんだけどすごい気持ち悪いですね…
https://github.com/sue445/rspec-parameterized-with-timecop-issue-minimum-reproduce-sample/commit/6b31bc782b29d007a63928f8ae3bd9e02f253c1d

どうやらrspec-parameterizedにパラメータとして渡したDateTime型を返すものはRailsのfreeze_time(またはTimecop)によってDateTimeがモックされる前に評価されてしまうらしいということがわかりました。

これ厳密にはちょっと違ってて、rspec-parameterizedの where ブロックがrspecの before の処理よりも前に評価されるのが原因です。( where の中で let の値が参照できないのも同じ理屈)

という返答をいただきました。

別の方から下記記事を教えていただきましたが今回のケースはrequest specなのでこれも使用できず。

techlife.cookpad.com

他の方から下記のようにrspecの外側でnowを変数に束縛し、whereブロック中でパラメータとして書く日時はnowからの加減算で表し、テスト時にTimecop.freeze(now)、Railsではtravel_to(now)するという形を提案していただき、これを使うことにしました。

+now = Time.now
+
 RSpec.context 'parameterized spec' do
   before :each do
-    Timecop.freeze
+    Timecop.freeze(now)
   end
 
   after :each do
     Timecop.return
   end
 
-  subject { Time.now }
+  subject { now }
 
   where(:time) do
     [
-      [Time.now],
+      [now],
     ]
   end

https://github.com/takayamaki/rspec-parameterized-with-timecop-issue-minimum-reproduce-sample/compare/fixed

書き方まで完璧とは言えませんが、テストの不確実性は排除できたので一旦よしとします…

反応いただいた皆様ありがとうございました 🙇‍♂️