Day 36 — Concurrent tests / synctest

Updated

July 30, 2026

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.

Note

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:

  1. No shared mutable globals without sync
  2. Package TestMain / suite setup must be parallel-safe
  3. Use subtests carefully—t.Parallel in subtests after t.Run scaffolding
  4. 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

  1. Classical tools remain foundational: -race, channels, ctx, errgroup, testing timeouts (go test -timeout).
  2. Experimental / evolving helpers may allow running concurrent logic in a controlled scheduler bubble so tests don’t depend on real time.Sleep durations.
  3. For this volume: do not depend on unstable APIs as your only skill—learn the portable patterns above first.
  4. 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 -v

CI snippet:

go test -race -count=1 -timeout 5m ./...

-count=1 disables cache; critical for flake hunting.


Theory 7 — Deadlocking tests

If a test hangs:

  1. go test -timeout 5s to fail fast
  2. Check missing Done / channel receive
  3. 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

  1. Write a deliberately flaky test using Sleep.
  2. Run -count=50 to see intermittent failures (or force failure).
  3. Rewrite with channel/join.
  4. 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

  1. Run go doc testing / search release notes for synctest on your 1.26.x.
  2. If present, implement one official-style example.
  3. 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.Parallel usage demonstrated
  • Cancel test fails if cancel ignored
  • -race -count=20+ clean
  • Synctest researched for 1.26.x
  • Knows -timeout and -count roles

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.