Problems with Time-Based Code & How Fake Clocks Solve Them

⚠️ Problems with Time-Based Code in Tests

When writing Go programs, we often rely on time-related functions like:

  • time.After
  • time.NewTicker
  • time.NewTimer
  • time.Sleep

These are useful for implementing timeouts, periodic tasks, and scheduled operations. However, they introduce several problems in tests:

1️⃣ Tests Become Slow

  • If a function waits 10 seconds before timing out, your test also takes 10 seconds.
  • This slows down CI/CD pipelines and developer productivity.

2️⃣ Flaky Tests (Intermittent Failures)

  • Real-world execution times fluctuate due to CPU load, network latency, and system clock drift.
  • Example: A timeout-based test might pass on a fast machine but fail on a slow one.

3️⃣ No Control Over Time

  • You can’t fast-forward time or pause execution.
  • Makes it hard to simulate timeouts, retries, and delays.

4️⃣ Hard to Test Race Conditions

  • Go routines using real time can interleave unpredictably, leading to non-deterministic tests.

✅ Solution: Using Fake Clocks in Go

To solve these problems, we use a fake clock instead of the real time package. Fake clocks allow us to:

Fast-forward time instead of waiting in real-time.

Ensure predictable timing behavior in tests.

Make tests run instantly, improving speed.

Simulate timeouts, delays, and retries deterministically.

🛠 Example 1: Testing a Timeout Without Waiting

🔹 Problem:

Let’s say we have a function that waits for an event but times out after 5 seconds if nothing happens.

package main

import (
    "fmt"
    "time"
)

func waitForEvent(timeout time.Duration) error {
    select {
    case <-time.After(timeout):
        return fmt.Errorf("timeout occurred")
    }
}

🔹 Issues in Tests:

  • Running this test would take 5 seconds!
  • If you had 10 tests, your test suite would take 50+ seconds to complete.

✅ Solution: Fake Clock

📌 Install the package:

go get github.com/benbjohnson/clock

📌 Rewriting the function using a Fake Clock:

package main

import (
    "fmt"
    "time"

    "github.com/benbjohnson/clock"
)

func waitForEventWithClock(clk clock.Clock, timeout time.Duration) error {
    select {
    case <-clk.After(timeout):
        return fmt.Errorf("timeout occurred")
    }
}

📌 Test using Fake Clock:

package main

import (
    "testing"
    "time"

    "github.com/benbjohnson/clock"
    "github.com/stretchr/testify/assert"
)

func TestWaitForEventTimeout(t *testing.T) {
    clk := clock.NewMock() // Create a mock clock

    // Run the function in a goroutine
    done := make(chan error)
    go func() {
        done <- waitForEventWithClock(clk, 5*time.Second)
    }()

    // Fast-forward time by 5 seconds
    clk.Add(5 * time.Second)

    // Verify the result
    assert.Equal(t, fmt.Errorf("timeout occurred"), <-done)
}

Test runs instantly! No need to wait for 5 seconds.

🛠 Example 2: Testing a Ticker Without Waiting

🔹 Problem:

A ticker prints “tick” every 2 seconds:

package main

import (
    "fmt"
    "time"
)

func startTicker() {
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()

    for t := range ticker.C {
        fmt.Println("Tick at:", t)
    }
}

🔹 Issues in Tests:

  • Takes 2 seconds per tickvery slow tests.
  • Hard to control timing.

✅ Solution: Fake Clock

📌 Rewriting the function using Fake Clock:

package main

import (
    "fmt"
    "time"

    "github.com/benbjohnson/clock"
)

func startTickerWithClock(clk clock.Clock) {
    ticker := clk.Ticker(2 * time.Second)
    defer ticker.Stop()

    for t := range ticker.C {
        fmt.Println("Tick at:", t)
    }
}

📌 Test using Fake Clock:

package main

import (
    "testing"
    "time"

    "github.com/benbjohnson/clock"
    "github.com/stretchr/testify/assert"
)

func TestTicker(t *testing.T) {
    clk := clock.NewMock() // Create a mock clock
    ticker := clk.Ticker(2 * time.Second)
    defer ticker.Stop()

    // Advance time by 2 seconds
    clk.Add(2 * time.Second)

    // Check if the ticker ticked
    select {
    case <-ticker.C:
        // Success: Ticker worked as expected
    default:
        t.Fatal("Ticker did not tick")
    }
}

Test runs instantly! We fast-forward time instead of waiting.

⏳ Summary

Problem Solution
Slow tests (timeouts/tickers) Use fake clocks to fast-forward time
Flaky tests (random failures) Control time manually for consistent behavior
Uncontrollable race conditions Deterministic execution using Fake Clocks
Hard to test retries Simulate retries instantly

Fake clocks make testing time-dependent code easy, fast, and reliable! 🚀

Here are some great resources to learn more about testing time-dependent code in Go using fake clocks:

📖 Articles & Blog Posts

  1. Using Fake Clocks in Go – A deep dive into the concept of fake clocks with Go code examples.
  2. Testing Time in Go – Explains different approaches to testing time-based code.
  3. Effective Testing for Go Applications – Discusses mock implementations in Go, including fake clocks.

📚 Official Documentation & Libraries

  1. Ben Johnson’s clock package – The most widely used Go library for fake clocks.
  2. Go time package documentation – Understanding the standard time package is essential before using fake clocks.

🎥 Video Tutorials

  1. Go Testing Techniques – GopherCon – Covers various testing strategies, including time-based testing.
  2. Mocking Time in Go – Tutorial – Hands-on implementation of fake clocks in Go.

Leave a Reply