zoobzio December 31, 2023 Edit this page

Testing Patterns

This guide covers common testing patterns for time-dependent code.

Basic Time Control

The fundamental pattern: create a FakeClock, advance time, verify behavior.

func TestTimeoutBehavior(t *testing.T) {
    clock := clockz.NewFakeClockAt(time.Now())

    done := make(chan bool)
    go func() {
        <-clock.After(5 * time.Minute)
        done <- true
    }()

    // Verify nothing happens before timeout
    clock.Advance(4 * time.Minute)
    select {
    case <-done:
        t.Fatal("Should not timeout yet")
    default:
        // Expected
    }

    // Trigger timeout
    clock.Advance(1 * time.Minute)
    select {
    case <-done:
        // Success
    case <-time.After(100 * time.Millisecond):
        t.Fatal("Should have timed out")
    }
}

Testing Concurrent Timers

When testing multiple timers, they fire in chronological order as time advances:

func TestConcurrentTimers(t *testing.T) {
    clock := clockz.NewFakeClockAt(time.Now())
    results := make(chan int, 3)

    // Start timers with different durations
    go func() {
        <-clock.After(1 * time.Second)
        results <- 1
    }()

    go func() {
        <-clock.After(2 * time.Second)
        results <- 2
    }()

    go func() {
        <-clock.After(3 * time.Second)
        results <- 3
    }()

    // Allow goroutines to register their timers
    time.Sleep(10 * time.Millisecond)

    // Advance past first two timers
    clock.Advance(2 * time.Second)

    if val := <-results; val != 1 {
        t.Errorf("Expected 1, got %d", val)
    }
    if val := <-results; val != 2 {
        t.Errorf("Expected 2, got %d", val)
    }

    // Advance past third timer
    clock.Advance(1 * time.Second)
    if val := <-results; val != 3 {
        t.Errorf("Expected 3, got %d", val)
    }
}

Context Timeout Testing

Test context deadlines without real waits:

func TestContextDeadline(t *testing.T) {
    clock := clockz.NewFakeClockAt(time.Now())

    ctx, cancel := clock.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    done := make(chan error)
    go func() {
        <-ctx.Done()
        done <- ctx.Err()
    }()

    // Advance to trigger timeout
    clock.Advance(5 * time.Second)

    err := <-done
    if err != context.DeadlineExceeded {
        t.Errorf("Expected DeadlineExceeded, got %v", err)
    }
}

Testing Periodic Tasks

Test ticker-based periodic work:

func TestPeriodicTask(t *testing.T) {
    clock := clockz.NewFakeClockAt(time.Now())
    executions := 0

    ticker := clock.NewTicker(time.Minute)
    defer ticker.Stop()

    // Simulate 5 ticks
    go func() {
        for i := 0; i < 5; i++ {
            <-ticker.C()
            executions++
        }
    }()

    // Advance through 5 minutes
    for i := 0; i < 5; i++ {
        clock.Advance(time.Minute)
        time.Sleep(time.Millisecond) // Let goroutine process
    }

    if executions != 5 {
        t.Errorf("Expected 5 executions, got %d", executions)
    }
}

Synchronization with BlockUntilReady

When goroutines need time to set up their timers, use BlockUntilReady():

func TestWithSynchronization(t *testing.T) {
    clock := clockz.NewFakeClockAt(time.Now())

    done := make(chan bool)
    go func() {
        <-clock.After(5 * time.Second)
        done <- true
    }()

    // Wait for timer to be registered
    clock.BlockUntilReady()

    // Now safe to advance
    clock.Advance(5 * time.Second)

    select {
    case <-done:
        // Success
    case <-time.After(100 * time.Millisecond):
        t.Fatal("Timer did not fire")
    }
}

Testing Timer Reset

Verify timer reset behavior:

func TestTimerReset(t *testing.T) {
    clock := clockz.NewFakeClockAt(time.Now())

    timer := clock.NewTimer(5 * time.Second)
    defer timer.Stop()

    // Advance partway
    clock.Advance(3 * time.Second)

    // Reset extends from current time
    timer.Reset(5 * time.Second)

    // Original deadline (5s) has passed, but reset deadline (8s total) hasn't
    clock.Advance(3 * time.Second) // Now at 6s total

    select {
    case <-timer.C():
        t.Fatal("Timer should not have fired yet")
    default:
        // Expected - 2 more seconds until reset deadline
    }

    // Advance past reset deadline
    clock.Advance(2 * time.Second) // Now at 8s total

    select {
    case <-timer.C():
        // Success
    case <-time.After(100 * time.Millisecond):
        t.Fatal("Timer should have fired")
    }
}

Common Pitfalls

Pitfall: Forgetting to Stop Timers

// Bad - timer leaks
timer := clock.NewTimer(5 * time.Second)
// ... use timer

// Good - always stop
timer := clock.NewTimer(5 * time.Second)
defer timer.Stop()

Pitfall: Not Waiting for Goroutines

// Bad - race between goroutine setup and Advance
go func() {
    <-clock.After(time.Second)
}()
clock.Advance(time.Second) // May advance before timer is registered

// Good - allow setup time or use BlockUntilReady
go func() {
    <-clock.After(time.Second)
}()
clock.BlockUntilReady()
clock.Advance(time.Second)

Pitfall: Not Calling Context Cancel

// Bad - resources leak
ctx, _ := clock.WithTimeout(ctx, time.Second)

// Good - always call cancel
ctx, cancel := clock.WithTimeout(ctx, time.Second)
defer cancel()