Deterministic Testing — Tasks and Exercises¶
A progression from easy rewrites to architectural refactors. Each task includes a starting point, a target, and a hint. Solutions are sketched at the end.
Task 1 — Rewrite a sleep-based test¶
Starting code¶
func TestIncrement(t *testing.T) {
var c int
go func() { c++ }()
time.Sleep(10 * time.Millisecond)
if c != 1 {
t.Fatal("expected 1")
}
}
Target¶
Rewrite without time.Sleep. The test must pass under go test -race -count=100.
Hint¶
Use a chan struct{} to signal completion.
Task 2 — Rewrite for N goroutines¶
Starting code¶
func TestSum(t *testing.T) {
var sum int64
for i := 0; i < 100; i++ {
go func(v int) {
atomic.AddInt64(&sum, int64(v))
}(i)
}
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt64(&sum); got != 4950 {
t.Fatalf("got %d want 4950", got)
}
}
Target¶
Use sync.WaitGroup so the test never sleeps and passes under -race -count=200.
Task 3 — Drain a pipeline¶
Starting code¶
func TestPipeline(t *testing.T) {
in := make(chan int)
out := square(in)
go func() {
for i := 1; i <= 5; i++ {
in <- i
}
close(in)
}()
time.Sleep(20 * time.Millisecond)
var got []int
for {
select {
case v := <-out:
got = append(got, v)
default:
}
}
// assertion never reached because the loop never exits
}
Target¶
Use for v := range out to drain until the pipeline closes its output. No sleep.
Hint¶
Make sure square closes out when in closes.
Task 4 — Test a TTL cache with a fake clock¶
Starting code¶
type Cache struct {
items map[string]item
}
type item struct {
v string
exp time.Time
}
func (c *Cache) Set(k, v string, ttl time.Duration) {
c.items[k] = item{v, time.Now().Add(ttl)}
}
func (c *Cache) Get(k string) (string, bool) {
it, ok := c.items[k]
if !ok || time.Now().After(it.exp) {
return "", false
}
return it.v, true
}
Target¶
- Refactor
Cacheto accept aClockinterface. - Write a test that uses
clockwork.NewFakeClockto verify expiration at the exact boundary. - The test must run in under 1 millisecond.
Hint¶
Define type Clock interface { Now() time.Time }. Inject via constructor.
Task 5 — Test a retry-with-backoff using synctest¶
Starting code¶
func Retry(fn func() error, max int, base time.Duration) error {
delay := base
for i := 0; i < max; i++ {
if err := fn(); err == nil {
return nil
}
time.Sleep(delay)
delay *= 2
}
return errors.New("max retries")
}
Target¶
Write a test (Go 1.24+) that:
- Uses
testing/synctest. - Confirms
fnis called exactly N times when configured. - Confirms virtual elapsed time matches the expected backoff sum.
- Runs in under 1 millisecond of wall clock.
Hint¶
Inside synctest.Run, time.Sleep returns immediately in wall clock but advances virtual time.
Task 6 — Build a worker pool test¶
Target¶
Write a WorkerPool with these methods: - New(size int) *Pool - Submit(task func()) - Close() — refuse new submissions, drain queue, wait for workers to exit.
Write tests that verify: - 1000 tasks submitted, all execute. - After Close, Submit panics or returns an error. - After Close returns, no goroutines are leaked (use goleak).
The test must pass under go test -race -count=50.
Task 7 — Test a rate limiter¶
Starting code¶
type Limiter struct {
rate int
last time.Time
tokens int
}
func (l *Limiter) Allow() bool {
now := time.Now()
elapsed := now.Sub(l.last)
l.tokens += int(elapsed.Seconds()) * l.rate
if l.tokens > l.rate { l.tokens = l.rate }
l.last = now
if l.tokens > 0 {
l.tokens--
return true
}
return false
}
Target¶
- Refactor to use injected
Clock. - Write a test that exercises: empty bucket → wait → refilled → drained.
- All assertions on exact
Allowboundaries.
Task 8 — Test cancellation¶
Starting code¶
func Process(ctx context.Context, work <-chan Task) {
for {
select {
case t := <-work:
t.Run()
case <-ctx.Done():
return
}
}
}
Target¶
Write tests that: 1. Verify Process exits when ctx is cancelled. 2. Verify Process does not exit while tasks are pending and context is alive. 3. No time.Sleep. The exit assertion uses a done channel signal.
Task 9 — Test a state machine¶
Starting code¶
A traffic light state machine: Red → Green → Yellow → Red, transitioning every 10 seconds.
Target¶
- Implement using a goroutine and a ticker.
- Inject a clock.
- Write tests that drive: at t=0 Red, at t=10s Green, at t=20s Yellow, at t=30s Red.
- Use
synctestto make the test instant.
Task 10 — Repeatable property test¶
Target¶
Use pgregory.net/rapid to write a property test for a concurrent FIFO queue: enqueueing N items in random order from K goroutines, then dequeueing all, produces all enqueued items (order may differ but set must match).
Run under -race -count=10. On failure, the seed must be logged and the failure reproducible with -seed=N.
Task 11 — Find the synthetic flake¶
Starting code (a deliberately flaky test)¶
func TestThing(t *testing.T) {
s := New()
var ready bool
go func() {
s.Init()
ready = true
}()
for !ready {
runtime.Gosched()
}
if !s.IsReady() {
t.Fatal("not ready")
}
}
Target¶
- Identify every race / determinism bug. There are at least three.
- Rewrite to be deterministic and race-free.
Task 12 — goleak integration¶
Target¶
For a package you own, add goleak.VerifyTestMain(m). Run the tests. If any leak, identify and fix. Document any legitimate background goroutines as goleak.IgnoreTopFunction with a comment.
Task 13 — -cpu sweep¶
Target¶
Pick a concurrent test you have written. Run:
If any combination fails, diagnose. If all pass, add this command to your CI as a nightly job.
Task 14 — Quiescent observation API¶
Target¶
Take a Worker type that consumes from a channel and processes tasks. Add a WaitIdle(ctx) method that returns when the worker's input is empty and it is currently blocked on receive (i.e., quiescent).
Test it: submit 5 tasks, call WaitIdle, assert all 5 finished.
Task 15 — Replay test¶
Target¶
You have a log file of a production incident: a sequence of events with timestamps. Convert it into a deterministic test that drives the system at virtual time, replaying the events, and asserts no goroutine leak or incorrect state.
Solution sketches¶
Task 1¶
func TestIncrement(t *testing.T) {
var c int
done := make(chan struct{})
go func() {
c++
close(done)
}()
<-done
if c != 1 {
t.Fatal("expected 1")
}
}
Task 2¶
func TestSum(t *testing.T) {
var sum int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(v int) {
defer wg.Done()
atomic.AddInt64(&sum, int64(v))
}(i)
}
wg.Wait()
if got := atomic.LoadInt64(&sum); got != 4950 {
t.Fatalf("got %d want 4950", got)
}
}
Task 3¶
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- v * v
}
}()
return out
}
func TestPipeline(t *testing.T) {
in := make(chan int)
out := square(in)
go func() {
defer close(in)
for i := 1; i <= 5; i++ {
in <- i
}
}()
var got []int
for v := range out {
got = append(got, v)
}
want := []int{1, 4, 9, 16, 25}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v want %v", got, want)
}
}
Task 4¶
type Clock interface { Now() time.Time }
type Cache struct {
clock Clock
items map[string]item
}
func NewCache(clock Clock) *Cache {
return &Cache{clock: clock, items: make(map[string]item)}
}
func (c *Cache) Set(k, v string, ttl time.Duration) {
c.items[k] = item{v, c.clock.Now().Add(ttl)}
}
func (c *Cache) Get(k string) (string, bool) {
it, ok := c.items[k]
if !ok || c.clock.Now().After(it.exp) {
return "", false
}
return it.v, true
}
func TestCacheTTL(t *testing.T) {
clk := clockwork.NewFakeClock()
c := NewCache(clk)
c.Set("k", "v", 10*time.Second)
if _, ok := c.Get("k"); !ok { t.Fatal("expected hit at t=0") }
clk.Advance(10*time.Second)
if _, ok := c.Get("k"); !ok { t.Fatal("expected hit at boundary") }
clk.Advance(1)
if _, ok := c.Get("k"); ok { t.Fatal("expected miss past boundary") }
}
Task 5¶
func TestRetry_Synctest(t *testing.T) {
synctest.Run(func() {
calls := 0
fn := func() error {
calls++
if calls < 3 { return errors.New("transient") }
return nil
}
start := time.Now()
if err := Retry(fn, 5, 100*time.Millisecond); err != nil {
t.Fatal(err)
}
if calls != 3 { t.Fatalf("calls=%d", calls) }
want := 300 * time.Millisecond
if elapsed := time.Since(start); elapsed != want {
t.Fatalf("virtual elapsed %v want %v", elapsed, want)
}
})
}
Task 6 (skeleton)¶
type Pool struct {
tasks chan func()
done chan struct{}
once sync.Once
wg sync.WaitGroup
}
func New(size int) *Pool {
p := &Pool{
tasks: make(chan func(), 64),
done: make(chan struct{}),
}
for i := 0; i < size; i++ {
p.wg.Add(1)
go p.worker()
}
return p
}
func (p *Pool) worker() {
defer p.wg.Done()
for t := range p.tasks { t() }
}
func (p *Pool) Submit(t func()) error {
select {
case <-p.done:
return errors.New("pool closed")
default:
p.tasks <- t
return nil
}
}
func (p *Pool) Close() {
p.once.Do(func() {
close(p.done)
close(p.tasks)
})
p.wg.Wait()
}
func TestPool(t *testing.T) {
p := New(4)
var done atomic.Int64
for i := 0; i < 1000; i++ {
if err := p.Submit(func() { done.Add(1) }); err != nil {
t.Fatal(err)
}
}
p.Close()
if got := done.Load(); got != 1000 {
t.Fatalf("got %d want 1000", got)
}
}
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
Task 7¶
Similar to Task 4 — inject clock, advance, assert.
Task 8¶
func TestProcess_Cancels(t *testing.T) {
work := make(chan Task)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
Process(ctx, work)
}()
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("did not exit after cancel")
}
}
Task 9 (skeleton)¶
type TrafficLight struct {
clock Clock
state atomic.Int32
}
func (l *TrafficLight) Run(ctx context.Context) {
t := l.clock.NewTicker(10*time.Second)
defer t.Stop()
for {
select {
case <-t.C():
l.next()
case <-ctx.Done():
return
}
}
}
Inside synctest.Run, start the goroutine, advance time, observe state transitions.
Task 10 (sketch)¶
func TestQueue_Property(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
items := rapid.SliceOfN(rapid.Int(), 1, 1000).Draw(rt, "items")
q := NewQueue()
var wg sync.WaitGroup
for _, v := range items {
wg.Add(1)
go func(v int) {
defer wg.Done()
q.Push(v)
}(v)
}
wg.Wait()
var got []int
for v, ok := q.Pop(); ok; v, ok = q.Pop() {
got = append(got, v)
}
// multiset equality
sort.Ints(got)
sort.Ints(items)
if !reflect.DeepEqual(got, items) {
rt.Fatalf("mismatch: got %v want %v", got, items)
}
})
}
Task 11¶
Bugs: (1) ready is read and written without synchronisation — data race. (2) for !ready { runtime.Gosched() } is a busy-wait, not deterministic. (3) Gosched is not a happens-before edge; the read may never see the write. Rewrite using a chan struct{}:
func TestThing(t *testing.T) {
s := New()
done := make(chan struct{})
go func() {
s.Init()
close(done)
}()
<-done
if !s.IsReady() {
t.Fatal("not ready")
}
}
Task 12, 13, 14, 15¶
Implementation-specific. Apply the patterns from the corresponding sections of middle.md and senior.md.
End of tasks.