Testing Concurrent Go Code with synctest
Stop sleeping in concurrent Go tests.
Testing concurrent Go code has always required a bit of discipline. Goroutines are cheap, channels are simple, and context cancellation is idiomatic — background workers and timers are everywhere in real Go services.
But testing all of that reliably is harder than writing it.

The usual bad pattern is familiar:
go doSomething()
time.Sleep(100 * time.Millisecond)
if !done {
t.Fatal("background work did not finish")
}
That test may pass on your laptop and fail in CI. Or it may pass for six months and then fail on a loaded runner. Or it may be slow because someone increased the sleep from 100 milliseconds to 2 seconds “just to be safe”.
This is not good testing — it is gambling with a timer, and that gamble gets more expensive as the test suite grows.
The testing/synctest package gives Go developers a better way to test many forms of asynchronous and time-dependent code. It lets a test run inside an isolated bubble, gives that bubble a fake clock, and provides a way to wait until goroutines inside the bubble are blocked.
The result is simple but powerful:
- No arbitrary sleeps
- Faster timeout tests
- More deterministic concurrent tests
- Better testing of context cancellation
- Better testing of background goroutines
- Less flaky CI
The slightly opinionated version: if your concurrent Go test depends on a real time.Sleep, you should probably treat that test as suspicious.
What testing/synctest is
testing/synctest is a Go standard library package for testing concurrent code.
It provides two main functions:
package synctest
func Test(t *testing.T, f func(*testing.T))
func Wait()
synctest.Test runs a function inside an isolated test bubble. Any goroutines started inside that bubble are also part of the bubble, time inside the bubble is fake, and the time package works against that fake clock rather than the real wall clock.
synctest.Wait waits until all other goroutines in the bubble are durably blocked. That sounds abstract, but the practical effect is easy to understand:
synctest.Test(t, func(t *testing.T) {
time.Sleep(10 * time.Second)
})
This does not make your test wait 10 real seconds. Inside the synctest bubble, time can advance instantly when the bubble is blocked and waiting for time to move forward — that is the core trick behind the package.
Why concurrent Go tests are flaky
If you are new to Go testing in general, Go Unit Testing: Structure & Best Practices covers the testing package, table-driven tests, and mocking patterns that form the foundation this article builds on. Concurrent tests are usually flaky for one of three reasons.
First, they depend on the scheduler. A goroutine may run immediately on your machine and later on CI.
Second, they depend on real time. A test that sleeps for 50 milliseconds assumes that 50 milliseconds is enough time for the background work to finish.
Third, they observe state too early. The test checks the result before the background operation has actually completed.
Here is a simple example:
func TestBackgroundWorkBad(t *testing.T) {
done := false
go func() {
done = true
}()
time.Sleep(10 * time.Millisecond)
if !done {
t.Fatal("background work did not finish")
}
}
This test has two problems.
The obvious one is the sleep. There is no guarantee that 10 milliseconds is the right amount of time.
The less obvious one is the data race. The test writes done in one goroutine and reads it in another without synchronization.
You can fix this specific example with a channel or a sync.WaitGroup, and often you should. But when the code under test uses timers, context deadlines, time.AfterFunc, background workers, or delayed cleanup, the test can still become awkward — and that is exactly where testing/synctest helps.
The core idea: run the test inside a bubble
A synctest bubble isolates the goroutines created inside it.
Use it like this:
func TestSomethingConcurrent(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Test concurrent code here.
})
}
Inside the bubble:
- Goroutines started by the test belong to the bubble.
- Timers and sleeps use a fake clock.
synctest.Waitcan wait for background activity to settle.- The test should avoid depending on external goroutines, real network I/O, or external processes.
The bubble is not magic. It does not make bad concurrency design good. But it gives your test a controlled environment where time and blocking behavior are more deterministic.
The problem with time.Sleep in tests
A real time.Sleep in a test usually means one of two things:
I do not know how to wait for the event I actually care about.
or:
I know what I care about, but the code under test does not expose a clean way to observe it.
Both are design signals worth taking seriously — they point to places where the production code may benefit from cleaner observability or more explicit coordination mechanisms.
Consider a function that completes work in the background:
type Worker struct {
out chan string
}
func NewWorker() *Worker {
return &Worker{
out: make(chan string, 1),
}
}
func (w *Worker) Start() {
go func() {
time.Sleep(5 * time.Second)
w.out <- "done"
}()
}
func (w *Worker) Result() <-chan string {
return w.out
}
A bad test might look like this:
func TestWorkerBad(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(6 * time.Second)
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("got %q, want done", got)
}
default:
t.Fatal("worker did not finish")
}
}
This test waits six real seconds.
That is slow. If you have many tests like this, the suite becomes painful.
A better test with synctest can advance fake time instantly:
func TestWorkerWithSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(5 * time.Second)
synctest.Wait()
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("got %q, want done", got)
}
default:
t.Fatal("worker did not finish")
}
})
}
The test still expresses the business fact — the worker should finish after 5 seconds — but it does not spend 5 real seconds doing so. That is the difference between testing time-dependent behavior and wasting developer time.
Testing context timeouts
One of the best uses for testing/synctest is testing context.Context deadlines and timeouts. Correctly propagating context.Canceled and context.DeadlineExceeded through service and handler layers is covered in depth in Go Error Handling Architecture: Boundaries and Patterns — synctest lets you verify that behavior without real time passing.
Here is a simple function that waits until a context is canceled:
func WaitForCancel(ctx context.Context, done chan<- error) {
go func() {
<-ctx.Done()
done <- ctx.Err()
}()
}
Without synctest, testing this with a 30-second timeout would either make the test slow or force you to change the timeout just for the test.
With synctest, you can test the real timeout duration quickly:
func TestWaitForCancelWithTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
done := make(chan error, 1)
WaitForCancel(ctx, done)
synctest.Wait()
select {
case err := <-done:
t.Fatalf("context canceled too early: %v", err)
default:
}
time.Sleep(30 * time.Second)
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("got %v, want %v", err, context.DeadlineExceeded)
}
default:
t.Fatal("context was not canceled")
}
})
}
This is the kind of test that synctest makes pleasant.
You can keep realistic timeout values in code and still run tests quickly.
Testing context cancellation
You can also test explicit cancellation without racing the background goroutine.
func TestWaitForCancelWithExplicitCancel(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
WaitForCancel(ctx, done)
synctest.Wait()
select {
case err := <-done:
t.Fatalf("context canceled too early: %v", err)
default:
}
cancel()
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v, want %v", err, context.Canceled)
}
default:
t.Fatal("context was not canceled")
}
})
}
The important detail is synctest.Wait.
It gives the background goroutine a chance to observe cancellation and settle before the test checks the result.
What synctest.Wait does
synctest.Wait waits until all other goroutines in the bubble are durably blocked.
In normal language, it means:
Wait until the goroutines inside this test have reached a stable blocked point.
This is useful when the test starts a goroutine and needs to know that the goroutine has either finished or is waiting.
For example:
func TestWaitExample(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() {
done = true
}()
synctest.Wait()
if !done {
t.Fatal("goroutine did not run")
}
})
}
This is intentionally small, but it demonstrates the idea.
synctest.Wait is not just a nicer sleep — it is a synchronization point inside the bubble, and that distinction matters more than it first appears.
A sleep says:
I hope enough time has passed.
Wait says:
I want the bubble to reach a stable blocked state.
The second is far better for tests because it describes an observable condition rather than a guess about elapsed time.
Fake time in a synctest bubble
Inside a synctest bubble, the time package uses a fake clock.
The fake clock starts at a fixed time. It advances only when every goroutine in the bubble is durably blocked and time needs to move forward to unblock something.
That means this test is fast:
func TestFakeTime(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
time.Sleep(1 * time.Hour)
elapsed := time.Since(start)
if elapsed != time.Hour {
t.Fatalf("got %v, want %v", elapsed, time.Hour)
}
})
}
It reads like it waits an hour.
It does not.
This is useful for testing:
- timeouts
- deadlines
- retries
- backoff
- delayed cleanup
- rate limits
- timers
- tickers
- context cancellation
But there is one important rule: fake time only helps code that uses the time package inside the bubble.
If your code depends on an external system, real network I/O, or time measured outside the bubble, synctest cannot make that deterministic.
Testing a retry loop
Retry loops are a common source of slow and flaky tests.
Here is a small retry helper:
func Retry(ctx context.Context, attempts int, delay time.Duration, fn func() error) error {
var last error
for i := 0; i < attempts; i++ {
if err := fn(); err != nil {
last = err
} else {
return nil
}
if i == attempts-1 {
break
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return last
}
A normal test might reduce the delay to 1 millisecond just to keep the suite fast.
That is not terrible, but it means the test is no longer exercising the real value used by production code.
With synctest, you can keep the real delay:
func TestRetryEventuallySucceeds(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx := t.Context()
calls := 0
err := Retry(ctx, 3, 10*time.Second, func() error {
calls++
if calls < 3 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
})
}
The test represents two 10-second waits.
It still runs quickly.
This is where synctest changes the economics of testing. You no longer need fake tiny durations scattered through tests just to avoid slow CI.
Testing retry cancellation
You can also test cancellation during retry delay:
func TestRetryStopsWhenContextCanceled(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
errCh := make(chan error, 1)
go func() {
errCh <- Retry(ctx, 10, 10*time.Second, func() error {
return errors.New("temporary failure")
})
}()
synctest.Wait()
cancel()
synctest.Wait()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v, want %v", err, context.Canceled)
}
default:
t.Fatal("Retry did not return after cancellation")
}
})
}
This test checks that the retry loop responds to cancellation instead of sleeping through the delay.
That is exactly the kind of behavior that matters in production.
Testing time.AfterFunc
time.AfterFunc is another good fit.
Suppose you have a function that schedules cleanup:
type Cache struct {
cleaned chan struct{}
}
func NewCache() *Cache {
return &Cache{
cleaned: make(chan struct{}, 1),
}
}
func (c *Cache) CleanupAfter(d time.Duration) {
time.AfterFunc(d, func() {
c.cleaned <- struct{}{}
})
}
func (c *Cache) Cleaned() <-chan struct{} {
return c.cleaned
}
The test can advance fake time:
func TestCleanupAfter(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewCache()
cache.CleanupAfter(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
t.Fatal("cleanup happened too early")
default:
}
time.Sleep(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
default:
t.Fatal("cleanup did not happen")
}
})
}
This test verifies both sides:
- The cleanup does not happen before the delay.
- The cleanup does happen after the delay.
And it does not wait a real minute.
Testing tickers
Tickers can also be tested with fake time, but be careful. Tickers are often used in long-running loops, and long-running loops need a clean shutdown path.
Here is a small ticker-based counter:
type Counter struct {
ticks int
done chan struct{}
}
func NewCounter() *Counter {
return &Counter{
done: make(chan struct{}),
}
}
func (c *Counter) Start(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
defer close(c.done)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.ticks++
}
}
}()
}
func (c *Counter) Wait() {
<-c.done
}
func (c *Counter) Ticks() int {
return c.ticks
}
A test might look like this:
func TestCounterTicks(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
counter := NewCounter()
counter.Start(ctx, 10*time.Second)
time.Sleep(35 * time.Second)
synctest.Wait()
cancel()
counter.Wait()
if counter.Ticks() != 3 {
t.Fatalf("ticks = %d, want 3", counter.Ticks())
}
})
}
This example has a deliberate design detail: the worker has a shutdown path.
That is not only good for tests. It is good for production.
Tests often reveal whether your goroutines can actually stop.
synctest and goroutine leaks
testing/synctest is helpful here because synctest.Test waits for goroutines in the bubble to exit before returning, which means leaked goroutines are harder to ignore. If a background goroutine never exits, the test fails instead of silently leaving work behind — and that is a good thing.
Concurrent code should have clear ownership. If a function starts a goroutine, there should be an explicit way to stop it, or a documented reason why it is allowed to live forever. In tests, “forever” is almost never acceptable.
A good pattern is:
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
Then make the goroutine stop when the context is canceled.
What “durably blocked” means in practice
The official docs use the term “durably blocked”.
You do not need to memorize every runtime detail, but you should understand the practical meaning.
A goroutine is durably blocked when it is blocked in a way that can only be unblocked by something inside the same synctest bubble.
Examples include:
- receiving from a channel created inside the bubble
- sending to a channel created inside the bubble
- waiting on a
sync.WaitGroupassociated with the bubble - sleeping with
time.Sleep - waiting on certain timer operations
Some things are not durably blocked because something outside the bubble may unblock them.
Examples include:
- network I/O
- system calls
- external process operations
- some mutex waits
- interactions with goroutines outside the bubble
This is why synctest tests should be self-contained and kept free from external synchronization that the bubble cannot see. Do not use synctest as a wrapper around integration tests that talk to the real network.
What synctest is good for
testing/synctest is especially good for unit tests around asynchronous behavior.
Good candidates include:
- context cancellation
- context timeouts
- retry loops
- backoff logic
- delayed cleanup
- timer-driven workers
- ticker-driven loops
- background goroutines
- timeout behavior
- channel coordination
time.AfterFunc- deterministic waiting for goroutines
The best use case is code where the hard part is time or scheduling, not external I/O.
What synctest is not good for
testing/synctest is not a replacement for all concurrency testing.
It is not a full deterministic scheduler for every possible race.
It is not a substitute for the race detector.
It is not a replacement for integration tests.
It does not make real network I/O deterministic.
It does not fix bad goroutine lifecycle design.
It does not mean you can ignore channels, contexts, ownership, and shutdown.
Use synctest for the right layer: deterministic unit tests for concurrent and time-dependent behavior.
Use other tools for other layers:
- use
go test -raceto detect data races - use integration tests for real dependencies
- use load tests for throughput and contention
- use benchmarks for performance
- use tracing and profiling for production behavior
synctest vs the race detector
testing/synctest and the race detector solve different problems.
The race detector finds unsafe concurrent memory access.
synctest helps you control asynchronous timing and waiting in tests.
You should often use both.
For example, this is still a race even inside a synctest bubble if there is no proper synchronization:
value := 0
go func() {
value = 1
}()
_ = value
synctest.Wait can provide a synchronization point for some test patterns, but it does not mean every concurrent access in your code is automatically safe.
Run concurrent tests with:
go test -race ./...
The race detector is still one of the best tools Go gives you. Pairing it with Go Linters: Essential Tools for Code Quality gives you a solid static analysis and runtime-check baseline for any concurrent codebase.
synctest vs manual fake clocks
Before testing/synctest, many teams used manual fake clocks.
That can still be a good design.
A manual clock interface might look like this:
type Clock interface {
Now() time.Time
After(time.Duration) <-chan time.Time
Sleep(time.Duration)
}
Then production code uses a real clock and tests use a fake clock.
This gives explicit control, but it has a cost:
- more interfaces
- more plumbing
- more test-only abstractions
- more ways for code to bypass the fake clock accidentally
synctest is attractive because ordinary code that uses the time package can run against fake time inside the test bubble.
That reduces the need for clock injection in many cases.
My opinion: use synctest when it keeps production code simpler. Use an injected clock only when clock control is part of your domain design or when you need control outside what synctest provides. For a broader look at dependency injection patterns in Go — including when and how to inject testable abstractions — see Dependency Injection in Go: Patterns & Best Practices.
synctest vs channels and WaitGroups
Do not replace good synchronization with synctest.
If your code can expose a completion channel, a callback, or a Wait method, that is often good design.
For example:
type Server struct {
done chan struct{}
}
func (s *Server) Done() <-chan struct{} {
return s.done
}
A test can wait on that directly.
synctest is most useful when the behavior under test involves time, context deadlines, background scheduling, or async callbacks.
The best tests often combine both:
- production code has explicit shutdown or completion signals
- synctest removes real-time waiting
- Wait makes background activity deterministic
Common mistakes
Mistake 1: Wrapping every test in synctest
Do not use synctest everywhere. If the code is synchronous, a plain test function is clearer, and adding the bubble wrapper only introduces unnecessary machinery that makes tests harder to read and reason about.
Mistake 2: Testing real network I/O inside the bubble
Keep synctest tests self-contained. If your test uses a real network socket, external service, database, or subprocess, it belongs in an integration test rather than inside a synctest bubble. Use fakes for unit tests and reserve real dependencies for separate integration tests where bubble isolation does not apply.
Mistake 3: Leaking goroutines
If your test starts a goroutine, make sure it has a clear exit path. Use context cancellation, closed channels, or explicit stop methods — a goroutine that never stops is both a production smell and a test smell that synctest will surface rather than hide.
Mistake 4: Depending on package-level state
Package-level channels, timers, and WaitGroups can break bubble isolation in subtle ways. Prefer creating all test state inside the synctest.Test function so that every resource belongs to the bubble and its lifetime is clearly scoped to the test.
Mistake 5: Treating fake time as real time
Fake time is for deterministic tests, not performance measurement. A test that advances one hour instantly tells you nothing useful about CPU cost, lock contention, memory usage, or real scheduling behavior in production — use benchmarks and load tests for those questions.
Mistake 6: Ignoring the race detector
synctest is not a replacement for go test -race, and the two tools solve different problems. Run the race detector alongside your synctest tests to catch unsafe concurrent memory access that the bubble alone cannot detect.
A practical checklist
Use this checklist when writing tests with testing/synctest.
Use synctest when
- the code starts goroutines
- the code uses
time.Sleep - the code uses timers or tickers
- the code uses context deadlines
- the code has retry or backoff behavior
- the test currently uses arbitrary sleeps
- the test is flaky in CI
- the test is slow because it waits for real time
Avoid synctest when
- the code is synchronous
- the test depends on real network I/O
- the test depends on external processes
- the test is really an integration test
- you are trying to measure performance
- the code has no clean shutdown path
Prefer this pattern
func TestSomething(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Arrange.
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
// Act.
_ = ctx
// Let background work settle.
synctest.Wait()
// Advance fake time if needed.
time.Sleep(1 * time.Second)
synctest.Wait()
// Assert.
})
}
This pattern is simple:
- set up inside the bubble
- start work inside the bubble
- wait for background activity to settle
- advance fake time only when needed
- assert after synchronization
Where to use testing/synctest in real projects
The best places to look are usually not in simple business logic.
Look for tests with these smells:
grep -R "time.Sleep" .
grep -R "time.After" .
grep -R "WithTimeout" .
grep -R "WithDeadline" .
grep -R "NewTicker" .
grep -R "AfterFunc" .
Then ask:
- Is this test slow because it waits for real time?
- Is this test flaky because it assumes a goroutine already ran?
- Can this test be isolated from network and external processes?
- Can the background goroutine be stopped cleanly?
- Would fake time make the assertion clearer?
Good candidates often live in:
- worker packages
- retry packages
- cache packages
- scheduler packages
- queue consumers
- HTTP client wrappers
- timeout middleware
- background cleanup code
- rate-limiting code
Start with one flaky test. Do not migrate the whole codebase at once. If your test suite uses parallel table-driven tests alongside async code, Parallel Table-Driven Tests in Go covers the t.Parallel() patterns and race condition traps that pair naturally with the synctest approach.
Example: before and after
Here is a realistic bad test:
func TestRetryBad(t *testing.T) {
calls := 0
err := Retry(context.Background(), 3, 500*time.Millisecond, func() error {
calls++
if calls < 3 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
}
This waits about one second because two retry delays occur.
That may not sound bad, but multiply it by many tests and several packages. Slow tests make developers run tests less often.
Now the synctest version:
func TestRetryWithSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
calls := 0
err := Retry(t.Context(), 3, 500*time.Millisecond, func() error {
calls++
if calls < 3 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
})
}
The test keeps the real delay value, the suite stays fast, and the intent is clearer. That is the main value of testing/synctest.
How to adopt synctest safely
I would adopt it gradually.
Step 1: Find flaky or slow concurrent tests
Search for real sleeps and timeout-heavy tests. The grep commands in the previous section are a good starting point for identifying candidates across the codebase.
Step 2: Pick one package
Choose a package that has clear asynchronous behavior but does not require real external services. Worker packages, retry helpers, and timer-driven components are ideal first targets.
Step 3: Convert one test
Wrap the test in synctest.Test and replace arbitrary sleeps with synctest.Wait, fake-time sleeps, or explicit synchronization. The conversion is usually small — the hardest part is making sure goroutines have clean shutdown paths.
Step 4: Run with the race detector
Always run with go test -race ./... after converting. A passing synctest test does not mean the code is race-free; it only means the async timing is now deterministic.
Step 5: Review goroutine lifecycle
Make sure every goroutine started by the test has a way to exit before the bubble closes. If it does not, synctest.Test will surface the leak rather than silently ignoring it.
Step 6: Repeat only where it improves clarity
Do not convert tests just for fashion. A good synctest test should be measurably faster, clearer to read, or less flaky than the version it replaced — if it is not, the conversion was not worth it.
My opinionated rules
Use these as practical rules of thumb.
Rule 1: No arbitrary sleeps in concurrent unit tests
A sleep that waits for a goroutine to maybe finish is a smell. Replace it with channels, WaitGroups, callbacks, synctest.Wait, or fake time — anything that waits for a condition rather than hoping enough time has passed.
Rule 2: Keep synctest tests self-contained
Create goroutines, channels, contexts, timers, and workers inside the bubble. Avoid package-level shared state, which can leak between tests and break the isolation that makes synctest useful.
Rule 3: Do not use synctest as an integration test wrapper
If the test talks to a real database, real network, or external process, keep it out of synctest unless you have a very specific reason for doing so.
Rule 4: Test behavior, not scheduler luck
The goal is not to force a goroutine to run. The goal is to verify observable behavior after the system has reached a meaningful state, which synctest.Wait makes possible without depending on timing assumptions.
Rule 5: Keep cancellation paths explicit
Every background goroutine should have a shutdown path, and tests should prove that path works by canceling the context or closing the channel and then verifying the goroutine exits cleanly.
Final thoughts
testing/synctest is one of those Go features that looks small but changes how you write a class of tests. It does not replace good concurrency design, the race detector, or the need for integration tests — but it does make many asynchronous unit tests faster, cleaner, and far less dependent on timing luck.
That matters because concurrent code is already hard enough. Tests should reduce uncertainty, not add to it. For a broader view of production Go patterns across integration, code structure, and data access, see App Architecture in Production.
The practical takeaway is simple:
Use synctest for deterministic unit tests around goroutines, timers, timeouts, retries, and cancellation.
Keep real sleeps out of concurrent tests unless you have a very good reason.
That one habit will make many Go test suites faster and less flaky.
The important current facts are: testing/synctest became generally available in Go 1.25, it exposes synctest.Test and synctest.Wait, it runs tests inside an isolated bubble, and time inside that bubble uses a fake clock that advances only when goroutines are durably blocked.
Sources
- https://pkg.go.dev/testing/synctest
- https://go.dev/blog/testing-time
- https://go.dev/blog/synctest
- [https://go.dev/blog/testing-time](“Testing Time (and other asynchronicities) - The Go Programming Language”)
- https://go.dev/doc/go1.25
- https://go.dev/blog/go1.25