Skip to content

Commit

Permalink
add the ability to freeze time temporary
Browse files Browse the repository at this point in the history
  • Loading branch information
adamluzsi committed Aug 12, 2022
1 parent 0311148 commit 8101b0f
Show file tree
Hide file tree
Showing 8 changed files with 255 additions and 64 deletions.
2 changes: 1 addition & 1 deletion clock/Clock.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func After(d time.Duration) <-chan time.Time {
wait:
for {
select {
case <-internal.Listen(): // FIXME: flaky behaviour with time travelling
case <-internal.Listen():
continue wait
case <-time.After(internal.RemainingDuration(startedAt, d)):
break wait
Expand Down
78 changes: 78 additions & 0 deletions clock/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Clock and Timecop

## DESCRIPTION

Package providing "time travel" and "time scaling" capabilities,
making it simple to test time-dependent code.

## INSTALL

```sh
go get -u github.com/adamluzsi/testcase
```

## FEATURES

- Freeze time to a specific point.
- Travel back to a specific time, but allow time to continue moving forward.
- Scale time by a given scaling factor will cause the time to move at an accelerated pace.
- No dependencies other than the stdlib
- Nested calls to timecop.Travel is supported
- Works with any regular Go projects

## USAGE

```go
package main

import (
"github.com/adamluzsi/testcase/assert"
"github.com/adamluzsi/testcase/clock"
"github.com/adamluzsi/testcase/clock/timecop"
"testing"
"time"
)

func Test(t *testing.T) {
type Entity struct {
CreatedAt time.Time
}

MyFunc := func() Entity {
return Entity{
CreatedAt: clock.TimeNow(),
}
}

expected := Entity{
CreatedAt: clock.TimeNow(),
}

timecop.Travel(t, expected.CreatedAt, timecop.Freeze())

assert.Equal(t, expected, MyFunc())
}
```

Time travelling is undone as part of the test's teardown.

### timecop.Travel + timecop.Freeze

The Freeze option causes the observed time to stop until the first time reading event.

### timecop.SetSpeed

Let's say you want to test a "live" integration wherein entire days could pass by
in minutes while you're able to simulate "real" activity. For example, one such use case
is being able to test reports and invoices that run in 30-day cycles in very little time while also
simulating activity via subsequent calls to your application.

```go
timecop.SetSpeed(t, 1000) // accelerate speed by 1000x times from now on.
<-clock.After(time.Hour) // takes only 1/1000 time to finish, not an hour.
clock.Sleep(time.Hour) // same
```

## References

The package was inspired by [travisjeffery' timecop project](https://github.com/travisjeffery/timecop).
38 changes: 33 additions & 5 deletions clock/examples_test.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,50 @@
package clock_test

import (
"github.com/adamluzsi/testcase/assert"
"github.com/adamluzsi/testcase/clock"
"github.com/adamluzsi/testcase/clock/timecop"
"testing"
"time"
)

func ExampleTimeNow() {
_ = clock.TimeNow() // now
func ExampleTimeNow_freeze() {
var tb testing.TB

type Entity struct {
CreatedAt time.Time
}

MyFunc := func() Entity {
return Entity{
CreatedAt: clock.TimeNow(),
}
}

expected := Entity{
CreatedAt: clock.TimeNow(),
}

timecop.Travel(tb, expected.CreatedAt, timecop.Freeze())

assert.Equal(tb, expected, MyFunc())
}

func ExampleTimeNow_withTravelByDuration() {
var tb testing.TB
timecop.Travel(tb, time.Hour)

_ = clock.TimeNow() // now
timecop.Travel(tb, time.Hour)
_ = clock.TimeNow() // now + 1 hour
}

func ExampleTimeNow_withTravelByDate() {
var tb testing.TB

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

func ExampleAfter() {
Expand Down
29 changes: 20 additions & 9 deletions clock/internal/chronos.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,41 +16,48 @@ var chrono struct {
Altered bool
SetAt time.Time
When time.Time
Freeze bool
}
Speed float64
}

func SetSpeed(s float64) func() {
defer notify()
defer lock()()
setTime(time.Now())
freeze := chrono.Timeline.Freeze
td := setTime(getTime(), freeze)
og := chrono.Speed
chrono.Speed = s
return func() {
defer notify()
defer lock()()
chrono.Speed = og
td()
}
}

func SetTime(target time.Time) func() {
func SetTime(target time.Time, freeze bool) func() {
defer notify()
defer lock()()
return setTime(target)
td := setTime(target, freeze)
return func() {
defer notify()
defer lock()()
td()
}
}

func setTime(target time.Time) func() {
func setTime(target time.Time, freeze bool) func() {
og := chrono.Timeline
n := chrono.Timeline
n.Altered = true
n.SetAt = time.Now()
n.When = target
chrono.Timeline = n
return func() {
defer notify()
defer lock()()
chrono.Timeline = og
if freeze {
n.Freeze = true
}
chrono.Timeline = n
return func() { chrono.Timeline = og }
}

func RemainingDuration(from time.Time, d time.Duration) time.Duration {
Expand All @@ -74,6 +81,10 @@ func getTime() time.Time {
if !chrono.Timeline.Altered {
return now
}
if chrono.Timeline.Freeze {
chrono.Timeline.Freeze = false
chrono.Timeline.SetAt = now
}
delta := now.Sub(chrono.Timeline.SetAt)
delta = time.Duration(float64(delta) * chrono.Speed)
return chrono.Timeline.When.Add(delta)
Expand Down
21 changes: 21 additions & 0 deletions clock/timecop/opts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package timecop

type TravelOption interface {
configure(*option)
}

func toOption(tos []TravelOption) option {
var o option
for _, opt := range tos {
opt.configure(&o)
}
return o
}

type fnTravelOption func(*option)

func (fn fnTravelOption) configure(o *option) { fn(o) }

type option struct {
Freeze bool
}
42 changes: 17 additions & 25 deletions clock/timecop/timecop.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,18 @@ import (
"time"
)

func Travel[D time.Duration | time.Time](tb testing.TB, d D) {
func Travel[D time.Duration | time.Time](tb testing.TB, d D, tos ...TravelOption) {
tb.Helper()
guardAgainstParallel(tb)
opt := toOption(tos)
switch d := any(d).(type) {
case time.Duration:
travelByDuration(tb, d)
travelByDuration(tb, d, opt.Freeze)
case time.Time:
travelByTime(tb, d)
travelByTime(tb, d, opt.Freeze)
}
}

func TravelTo[M int | time.Month](tb testing.TB, year int, month M, day int) {
tb.Helper()
guardAgainstParallel(tb)
now := internal.GetTime()
tb.Cleanup(internal.SetTime(time.Date(
year,
time.Month(month),
day,
now.Hour(),
now.Minute(),
now.Second(),
now.Nanosecond(),
now.Location(),
)))
}

func SetSpeed(tb testing.TB, multiplier float64) {
tb.Helper()
guardAgainstParallel(tb)
Expand All @@ -45,17 +30,24 @@ func SetSpeed(tb testing.TB, multiplier float64) {
// guardAgainstParallel
// is a hack that ensures that there was no testing.T.Parallel() used in the test.
func guardAgainstParallel(tb testing.TB) {
const key = `TEST_CASE_CLOC_IN_USE`
tb.Helper()
tb.Setenv(key, "TRUE")
const key, value = `TEST_CASE_TIMECOP_IN_USE`, "TRUE"
tb.Setenv(key, value)
}

func travelByDuration(tb testing.TB, d time.Duration) {
func travelByDuration(tb testing.TB, d time.Duration, freeze bool) {
tb.Helper()
travelByTime(tb, internal.GetTime().Add(d))
travelByTime(tb, internal.GetTime().Add(d), freeze)
}

func travelByTime(tb testing.TB, target time.Time) {
func travelByTime(tb testing.TB, target time.Time, freeze bool) {
tb.Helper()
tb.Cleanup(internal.SetTime(target))
tb.Cleanup(internal.SetTime(target, freeze))
}

// Freeze instruct travel to freeze the time until the first time reading on the clock.
func Freeze() TravelOption {
return fnTravelOption(func(o *option) {
o.Freeze = true
})
}
61 changes: 43 additions & 18 deletions clock/timecop/timecop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

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

func TestSetFlowOfTime_invalidMultiplier(t *testing.T) {
func TestSetSpeed(t *testing.T) {
t.Run("on zero", func(t *testing.T) {
dtb := &doubles.TB{}
defer dtb.Finish()
Expand All @@ -30,6 +30,21 @@ func TestSetFlowOfTime_invalidMultiplier(t *testing.T) {
})
assert.True(t, dtb.IsFailed)
})
t.Run("on positive value", func(t *testing.T) {
timecop.SetSpeed(t, 10000000)
s := clock.TimeNow()
time.Sleep(time.Millisecond)
e := clock.TimeNow()
assert.True(t, time.Hour < e.Sub(s))
})
t.Run("on frozen time SetSpeed don't start the time", func(t *testing.T) {
now := time.Now()
timecop.Travel(t, now, timecop.Freeze())
timecop.SetSpeed(t, rnd.Float64())
time.Sleep(time.Microsecond)
got := clock.TimeNow()
assert.True(t, now.Equal(got))
})
}

const buffer = 500 * time.Millisecond
Expand Down Expand Up @@ -85,27 +100,37 @@ func TestTravel_timeTime(t *testing.T) {
assert.Equal(t, hour, got.Hour())
assert.Equal(t, minute, got.Minute())
assert.True(t, second-1 <= got.Second() && got.Second() <= second+1)
assert.True(t, nano-100 <= got.Nanosecond() && got.Nanosecond() <= nano+3000)
})
}

func TestTravelTo(t *testing.T) {
t.Run("on no travel", func(t *testing.T) {
t1 := time.Now()
t2 := clock.TimeNow()
assert.True(t, t1.Equal(t2) || t1.Before(t2))
assert.True(t, nano-int(buffer) <= got.Nanosecond() && got.Nanosecond() <= nano+int(buffer))
})
t.Run("on travelling", func(t *testing.T) {
t.Run("on travel with freeze", func(t *testing.T) {
now := time.Now()
var (
year = now.Year()
month = now.Month()
day = now.Day() + rnd.IntB(1, 3)
year = rnd.IntB(0, now.Year())
month = time.Month(rnd.IntB(1, 12))
day = rnd.IntB(1, 20)
hour = rnd.IntB(1, 23)
minute = rnd.IntB(1, 59)
second = rnd.IntB(1, 59)
nano = rnd.IntB(1, int(time.Microsecond-1))
)
timecop.TravelTo(t, year, month, day)
date := time.Date(year, month, day, hour, minute, second, nano, time.Local)
timecop.Travel(t, date, timecop.Freeze())
time.Sleep(time.Millisecond)
got := clock.TimeNow()
assert.Equal(t, year, got.Year())
assert.Equal(t, month, got.Month())
assert.Equal(t, day, got.Day())
assert.True(t, date.Equal(got))

assert.EventuallyWithin(time.Second).Assert(t, func(it assert.It) {
it.Must.False(date.Equal(clock.TimeNow()))
})
})
}

func TestTravel_cleanup(t *testing.T) {
date := time.Now().AddDate(-10, 0, 0)
t.Run("", func(t *testing.T) {
timecop.Travel(t, date, timecop.Freeze())
assert.Equal(t, date.Year(), clock.TimeNow().Year())
})
const msg = "was not expected that timecop travel leak out from the sub test"
assert.NotEqual(t, date.Year(), clock.TimeNow().Year(), msg)
}
Loading

0 comments on commit 8101b0f

Please sign in to comment.