RSpec:如何Mock SQL语句中的NOW()函数
这个问题我之前也碰到过——Timecop确实是Ruby层面模拟时间的神器,但它管不到数据库里的NOW()函数,因为NOW()是数据库进程自己计算的当前时间,和Ruby runtime的时间完全是两个独立的东西。不过有几个靠谱的解决办法,给你详细说说:
方法1:用Ruby的Time.now替换SQL里的NOW()(推荐)
这是最简单也最符合Rails最佳实践的方案。把原来直接写在SQL里的NOW()换成参数化查询,传入Ruby的Time.now,这样Timecop模拟的时间就会被带到SQL查询里:
# 原来的写法(不受Timecop控制) TeacherVacation.where('ends_at > NOW()').count # 修改后的写法(会用Timecop模拟的时间) TeacherVacation.where('ends_at > ?', Time.now).count
这样一来,不管你用Timecop把时间改成什么,查询都会用这个模拟时间作为判断标准,和你Ruby代码里的时间逻辑完全一致,而且还避免了SQL注入风险,一举两得。
方法2:临时修改数据库的会话时间(适合遗留代码)
如果因为历史原因没法修改SQL里的NOW(),那可以直接修改数据库会话的时间,让数据库的“当前时间”和Timecop模拟的时间同步。不同数据库的写法不一样:
PostgreSQL
在测试的setup阶段执行这条SQL,设置会话的时区/时间:
-- 设置为指定时间(注意时区格式) SET TIME ZONE '2018-03-12 12:00:00+03';
或者用更灵活的方式,在Ruby代码里动态生成这个SQL:
time = Timecop.now ActiveRecord::Base.connection.execute("SET TIME ZONE '#{time.strftime('%Y-%m-%d %H:%M:%S%z')}'")
别忘了在测试的teardown阶段把时间改回默认:
ActiveRecord::Base.connection.execute("SET TIME ZONE DEFAULT")
MySQL
类似地,修改会话级别的时区:
SET time_zone = '2018-03-12 12:00:00';
或者用Ruby动态生成:
time = Timecop.now ActiveRecord::Base.connection.execute("SET time_zone = '#{time.strftime('%Y-%m-%d %H:%M:%S')}'")
这种方法的缺点是需要针对不同数据库写不同的代码,而且如果测试并行运行的话可能会互相干扰,所以只适合单线程的测试场景。
方法3:Mock数据库函数(复杂场景)
有些数据库支持临时替换函数的实现,比如PostgreSQL可以用CREATE OR REPLACE FUNCTION临时把NOW()替换成返回固定时间的函数,但这种方法风险较高,容易破坏其他测试,一般不推荐,除非万不得已。
总结一下,优先用方法1,它最安全也最简洁;如果实在没法改SQL,再考虑方法2。
内容的提问来源于stack exchange,提问作者installero

