Go 1.27.2 synctest: test a 30-second Sume poll loop in milliseconds

Test a Go poll loop for Sume video jobs with testing/synctest: 90 seconds of fake sleeps and a 20-minute deadline pass in under a second, no sockets.

5 min readSume
All posts

Use testing/synctest from the Go standard library. Inside synctest.Test, time.After and time.Sleep run on a fake clock, so a poll loop with a 30-second interval and a 20-minute deadline finishes in a few hundred milliseconds of real time. One rule matters: do not put a real socket in the bubble. Use a fake http.RoundTripper.

Go 1.27.2 shipped on 2026-10-08 (the release history lists it with go1.26.9, both with security fixes in net/http), which is a good moment to check that your tests run on the current toolchain. The package page says synctest.Test was added in Go 1.25.0.

What is the Sume poll loop being tested?

POST /v1/videos returns 202 with an id and a polling_url. You then GET that URL until the status is completed, failed, or cancelled, as the Video Generation guide describes. Note the spelling: on this surface the canceled state is cancelled with two l's, while the /v1/jobs routes use canceled. The guide suggests about 30 seconds between polls, because a video takes from 30 seconds to several minutes.

This is the function under test. It stops when the context ends, which stops the wait, not the job.

type Job struct {
	ID     string   `json:"id"`
	Status string   `json:"status"`
	URLs   []string `json:"unsigned_urls"`
}

func Poll(ctx context.Context, c *http.Client, url string, every time.Duration) (Job, error) {
	for {
		req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
		req.Header.Set("Authorization", "Bearer "+os.Getenv("SUME_API_KEY"))
		resp, err := c.Do(req)
		if err != nil {
			return Job{}, err
		}
		var j Job
		err = json.NewDecoder(resp.Body).Decode(&j)
		resp.Body.Close()
		if err != nil {
			return Job{}, err
		}
		switch j.Status {
		case "completed", "failed", "cancelled":
			return j, nil
		}
		select {
		case <-time.After(every):
		case <-ctx.Done():
			return Job{}, ctx.Err()
		}
	}
}

How does synctest make the clock fake?

The package page says time in a bubble only advances when every goroutine in the bubble is durably blocked, and that the clock starts at midnight UTC on 2000-01-01. A select waiting on time.After is durably blocked, so the clock jumps ahead. Network I/O is not durably blocked, and the page says a loopback connection prevents the bubble from becoming idle. That is why httptest.NewServer does not fit here, and a canned RoundTripper does.

What blocks a synctest bubble (read 2026-10-13)
Operation in the bubbleDurably blocked?Effect on the fake clock
time.After, time.Sleep, channel waitYesClock jumps to the next timer
Read from a network socketNoBubble never goes idle
sync.Mutex lock, system callNoBubble never goes idle

What does the test look like?

Start with a client that replies from a function. It reads no network, so it is safe in a bubble. Add the imports context, errors, io, net/http, strings, testing, testing/synctest, and time to the test file.

type roundTrip func(*http.Request) (*http.Response, error)

func (r roundTrip) RoundTrip(q *http.Request) (*http.Response, error) { return r(q) }

// fake returns a client whose nth reply body is reply(n). No sockets.
func fake(reply func(n int) string) *http.Client {
	n := 0
	return &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) {
		n++
		return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(reply(n)))}, nil
	})}
}

Two tests then cover the interesting paths: three in_progress replies cost exactly 90 seconds of fake time, and an endless pending stops at the 20-minute deadline with context.DeadlineExceeded. Run go test ./.... In the run for this post the two tests took 0.38 seconds.

func TestPollSleepsThreeTimes(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		c := fake(func(n int) string {
			if n < 4 {
				return `{"id":"job_1","status":"in_progress"}`
			}
			return `{"id":"job_1","status":"completed","unsigned_urls":["u"]}`
		})
		start := time.Now()
		j, err := Poll(context.Background(), c, "https://api.sume.com/v1/videos/job_1", 30*time.Second)
		if err != nil || j.Status != "completed" || time.Since(start) != 90*time.Second {
			t.Fatalf("%v %v %v", j, err, time.Since(start))
		}
	})
}

func TestPollStopsAtDeadline(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		c := fake(func(int) string { return `{"status":"pending"}` })
		ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
		defer cancel()
		_, err := Poll(ctx, c, "https://api.sume.com/v1/videos/job_1", 30*time.Second)
		if !errors.Is(err, context.DeadlineExceeded) {
			t.Fatal(err)
		}
	})
}

What can this not prove?

A fake transport proves your loop, not Sume. Keep one small test against the real API for the submit call, and see the error table in Errors and rate limits for the 429 and 402 branches. For 429, use the retry-after header when present, and do not retry a submit without an Idempotency-Key. Those branches can use the same fake client with a different status code.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume