In this post we will review how to test time based GO code without using time.Sleep.
Many applications include logic that depends on time. For example, a cache item expires after a minute, a task runs every hour, or a request is rejected after a timeout. The simple method to test such logic is to wait for the required time. However this causes the tests to be slow, and in some cases unstable.
The Sleep Problem
Let's assume we have a token that is valid for one minute:
type Token struct {
createdTime time.Time
}
func (t *Token) Expired() bool {
return time.Since(t.createdTime) >= time.Minute
}
A test for this code can use time.Sleep:
func TestTokenExpired(t *testing.T) {
token := &Token{
createdTime: time.Now(),
}
time.Sleep(time.Minute)
if !token.Expired() {
t.Fatal("token should be expired")
}
}
The test works, but it takes at least one minute. If we have multiple tests with hours or days based logic, waiting for the real time is not an option.
Using shorter durations only for the test is also problematic. A test that sleeps for ten milliseconds might fail when the machine or CI is under load. Increasing the sleep duration makes it more stable, but also makes the test slower.
Create NowTime
The solution is to avoid reading the current time directly from the business logic. For this purpose I've created the NowTime interface:
type NowTime interface {
Now() time.Time
NowPointer() *time.Time
}
The production implementation returns the real time:
type NowTimeImpl struct {
}
func ProduceNowTimeImpl() *NowTimeImpl {
return &NowTimeImpl{}
}
func (n *NowTimeImpl) Now() time.Time {
return time.Now()
}
func (n *NowTimeImpl) NowPointer() *time.Time {
now := n.Now()
return &now
}
NowPointer is useful since the GO time package returns time.Time and in many structs we keep a *time.Time.
Use NowTime
The token receives NowTime and no longer calls time.Now() directly:
type Token struct {
createdTime *time.Time
nowTime NowTime
}
func NewToken(
nowTime NowTime,
) *Token {
return &Token{
createdTime: nowTime.NowPointer(),
nowTime: nowTime,
}
}
func (t *Token) Expired() bool {
return t.nowTime.Now().Sub(*t.createdTime) >= time.Minute
}
In production we create the token with the real implementation:
token := NewToken(ProduceNowTimeImpl())
The application behavior did not change, but the source of the current time can now be replaced in a test.
Create NowTime Stub
The test implementation stores a fake time that can be changed without waiting:
type NowTimeStub struct {
fakeTime *time.Time
}
func ProduceNowTimeStub() *NowTimeStub {
return &NowTimeStub{}
}
func (n *NowTimeStub) SetFakeTime(
fakeTime *time.Time,
) {
n.fakeTime = fakeTime
}
func (n *NowTimeStub) Now() time.Time {
if n.fakeTime == nil {
return time.Now()
}
return *n.fakeTime
}
func (n *NowTimeStub) NowPointer() *time.Time {
now := n.Now()
return &now
}
func (n *NowTimeStub) IncrementFakeTime(
duration time.Duration,
) {
timestamp := n.fakeTime.Add(duration)
n.fakeTime = ×tamp
}
Fake Time Test
The test uses NowTimeStub and controls the current time:
func TestTokenExpired(t *testing.T) {
startTime := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
nowTime := ProduceNowTimeStub()
nowTime.SetFakeTime(&startTime)
token := NewToken(nowTime)
if token.Expired() {
t.Fatal("new token should not be expired")
}
nowTime.IncrementFakeTime(59 * time.Second)
if token.Expired() {
t.Fatal("token should still be valid")
}
nowTime.IncrementFakeTime(time.Second)
if !token.Expired() {
t.Fatal("token should be expired")
}
}
The test moves one minute forward immediately. It does not sleep, so it runs fast and always gets the same result. The same NowTimeStub can be supplied to multiple components, so the full application moves on the same fake timeline.
Timers and Tickers
Replacing time.Now is enough for expiration and elapsed time calculations. Code that uses time.NewTimer, time.After, or time.NewTicker requires more work, since these functions also wait for the real time.
A simple approach is to keep the timer outside the business logic. The timer triggers an operation, while the operation itself is tested directly with the fake clock. If testing the scheduling code is also required, the clock abstraction can provide timer and ticker functions, or we can use an existing fake clock library.
Avoid Time Comparison Problems
Tests should use a fixed start time instead of time.Now. This makes failures reproducible and prevents the expected values from changing on each run.
It is also recommended to use UTC in tests. Local time might include daylight saving changes, where adding a day and adding 24 hours do not always provide the same result.
For duration based logic, compare durations. For calendar based logic, such as the next day or next month, use time.AddDate and test the relevant timezone explicitly.
Final Note
Sleeping in a test is sometimes required when testing integration with a real external component, but it should not be the default method for testing application logic.
By injecting the current time or a clock, we can test minutes, days, and expiration boundaries in a few milliseconds. The tests become faster, stable, and much easier to reproduce.
No comments:
Post a Comment