Day 36 — Concurrent tests / synctest
Day 36 — Concurrent tests / synctest
Stage IV · ~3h (theory-heavy)
Goal: Test concurrent code without flakes: t.Parallel discipline, race detector in CI shape, timeout assertions, and synctest awareness for deterministic concurrency testing where available.
Flaky concurrent tests train teams to ignore red CI. Your job is deterministic failure when code is wrong—and green when code is right.
Why this day exists
Concurrent tests go wrong by:
- Sleeping “enough”
- Depending on scheduler luck
- Sharing global state across tests
- Forgetting
-race
- Deadlocking the test binary
You already have primitives (Days 19–28). Today is the test harness craft.
Theory 1 — What good concurrent tests prove
| Property | How to test |
|---|---|
| Race-freedom | go test -race |
| Cancel exits | assert return before deadline |
| Concurrency bound | atomic in-flight max |
| Correctness under load | many goroutines + final assert |
| No leak (coarse) | NumGoroutine settle / join API |
Prefer observable postconditions over “didn’t crash once.”
Theory 2 — t.Parallel rules
func TestA(t *testing.T) {
t.Parallel()
// ...
}Rules:
- No shared mutable globals without sync
- Package
TestMain/ suite setup must be parallel-safe
- Use subtests carefully—
t.Parallelin subtests aftert.Runscaffolding
- Parallel + time-sensitive sleeps ⇒ flakes
Pattern for subtests:
func TestAll(t *testing.T) {
t.Parallel()
cases := []string{"a", "b"}
for _, tc := range cases {
tc := tc
t.Run(tc, func(t *testing.T) {
t.Parallel()
// use tc
})
}
}Theory 3 — Never sleep for sync (almost)
Bad:
go doWork()
time.Sleep(100 * time.Millisecond)
assertDone()Better:
done := make(chan struct{})
go func() {
defer close(done)
doWork()
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timeout")
}Or block on errgroup.Wait / API that joins.
Sleep is OK only as a scheduler settle upper bound after proper joins—not as the join itself.
Theory 4 — Testing cancel
func TestCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
started := make(chan struct{})
errCh := make(chan error, 1)
go func() {
errCh <- engine.Run(ctx, slowItems(), func(ctx context.Context, it Item) error {
close(started)
<-ctx.Done()
return ctx.Err()
})
}()
<-started
cancel()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Run did not return")
}
}Theory 5 — Synctest awareness
Go’s testing ecosystem has been moving toward better deterministic concurrency testing (often discussed as synctest / bubble-style testing: control when goroutines advance, avoid real wall-clock flakiness).
What you should know on Go 1.26.x path
- Classical tools remain foundational:
-race, channels, ctx, errgroup,testingtimeouts (go test -timeout).
- Experimental / evolving helpers may allow running concurrent logic in a controlled scheduler bubble so tests don’t depend on real
time.Sleepdurations.
- For this volume: do not depend on unstable APIs as your only skill—learn the portable patterns above first.
- When your toolchain includes
testing/synctest(or successor docs), practice one example from official docs and note version requirements in your journal.
// Pseudocode / version-gated sketch — verify against your Go 1.26.x docs:
//
// synctest.Run(func() {
// // goroutines + fake time advancement
// })If the package is not available in your exact patch level, complete labs with classical techniques and write a short note: “synctest: checked docs on <date>.”
Theory 6 — go test flags for concurrency work
go test -race -count=20 ./...
go test -timeout 30s ./...
go test -parallel 4 ./...
go test -run TestCancel -vCI snippet:
go test -race -count=1 -timeout 5m ./...-count=1 disables cache; critical for flake hunting.
Theory 7 — Deadlocking tests
If a test hangs:
go test -timeout 5sto fail fast
- Check missing
Done/ channel receive
- Avoid waiting on locks held by the test goroutine
For interactive debug, SIGQUIT (where supported) dumps stacks.
Worked example — bound test
func TestWorkerLimit(t *testing.T) {
var (
current atomic.Int64
max atomic.Int64
)
items := make([]int, 50)
_, err := MapLimit(context.Background(), items, 5, func(ctx context.Context, v int) (int, error) {
c := current.Add(1)
for {
m := max.Load()
if c <= m || max.CompareAndSwap(m, c) {
break
}
}
time.Sleep(5 * time.Millisecond)
current.Add(-1)
return v, nil
})
if err != nil {
t.Fatal(err)
}
if max.Load() > 5 {
t.Fatalf("max in-flight %d", max.Load())
}
}Lab 1 — Flake then fix
Workspace: ~/lab/90daysofx/01-go/day36
- Write a deliberately flaky test using
Sleep.
- Run
-count=50to see intermittent failures (or force failure).
- Rewrite with channel/join.
- Prove green at
-count=50.
go test -race -count=50 ./...Lab 2 — Parallel suite hygiene
Create two tests that would race on a package-level map if both parallel. Show failure with -race, then isolate state (or protect) so t.Parallel is safe.
Lab 3 — Cancel + timeout tests
Port Day 29/30 engine tests: cancel and deadline paths with 2s upper bound. No sleeps longer than needed inside assertions.
Lab 4 — Synctest spike
- Run
go doc testing/ search release notes for synctest on your 1.26.x.
- If present, implement one official-style example.
- If absent, document classical substitutes and keep labs green.
Common gotchas
| Gotcha | Fix |
|---|---|
| Global test counters | Per-test state |
t.Parallel + shared files |
TempDir per test |
| Infinite Wait on bug | -timeout |
| Ignoring race in parallel tests | Always -race for concurrent packages |
| Over-synchronizing production code for tests | Prefer injectable seams |
Checkpoint
- Eliminated a sleep-based flake
- Safe
t.Parallelusage demonstrated
- Cancel test fails if cancel ignored
-race -count=20+clean
- Synctest researched for 1.26.x
- Knows
-timeoutand-countroles
Commit
git add .
git commit -m "day36: concurrent tests — de-flake, parallel, cancel asserts"Write three personal gotchas before continuing.
Tomorrow
Day 37 — Go 1.26 quality pass: Green Tea GC default, new(expr), rewritten go fix, and a deliberate modernization sweep.