1

Golang 1.24 provides synctest, which can create isolated environments with synctest.Run(f) called bubbles that have their own synthetic clock. This is really useful for testing my production code, which depends on UTC timestamps. However, it contains some goroutines that are triggered asynchronously in the background, and I need to actually wait for their result inside the synctest bubble before I can continue test execution.

How can I write a polling function that waits properly inside a synctest bubble? My current polling function uses time.NewTicker, but that doesn't seeom to integrate with synctest's synthetic clock. It does not seem to wait between polling cycles.

Simplified test code:

func TestTimingWithSynctest(t *testing.T) {
    synctest.Run(func() {
        // run other test code..
        
        // now I need to actually wait between polling cycles inside the bubble
        polling.Run(...)

        // run other test code again..
    })
}

My current polling function:

// Package polling provides utility functions for polling.
package polling

import (
    "context"
    "time"
)

// Run repeatedly calls action() until it returns true or an error,
// or until ctx is canceled or timeout is reached.
func Run(ctx context.Context, interval, timeout time.Duration,
    action func() (bool, error)) error {
    if timeout > 0 {
        var cancel context.CancelFunc
        // create child context that combines passed ctx with timeout
        // then either an cancellation from outside or the timeout cancels polling
        ctx, cancel = context.WithTimeout(ctx, timeout)
        defer cancel()
    }

    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        done, err := action()
        if err != nil {
            return err
        }
        if done {
            return nil
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-ticker.C:
            // continue looping
        }
    }
}
2
  • 1
    "doesn't seem to integrate with synctest's synthetic clock" - this appears to work as expected (the time advances by timeout in this test). Can you please add more detail (ideally a fully working example with an explanation of what you expect vs the current result). "to actually wait" this should happen if your routines are not "durably blocked" (but as you don't include this detail it's difficult to comment) - example with external ticker. Commented Nov 9 at 19:07
  • I think I worded my question bad. What I wanted is simply a working polling function inside the bubble. What you provided here is exactly what I wanted, I wasn't aware that if I simply declare the ticker outside of the synctest scope it will work normally when used inside it. If you write it up as an answer I will accept it. Commented Nov 10 at 9:05

1 Answer 1

2

Answering based on the comments.

Golang 1.24 provides synctest,

Note that in 1.24 this is behind GOEXPERIMENT=synctest and has a slightly different API (I'll use the API relesased in 1.25 here).

How can I write a polling function that waits properly inside a synctest bubble

This depends upon what you mean by "waits properly". In many cases you would want synctest to simulate the wait (so your test completes quickly!). To demonstrate this lets turn your example Run function into something that compiles:

func Run(ctx context.Context, interval, timeout time.Duration,
    action func() (bool, error)) error {
    if timeout > 0 {
        var cancel context.CancelFunc
        // create child context that combines passed ctx with timeout
        // then either an cancellation from outside or the timeout cancels polling
        ctx, cancel = context.WithTimeout(ctx, timeout)
        defer cancel()
    }

    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        done, err := action()
        if err != nil {
            return err
        }
        if done {
            return nil
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-ticker.C:
            // continue looping
        }
    }
}

We could now test this with (playground):

func TestTimingWithSynctest(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        s := time.Now()
        t.Log(s)
        // now I need to actually wait between polling cycles inside the bubble
        Run(context.Background(), time.Minute, 5*time.Minute, func() (bool, error) { fmt.Println(time.Now()); return false, nil })

        d := time.Now()
        t.Log(d, d.Sub(s))
    })
}
=== RUN   TestTimingWithSynctest
    prog_test.go:47: 2000-01-01 00:00:00 +0000 UTC
2000-01-01 00:00:00 +0000 UTC
...
2000-01-01 00:04:00 +0000 UTC
2000-01-01 00:05:00 +0000 UTC
    prog_test.go:52: 2000-01-01 00:05:00 +0000 UTC 5m0s
--- PASS: TestTimingWithSynctest (0.00s)

This test will complete very quickly ("0.00s"), but if we look at the output we can see that the simulated time has moved on.

In many cases testing in this way is all you need. The time "bubble" enables you to check the interaction of goroutines that watch the clock (e.g. does the call time out). synctest advances time when every goroutine in the bubble is durably blocked, that is "it is blocked and can only be unblocked by another goroutine in the same bubble".

If you want "to actually wait" you can do this by using a goroutine running outside of the "bubble", i.e. (playground):

func TestTimingWithSynctest(t *testing.T) {
    outsideTicker := time.NewTicker(time.Second)
    defer outsideTicker.Stop()

    synctest.Test(t, func(t *testing.T) {
        s := time.Now()
        t.Log("start", s)
        // now I need to actually wait between polling cycles inside the bubble
        Run(context.Background(), time.Minute, 5*time.Minute, func() (bool, error) {
            <-outsideTicker.C // Wait for ticker that is outside the bubble
            t.Log("end", time.Now())
            return false, nil
        })

        d := time.Now()
        t.Log(d, d.Sub(s))
    })
}
=== RUN   TestTimingWithSynctest
    prog_test.go:49: start 2000-01-01 00:00:00 +0000 UTC
    prog_test.go:53: end 2000-01-01 00:00:00 +0000 UTC
    prog_test.go:53: end 2000-01-01 00:01:00 +0000 UTC
    prog_test.go:53: end 2000-01-01 00:02:00 +0000 UTC
    prog_test.go:53: end 2000-01-01 00:03:00 +0000 UTC
    prog_test.go:53: end 2000-01-01 00:04:00 +0000 UTC
    prog_test.go:53: end 2000-01-01 00:05:00 +0000 UTC
    prog_test.go:58: 2000-01-01 00:05:00 +0000 UTC 5m0s
--- PASS: TestTimingWithSynctest (6.00s)

This test takes a bit longer to run ("6.00s") because it waits for outsideTicker (note that the runtime may not be what you expect because real time is only used when waiting on outsideTicker and that only happens 6 times).

Please be sure that using a "real" timer is actually neccessary (it will slow down your tests). Your question does not provide sufficient context to establish whether this is actually needed.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.