-
Notifications
You must be signed in to change notification settings - Fork 21.4k
/
time_helpers.rb
268 lines (236 loc) · 9.73 KB
/
time_helpers.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# frozen_string_literal: true
require "active_support/core_ext/module/redefine_method"
require "active_support/core_ext/time/calculations"
module ActiveSupport
module Testing
# Manages stubs for TimeHelpers
class SimpleStubs # :nodoc:
Stub = Struct.new(:object, :method_name, :original_method)
def initialize
@stubs = Hash.new { |h, k| h[k] = {} }
end
# Stubs object.method_name with the given block
# If the method is already stubbed, remove that stub
# so that removing this stub will restore the original implementation.
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
# target = Time.zone.local(2004, 11, 24, 1, 4, 44)
# simple_stubs.stub_object(Time, :now) { at(target.to_i) }
# Time.current # => Wed, 24 Nov 2004 01:04:44 EST -05:00
def stub_object(object, method_name, &block)
if stub = stubbing(object, method_name)
unstub_object(stub)
end
new_name = "__simple_stub__#{method_name}__#{object_id}"
@stubs[object.object_id][method_name] = Stub.new(object, method_name, new_name)
object.singleton_class.alias_method new_name, method_name
object.define_singleton_method(method_name, &block)
end
# Remove all object-method stubs held by this instance
def unstub_all!
@stubs.each_value do |object_stubs|
object_stubs.each_value do |stub|
unstub_object(stub)
end
end
@stubs.clear
end
# Returns the Stub for object#method_name
# (nil if it is not stubbed)
def stubbing(object, method_name)
@stubs[object.object_id][method_name]
end
# Returns true if any stubs are set, false if there are none
def stubbed?
!@stubs.empty?
end
private
# Restores the original object.method described by the Stub
def unstub_object(stub)
singleton_class = stub.object.singleton_class
singleton_class.silence_redefinition_of_method stub.method_name
singleton_class.alias_method stub.method_name, stub.original_method
singleton_class.undef_method stub.original_method
end
end
# Contains helpers that help you test passage of time.
module TimeHelpers
def after_teardown
travel_back
super
end
# Changes current time to the time in the future or in the past by a given time difference by
# stubbing +Time.now+, +Date.today+, and +DateTime.now+. The stubs are automatically removed
# at the end of the test.
#
# Note that the usec for the resulting time will be set to 0 to prevent rounding
# errors with external services, like MySQL (which will round instead of floor,
# leading to off-by-one-second errors), unless the <tt>with_usec</tt> argument
# is set to <tt>true</tt>.
#
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
# travel 1.day
# Time.current # => Sun, 10 Nov 2013 15:34:49 EST -05:00
# Date.current # => Sun, 10 Nov 2013
# DateTime.current # => Sun, 10 Nov 2013 15:34:49 -0500
#
# This method also accepts a block, which will return the current time back to its original
# state at the end of the block:
#
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
# travel 1.day do
# User.create.created_at # => Sun, 10 Nov 2013 15:34:49 EST -05:00
# end
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
def travel(duration, with_usec: false, &block)
travel_to Time.now + duration, with_usec: with_usec, &block
end
# Changes current time to the given time by stubbing +Time.now+, +Time.new+,
# +Date.today+, and +DateTime.now+ to return the time or date passed into this method.
# The stubs are automatically removed at the end of the test.
#
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
# travel_to Time.zone.local(2004, 11, 24, 1, 4, 44)
# Time.current # => Wed, 24 Nov 2004 01:04:44 EST -05:00
# Date.current # => Wed, 24 Nov 2004
# DateTime.current # => Wed, 24 Nov 2004 01:04:44 -0500
#
# Dates are taken as their timestamp at the beginning of the day in the
# application time zone. <tt>Time.current</tt> returns said timestamp,
# and <tt>Time.now</tt> its equivalent in the system time zone. Similarly,
# <tt>Date.current</tt> returns a date equal to the argument, and
# <tt>Date.today</tt> the date according to <tt>Time.now</tt>, which may
# be different. (Note that you rarely want to deal with <tt>Time.now</tt>,
# or <tt>Date.today</tt>, in order to honor the application time zone
# please always use <tt>Time.current</tt> and <tt>Date.current</tt>.)
#
# Note that the usec for the time passed will be set to 0 to prevent rounding
# errors with external services, like MySQL (which will round instead of floor,
# leading to off-by-one-second errors), unless the <tt>with_usec</tt> argument
# is set to <tt>true</tt>.
#
# This method also accepts a block, which will return the current time back to its original
# state at the end of the block:
#
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
# travel_to Time.zone.local(2004, 11, 24, 1, 4, 44) do
# Time.current # => Wed, 24 Nov 2004 01:04:44 EST -05:00
# end
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
def travel_to(date_or_time, with_usec: false)
if block_given? && in_block
travel_to_nested_block_call = <<~MSG
Calling `travel_to` with a block, when we have previously already made a call to `travel_to`, can lead to confusing time stubbing.
Instead of:
travel_to 2.days.from_now do
# 2 days from today
travel_to 3.days.from_now do
# 5 days from today
end
end
preferred way to achieve above is:
travel 2.days do
# 2 days from today
end
travel 5.days do
# 5 days from today
end
MSG
raise travel_to_nested_block_call
end
if date_or_time.is_a?(Date) && !date_or_time.is_a?(DateTime)
now = date_or_time.midnight.to_time
elsif date_or_time.is_a?(String)
now = Time.zone.parse(date_or_time)
elsif with_usec
now = date_or_time.to_time
else
now = date_or_time.to_time.change(usec: 0)
end
# +now+ must be in local system timezone, because +Time.at(now)+
# and +now.to_date+ (see stubs below) will use +now+'s timezone too!
now = now.getlocal
stubs = simple_stubs
stubbed_time = Time.now if stubs.stubbing(Time, :now)
stubs.stub_object(Time, :now) { at(now) }
stubs.stub_object(Time, :new) do |*args, **options|
if args.empty? && options.empty?
at(now)
else
stub = stubs.stubbing(Time, :new)
Time.send(stub.original_method, *args, **options)
end
end
stubs.stub_object(Date, :today) { jd(now.to_date.jd) }
stubs.stub_object(DateTime, :now) { jd(now.to_date.jd, now.hour, now.min, now.sec, Rational(now.utc_offset, 86400)) }
if block_given?
begin
self.in_block = true
yield
ensure
if stubbed_time
travel_to stubbed_time
else
travel_back
end
self.in_block = false
end
end
end
# Returns the current time back to its original state, by removing the stubs added by
# +travel+, +travel_to+, and +freeze_time+.
#
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
#
# travel_to Time.zone.local(2004, 11, 24, 1, 4, 44)
# Time.current # => Wed, 24 Nov 2004 01:04:44 EST -05:00
#
# travel_back
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
#
# This method also accepts a block, which brings the stubs back at the end of the block:
#
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
#
# travel_to Time.zone.local(2004, 11, 24, 1, 4, 44)
# Time.current # => Wed, 24 Nov 2004 01:04:44 EST -05:00
#
# travel_back do
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
# end
#
# Time.current # => Wed, 24 Nov 2004 01:04:44 EST -05:00
def travel_back
stubbed_time = Time.current if block_given? && simple_stubs.stubbed?
simple_stubs.unstub_all!
yield if block_given?
ensure
travel_to stubbed_time if stubbed_time
end
alias_method :unfreeze_time, :travel_back
# Calls +travel_to+ with +Time.now+. Forwards optional <tt>with_usec</tt> argument.
#
# Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00
# freeze_time
# sleep(1)
# Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00
#
# This method also accepts a block, which will return the current time back to its original
# state at the end of the block:
#
# Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00
# freeze_time do
# sleep(1)
# User.create.created_at # => Sun, 09 Jul 2017 15:34:49 EST -05:00
# end
# Time.current # => Sun, 09 Jul 2017 15:34:50 EST -05:00
def freeze_time(with_usec: false, &block)
travel_to Time.now, with_usec: with_usec, &block
end
private
def simple_stubs
@simple_stubs ||= SimpleStubs.new
end
attr_accessor :in_block
end
end
end