Skip to content

Commit 8101b0f

Browse files
committed
add the ability to freeze time temporary
1 parent 0311148 commit 8101b0f

File tree

8 files changed

+255
-64
lines changed

8 files changed

+255
-64
lines changed

clock/Clock.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func After(d time.Duration) <-chan time.Time {
2020
wait:
2121
for {
2222
select {
23-
case <-internal.Listen(): // FIXME: flaky behaviour with time travelling
23+
case <-internal.Listen():
2424
continue wait
2525
case <-time.After(internal.RemainingDuration(startedAt, d)):
2626
break wait

clock/README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Clock and Timecop
2+
3+
## DESCRIPTION
4+
5+
Package providing "time travel" and "time scaling" capabilities,
6+
making it simple to test time-dependent code.
7+
8+
## INSTALL
9+
10+
```sh
11+
go get -u github.com/adamluzsi/testcase
12+
```
13+
14+
## FEATURES
15+
16+
- Freeze time to a specific point.
17+
- Travel back to a specific time, but allow time to continue moving forward.
18+
- Scale time by a given scaling factor will cause the time to move at an accelerated pace.
19+
- No dependencies other than the stdlib
20+
- Nested calls to timecop.Travel is supported
21+
- Works with any regular Go projects
22+
23+
## USAGE
24+
25+
```go
26+
package main
27+
28+
import (
29+
"github.com/adamluzsi/testcase/assert"
30+
"github.com/adamluzsi/testcase/clock"
31+
"github.com/adamluzsi/testcase/clock/timecop"
32+
"testing"
33+
"time"
34+
)
35+
36+
func Test(t *testing.T) {
37+
type Entity struct {
38+
CreatedAt time.Time
39+
}
40+
41+
MyFunc := func() Entity {
42+
return Entity{
43+
CreatedAt: clock.TimeNow(),
44+
}
45+
}
46+
47+
expected := Entity{
48+
CreatedAt: clock.TimeNow(),
49+
}
50+
51+
timecop.Travel(t, expected.CreatedAt, timecop.Freeze())
52+
53+
assert.Equal(t, expected, MyFunc())
54+
}
55+
```
56+
57+
Time travelling is undone as part of the test's teardown.
58+
59+
### timecop.Travel + timecop.Freeze
60+
61+
The Freeze option causes the observed time to stop until the first time reading event.
62+
63+
### timecop.SetSpeed
64+
65+
Let's say you want to test a "live" integration wherein entire days could pass by
66+
in minutes while you're able to simulate "real" activity. For example, one such use case
67+
is being able to test reports and invoices that run in 30-day cycles in very little time while also
68+
simulating activity via subsequent calls to your application.
69+
70+
```go
71+
timecop.SetSpeed(t, 1000) // accelerate speed by 1000x times from now on.
72+
<-clock.After(time.Hour) // takes only 1/1000 time to finish, not an hour.
73+
clock.Sleep(time.Hour) // same
74+
```
75+
76+
## References
77+
78+
The package was inspired by [travisjeffery' timecop project](https://github.com/travisjeffery/timecop).

clock/examples_test.go

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,50 @@
11
package clock_test
22

33
import (
4+
"github.com/adamluzsi/testcase/assert"
45
"github.com/adamluzsi/testcase/clock"
56
"github.com/adamluzsi/testcase/clock/timecop"
67
"testing"
78
"time"
89
)
910

10-
func ExampleTimeNow() {
11-
_ = clock.TimeNow() // now
11+
func ExampleTimeNow_freeze() {
12+
var tb testing.TB
13+
14+
type Entity struct {
15+
CreatedAt time.Time
16+
}
17+
18+
MyFunc := func() Entity {
19+
return Entity{
20+
CreatedAt: clock.TimeNow(),
21+
}
22+
}
23+
24+
expected := Entity{
25+
CreatedAt: clock.TimeNow(),
26+
}
27+
28+
timecop.Travel(tb, expected.CreatedAt, timecop.Freeze())
29+
30+
assert.Equal(tb, expected, MyFunc())
31+
}
1232

33+
func ExampleTimeNow_withTravelByDuration() {
1334
var tb testing.TB
14-
timecop.Travel(tb, time.Hour)
1535

36+
_ = clock.TimeNow() // now
37+
timecop.Travel(tb, time.Hour)
1638
_ = clock.TimeNow() // now + 1 hour
39+
}
40+
41+
func ExampleTimeNow_withTravelByDate() {
42+
var tb testing.TB
1743

18-
timecop.TravelTo(tb, 2022, 01, 01)
19-
_ = clock.TimeNow() // 2022-01-01 at {now.Hour}-{now.Minute}-{now.Second}
44+
date := time.Date(2022, 01, 01, 12, 0, 0, 0, time.Local)
45+
timecop.Travel(tb, date, timecop.Freeze()) // freeze the time until it is read
46+
time.Sleep(time.Second)
47+
_ = clock.TimeNow() // equals with date
2048
}
2149

2250
func ExampleAfter() {

clock/internal/chronos.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,41 +16,48 @@ var chrono struct {
1616
Altered bool
1717
SetAt time.Time
1818
When time.Time
19+
Freeze bool
1920
}
2021
Speed float64
2122
}
2223

2324
func SetSpeed(s float64) func() {
2425
defer notify()
2526
defer lock()()
26-
setTime(time.Now())
27+
freeze := chrono.Timeline.Freeze
28+
td := setTime(getTime(), freeze)
2729
og := chrono.Speed
2830
chrono.Speed = s
2931
return func() {
3032
defer notify()
3133
defer lock()()
3234
chrono.Speed = og
35+
td()
3336
}
3437
}
3538

36-
func SetTime(target time.Time) func() {
39+
func SetTime(target time.Time, freeze bool) func() {
3740
defer notify()
3841
defer lock()()
39-
return setTime(target)
42+
td := setTime(target, freeze)
43+
return func() {
44+
defer notify()
45+
defer lock()()
46+
td()
47+
}
4048
}
4149

42-
func setTime(target time.Time) func() {
50+
func setTime(target time.Time, freeze bool) func() {
4351
og := chrono.Timeline
4452
n := chrono.Timeline
4553
n.Altered = true
4654
n.SetAt = time.Now()
4755
n.When = target
48-
chrono.Timeline = n
49-
return func() {
50-
defer notify()
51-
defer lock()()
52-
chrono.Timeline = og
56+
if freeze {
57+
n.Freeze = true
5358
}
59+
chrono.Timeline = n
60+
return func() { chrono.Timeline = og }
5461
}
5562

5663
func RemainingDuration(from time.Time, d time.Duration) time.Duration {
@@ -74,6 +81,10 @@ func getTime() time.Time {
7481
if !chrono.Timeline.Altered {
7582
return now
7683
}
84+
if chrono.Timeline.Freeze {
85+
chrono.Timeline.Freeze = false
86+
chrono.Timeline.SetAt = now
87+
}
7788
delta := now.Sub(chrono.Timeline.SetAt)
7889
delta = time.Duration(float64(delta) * chrono.Speed)
7990
return chrono.Timeline.When.Add(delta)

clock/timecop/opts.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package timecop
2+
3+
type TravelOption interface {
4+
configure(*option)
5+
}
6+
7+
func toOption(tos []TravelOption) option {
8+
var o option
9+
for _, opt := range tos {
10+
opt.configure(&o)
11+
}
12+
return o
13+
}
14+
15+
type fnTravelOption func(*option)
16+
17+
func (fn fnTravelOption) configure(o *option) { fn(o) }
18+
19+
type option struct {
20+
Freeze bool
21+
}

clock/timecop/timecop.go

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,18 @@ import (
66
"time"
77
)
88

9-
func Travel[D time.Duration | time.Time](tb testing.TB, d D) {
9+
func Travel[D time.Duration | time.Time](tb testing.TB, d D, tos ...TravelOption) {
1010
tb.Helper()
1111
guardAgainstParallel(tb)
12+
opt := toOption(tos)
1213
switch d := any(d).(type) {
1314
case time.Duration:
14-
travelByDuration(tb, d)
15+
travelByDuration(tb, d, opt.Freeze)
1516
case time.Time:
16-
travelByTime(tb, d)
17+
travelByTime(tb, d, opt.Freeze)
1718
}
1819
}
1920

20-
func TravelTo[M int | time.Month](tb testing.TB, year int, month M, day int) {
21-
tb.Helper()
22-
guardAgainstParallel(tb)
23-
now := internal.GetTime()
24-
tb.Cleanup(internal.SetTime(time.Date(
25-
year,
26-
time.Month(month),
27-
day,
28-
now.Hour(),
29-
now.Minute(),
30-
now.Second(),
31-
now.Nanosecond(),
32-
now.Location(),
33-
)))
34-
}
35-
3621
func SetSpeed(tb testing.TB, multiplier float64) {
3722
tb.Helper()
3823
guardAgainstParallel(tb)
@@ -45,17 +30,24 @@ func SetSpeed(tb testing.TB, multiplier float64) {
4530
// guardAgainstParallel
4631
// is a hack that ensures that there was no testing.T.Parallel() used in the test.
4732
func guardAgainstParallel(tb testing.TB) {
48-
const key = `TEST_CASE_CLOC_IN_USE`
4933
tb.Helper()
50-
tb.Setenv(key, "TRUE")
34+
const key, value = `TEST_CASE_TIMECOP_IN_USE`, "TRUE"
35+
tb.Setenv(key, value)
5136
}
5237

53-
func travelByDuration(tb testing.TB, d time.Duration) {
38+
func travelByDuration(tb testing.TB, d time.Duration, freeze bool) {
5439
tb.Helper()
55-
travelByTime(tb, internal.GetTime().Add(d))
40+
travelByTime(tb, internal.GetTime().Add(d), freeze)
5641
}
5742

58-
func travelByTime(tb testing.TB, target time.Time) {
43+
func travelByTime(tb testing.TB, target time.Time, freeze bool) {
5944
tb.Helper()
60-
tb.Cleanup(internal.SetTime(target))
45+
tb.Cleanup(internal.SetTime(target, freeze))
46+
}
47+
48+
// Freeze instruct travel to freeze the time until the first time reading on the clock.
49+
func Freeze() TravelOption {
50+
return fnTravelOption(func(o *option) {
51+
o.Freeze = true
52+
})
6153
}

clock/timecop/timecop_test.go

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313

1414
var rnd = random.New(random.CryptoSeed{})
1515

16-
func TestSetFlowOfTime_invalidMultiplier(t *testing.T) {
16+
func TestSetSpeed(t *testing.T) {
1717
t.Run("on zero", func(t *testing.T) {
1818
dtb := &doubles.TB{}
1919
defer dtb.Finish()
@@ -30,6 +30,21 @@ func TestSetFlowOfTime_invalidMultiplier(t *testing.T) {
3030
})
3131
assert.True(t, dtb.IsFailed)
3232
})
33+
t.Run("on positive value", func(t *testing.T) {
34+
timecop.SetSpeed(t, 10000000)
35+
s := clock.TimeNow()
36+
time.Sleep(time.Millisecond)
37+
e := clock.TimeNow()
38+
assert.True(t, time.Hour < e.Sub(s))
39+
})
40+
t.Run("on frozen time SetSpeed don't start the time", func(t *testing.T) {
41+
now := time.Now()
42+
timecop.Travel(t, now, timecop.Freeze())
43+
timecop.SetSpeed(t, rnd.Float64())
44+
time.Sleep(time.Microsecond)
45+
got := clock.TimeNow()
46+
assert.True(t, now.Equal(got))
47+
})
3348
}
3449

3550
const buffer = 500 * time.Millisecond
@@ -85,27 +100,37 @@ func TestTravel_timeTime(t *testing.T) {
85100
assert.Equal(t, hour, got.Hour())
86101
assert.Equal(t, minute, got.Minute())
87102
assert.True(t, second-1 <= got.Second() && got.Second() <= second+1)
88-
assert.True(t, nano-100 <= got.Nanosecond() && got.Nanosecond() <= nano+3000)
89-
})
90-
}
91-
92-
func TestTravelTo(t *testing.T) {
93-
t.Run("on no travel", func(t *testing.T) {
94-
t1 := time.Now()
95-
t2 := clock.TimeNow()
96-
assert.True(t, t1.Equal(t2) || t1.Before(t2))
103+
assert.True(t, nano-int(buffer) <= got.Nanosecond() && got.Nanosecond() <= nano+int(buffer))
97104
})
98-
t.Run("on travelling", func(t *testing.T) {
105+
t.Run("on travel with freeze", func(t *testing.T) {
99106
now := time.Now()
100107
var (
101-
year = now.Year()
102-
month = now.Month()
103-
day = now.Day() + rnd.IntB(1, 3)
108+
year = rnd.IntB(0, now.Year())
109+
month = time.Month(rnd.IntB(1, 12))
110+
day = rnd.IntB(1, 20)
111+
hour = rnd.IntB(1, 23)
112+
minute = rnd.IntB(1, 59)
113+
second = rnd.IntB(1, 59)
114+
nano = rnd.IntB(1, int(time.Microsecond-1))
104115
)
105-
timecop.TravelTo(t, year, month, day)
116+
date := time.Date(year, month, day, hour, minute, second, nano, time.Local)
117+
timecop.Travel(t, date, timecop.Freeze())
118+
time.Sleep(time.Millisecond)
106119
got := clock.TimeNow()
107-
assert.Equal(t, year, got.Year())
108-
assert.Equal(t, month, got.Month())
109-
assert.Equal(t, day, got.Day())
120+
assert.True(t, date.Equal(got))
121+
122+
assert.EventuallyWithin(time.Second).Assert(t, func(it assert.It) {
123+
it.Must.False(date.Equal(clock.TimeNow()))
124+
})
125+
})
126+
}
127+
128+
func TestTravel_cleanup(t *testing.T) {
129+
date := time.Now().AddDate(-10, 0, 0)
130+
t.Run("", func(t *testing.T) {
131+
timecop.Travel(t, date, timecop.Freeze())
132+
assert.Equal(t, date.Year(), clock.TimeNow().Year())
110133
})
134+
const msg = "was not expected that timecop travel leak out from the sub test"
135+
assert.NotEqual(t, date.Year(), clock.TimeNow().Year(), msg)
111136
}

0 commit comments

Comments
 (0)