Have you ever got confused about testing functions using time.Now()? What about functions calling goroutines?

Well your problems are over… or they will be over with Go 1.25, that will be released soon (probably next month) featuring the new testing/synctest package

What does synctest do?

Basically, it allows you to run your tests in a “bubble”, a kind of isolation from the rest of the test.

It’s meant to make your life easier when testing concurrent code, avoiding flaky tests, changing your production code to be testable, and reducing the time to run tests.

This bubble has two main features:

  • A fake clock
  • A blocking mechanism for goroutines

It has two exported functions (actually three, but one is already deprecated 😲; we’ll discuss this more in a bit):

func Test(t *testing.T, f func(*testing.T))
func Wait()

Test creates what we call a “bubble.” It’s the function that allows you to stop the clock in the past and control its flow as if you were the Flash running so fast ⚡. It waits until all goroutines have returned and will also make the test fail if any goroutine becomes deadlocked. It’s a way to even detect goroutine leaks.

Wait will wait until all goroutines get into a state called “durably blocked.” This means waiting until they have already finished or are blocked and won’t keep running unless an event is triggered by another goroutine inside the bubble to unblock them. I know it’s a bit confusing, but we’ll see an example soon.

The fake clock

The time.Now() will return midnight UTC 2000-01-01, meticulously chosen to remind us about the fears of the year 2000 problem, or millennium bug 🤪. And time won’t advance until all goroutines inside the bubble are blocked, for example, by using a time.Sleep to block them. It’s so much easier to control time this way, instead of having to rely on random sleep times and creating flaky tests.

I used to do things like this:

var _now = time.Now

func TimeToSendMessage(fallbackTime time.Time) time.Time  {
	now := _now()
	
	if now.Hour() > 9 && now.Hour() < 18  {
		return now
	}
	
	return fallbackTime
}

This code basically returns the time to send a message (SMS or something like this). We don’t want to bother our clients by sending messages outside business hours, so we check the hour of our current day. If we are before 9 a.m. or after 6 p.m., the system schedules a message for a fallback time.

Focus on the _now variable. I created it to hold the function time.Now, because this way I would be able to “mock” the time.Now like this:

func TestTimeToSendMessage(t *testing.T) {
	inputTime := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC)
	
	tt := []struct {
		name  string
		mockedNow time.Time
		expected time.Time
	}{
		{
			name:  "should return now",
			mockedNow: time.Date(2025, 5, 8, 12, 0, 0, 0, time.UTC),
			expected: time.Date(2025, 5, 8, 12, 0, 0, 0, time.UTC),
		},
		{
			name:  "should return fallback time",
			mockedNow: time.Date(2025, 5, 8, 20, 0, 0, 0, time.UTC),
			expected: inputTime,
		},
	}
	
	for _, tc := range tt {
		t.Run(tc.name, func(t *testing.T) {
			_now = func() time.Time {
				return tc.mockedNow
			}
	
			result := TimeToSendMessage(inputTime)

			if result != tc.expected {
				t.Fatalf("wrong output")
			}
		})
	}
}

I always felt dirty doing things this way. We have some problems here:

  • We can’t run these test cases in parallel; we would have a data race since we are reassigning the value of _now, and our tests would be flaky.
  • I had to add more code, complexity, and ugliness to the production code in order to test it.

Now my problems are over; I can just do the following:

func TimeToSendMessage(fallbackTime time.Time) time.Time  {
	now := time.Now()
	
	if now.Hour() > 9 && now.Hour() < 18  {
		return now
	}
	
	return fallbackTime
}
func TestTimeToSendMessage(t *testing.T) {
	inputTime := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC)

	tt := []struct {
		name  string
		sleepDuration time.Duration
		expected time.Time
	}{
		{
			name:  "should return now",
			sleepDuration: 12 * time.Hour,
			expected: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
		},
		{
			name:  "should return fallback time",
			sleepDuration: 0,
			expected: inputTime,
		},
	}
	
	for _, tc := range tt {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			
			synctest.Run(func() {
				time.Sleep(tc.sleepDuration) // time.Now() will add this sleep time
				
				result := TimeToSendMessage(inputTime)

				if !result.Equal(tc.expected) {
					t.Fatalf("wrong result")
				}
			})
		})
	}
}

This way, I don’t have to change production code, and I can run the tests in parallel without worries.

If you don’t believe me, here’s the code.

Blocking mechanism for goroutines

I’ve already explained the Wait function; let’s go to an example.

Imagine a function DoSomething that basically receives a string and returns a string pointer. The pointer will default to the initialString address (and value). But we have a goroutine inside that will change the value of the pointer to "test" if the initialString has the value "returnTest".

Here’s the code and the test:

func DoSomething(initialString string) *string {
	strPointer := &initialString

	go func() {
		if initialString != "returnTest" {
			return
		}

		*strPointer = "test"
	}()

	return strPointer
}
func TestDoSomething(t *testing.T) {
	tt := []struct {
		name     string
		input    string
		expected string
	}{
		{
			name:     "should return 'any' pointer",
			input:    "any",
			expected: "any",
		},
		{
			name:     "should return 'test' pointer",
			input:    "returnTest",
			expected: "test",
		},
	}

	for _, tc := range tt {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			synctest.Test(t, func(t *testing.T) {
				result := DoSomething(tc.input)

				if *result != tc.expected {
					t.Fatalf("wrong result %s", *result)
				}
			})
		})
	}
}

First, we don’t have a synctest.Wait() call, as you can see, and the output of our code is the following:

=== RUN   TestDoSomething
=== RUN   TestDoSomething/should_return_'any'_pointer
=== PAUSE TestDoSomething/should_return_'any'_pointer
=== RUN   TestDoSomething/should_return_'test'_pointer
=== PAUSE TestDoSomething/should_return_'test'_pointer
=== CONT  TestDoSomething/should_return_'any'_pointer
=== CONT  TestDoSomething/should_return_'test'_pointer
	prog_test.go:50: wrong result returnTest
--- FAIL: TestDoSomething (0.00s)
	--- PASS: TestDoSomething/should_return_'any'_pointer (0.00s)
	--- FAIL: TestDoSomething/should_return_'test'_pointer (0.00s)
FAIL

It failed. That’s because we are not waiting for all the goroutines to become “durably blocked.” Changing our test:

func TestDoSomething(t *testing.T) {
	tt := []struct {
		name     string
		input    string
		expected string
	}{
		{
			name:     "should return 'any' pointer",
			input:    "any",
			expected: "any",
		},
		{
			name:     "should return 'test' pointer",
			input:    "returnTest",
			expected: "test",
		},
	}

	for _, tc := range tt {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			synctest.Test(t, func(t *testing.T) {
				result := DoSomething(tc.input)

				synctest.Wait()
				if *result != tc.expected {
					t.Fatalf("wrong result %s", *result)
				}
			})
		})
	}
}

The new output will be:

=== RUN   TestDoSomething
=== RUN   TestDoSomething/should_return_'any'_pointer
=== PAUSE TestDoSomething/should_return_'any'_pointer
=== RUN   TestDoSomething/should_return_'test'_pointer
=== PAUSE TestDoSomething/should_return_'test'_pointer
=== CONT  TestDoSomething/should_return_'any'_pointer
=== CONT  TestDoSomething/should_return_'test'_pointer
--- PASS: TestDoSomething (0.00s)
	--- PASS: TestDoSomething/should_return_'any'_pointer (0.00s)
	--- PASS: TestDoSomething/should_return_'test'_pointer (0.00s)
PASS

Goroutine Leaks

I will not dive too much into this subject—maybe it deserves a proper post. But the synctest package can be used to detect goroutine leaks. Goroutine what???

It’s similar to the idea of memory leaks—a goroutine that’s stuck and should have already ended.

It can happen for different reasons, but one of them is unbuffered channels and early returns.

var (
	ErrorProcess = errors.New("process error")
	ErrorRequest = errors.New("request error")
)

func DoSomething(input string) error {
	c := make(chan error)
	
	go func() {
		c <- process()
	}()
	
	if err := request(input); err != nil {
		return err
	}
	
	err := <-c

	return err
}

func process() error {
	time.Sleep(2 * time.Second)

	return ErrorProcess
}

func request(input string) error {
	time.Sleep(1 * time.Second)
	
	if input == "error" {
		return ErrorRequest
	}

	return nil
}

Let’s give an example. Imagine a function where you have an error channel and you process some stuff on a goroutine; if any error happens, you produce an error in the channel. Then you do some request, and after, you consume the error from the channel in order to return it. But if this request in the middle fails, it will return, and the consumer will vanish faster than beer near me. As we already know (or maybe you are learning here 😄), unbuffered channels must have active producers and consumers at the same time (we don’t have a buffer to hold the information). In this case, we have lost the consumer, so the producer will stall and block our goroutine. There’s your leak.

When we try to test it without synctest, we get:

func TestDoSomethingNoSynctest(t *testing.T) {
	tt := []struct {
		name     string
		input    string
		expected error
	}{
		{
			name:     "should return process error",
			input:    "ok",
			expected: ErrorProcess,
		},
		{
			name:     "should return request error",
			input:    "error",
			expected: ErrorRequest,
		},
	}

	for _, tc := range tt {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			err := DoSomething(tc.input)

			if !errors.Is(err, tc.expected) {
				t.Fatalf("wrong result")
			}
		})
	}
}
=== RUN   TestDoSomethingNoSynctest
=== RUN   TestDoSomethingNoSynctest/should_return_process_error
=== PAUSE TestDoSomethingNoSynctest/should_return_process_error
=== RUN   TestDoSomethingNoSynctest/should_return_request_error
=== PAUSE TestDoSomethingNoSynctest/should_return_request_error
=== CONT  TestDoSomethingNoSynctest/should_return_process_error
=== CONT  TestDoSomethingNoSynctest/should_return_request_error
--- PASS: TestDoSomethingNoSynctest (0.00s)
	--- PASS: TestDoSomethingNoSynctest/should_return_request_error (1.00s)
	--- PASS: TestDoSomethingNoSynctest/should_return_process_error (2.00s)
PASS

Oh! Beautiful, my code works, it’s perfect… ewwww, not actually… Running my test with synctest:

func TestDoSomething(t *testing.T) {
	tt := []struct {
		name     string
		input    string
		expected error
	}{
		{
			name:     "should return process error",
			input:    "ok",
			expected: ErrorProcess,
		},
		{
			name:     "should return request error",
			input:    "error",
			expected: ErrorRequest,
		},
	}

	for _, tc := range tt {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			synctest.Test(t, func(t *testing.T) {
				err := DoSomething(tc.input)

				if !errors.Is(err, tc.expected) {
					t.Fatalf("wrong result")
				}
			})
		})
	}
} 
=== RUN   TestDoSomething
=== RUN   TestDoSomething/should_return_process_error
=== PAUSE TestDoSomething/should_return_process_error
=== RUN   TestDoSomething/should_return_request_error
=== PAUSE TestDoSomething/should_return_request_error
=== CONT  TestDoSomething/should_return_process_error
=== CONT  TestDoSomething/should_return_request_error
--- FAIL: TestDoSomething (0.00s)
	--- PASS: TestDoSomething/should_return_process_error (0.00s)
	--- FAIL: TestDoSomething/should_return_request_error (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]

goroutine 18 [running]:
testing.tRunner.func1.2({0x567da0, 0xc000212000})
	/usr/local/go-faketime/src/testing/testing.go:1872 +0x237
testing.tRunner.func1()
	/usr/local/go-faketime/src/testing/testing.go:1875 +0x35b
panic({0x567da0?, 0xc000212000?})
	/usr/local/go-faketime/src/runtime/panic.go:783 +0x132
internal/synctest.Run(0xc00018e000)
	/usr/local/go-faketime/src/runtime/synctest.go:251 +0x2de
testing/synctest.Test(0xc000118380, 0xc00018c000)
	/usr/local/go-faketime/src/testing/synctest/synctest.go:282 +0x90
play.TestDoSomething.func1(0xc000118380)
	/tmp/sandbox4287699162/prog_test.go:67 +0x94
testing.tRunner(0xc000118380, 0xc000104040)
	/usr/local/go-faketime/src/testing/testing.go:1934 +0xea
created by testing.(*T).Run in goroutine 7
	/usr/local/go-faketime/src/testing/testing.go:1997 +0x465

goroutine 35 [sleep (durable), synctest bubble 2]:
time.Sleep(0x77359400?)
	/usr/local/go-faketime/src/runtime/time.go:361 +0x12c
play.process(...)
	/tmp/sandbox4287699162/prog_test.go:31
play.DoSomething.func1()
	/tmp/sandbox4287699162/prog_test.go:20 +0x25
created by play.DoSomething in goroutine 34
	/tmp/sandbox4287699162/prog_test.go:19 +0x6b

Program exited.

This happens because we still have a blocked goroutine at the end of the synctest bubble. The output is a little bit confusing, I know, but there’s another alternative for it: the goleak package by Uber.

And to fix this leak in our code it’s easy, we just have to transform our unbuffered channel into a buffered channel, so we can have a buffer to hold the information if the consumer is not there:

func DoSomething(input string) error {
	c := make(chan error, 1) // Buffered channel with a capacity of 1
	
	go func() {
		c <- process()
	}()
	
	if err := request(input); err != nil {
		return err
	}
	
	err := <-c

	return err
}

The 3rd function

I said that there were actually three exported functions inside this package (at least at the time of this post). That’s because this package was first introduced as an experimental package with Go 1.24. This way, you can already preview this new change now—if your code is not stuck in an older Go version 👀.

The examples I gave used the new Go 1.25 API. It’s possible to test it easily by downloading the release candidates or using the Go Playground and selecting the Go dev branch version in the dropdown. Be aware with every experimental feature because the API can change. Quoting the release notes:

The package API is subject to change in future releases
Go 1.24 release notes

Let’s first take a look at the differences between them:

Go 1.24:
func Run(f func())
func Wait()

Go 1.25:
func Run(f func())
func Test(t *testing.T, f func(*testing.T))
func Wait()

As we can see, the Wait and Run functions haven’t changed. The only change is the addition of Test. But it was actually made to replace Run. In the docs, you can read:

func Run(f func())
Run is deprecated.

Deprecated: Use Test instead. Run will be removed in Go 1.26.

Their core functionality is the same, as we can see in the source code. They both call synctest.Run:

func Run(f func()) {
	synctest.Run(f)
}
func Test(t *testing.T, f func(*testing.T)) {
	var ok bool
	synctest.Run(func() {
		ok = testingSynctestTest(t, f)
	})
	if !ok {
		// Fail the test outside the bubble,
		// so test durations get set using real time.
		t.FailNow()
	}
}

But the change was not only the name—the parameters are different, and Test calls testingSynctestTest, which is important. Let’s understand a little bit more about what happened here.

They added the t *testing.T parameter, and testingSynctestTest will basically create a new t2 *testing.T based on t. But why?

So T.Cleanup calls can run inside the bubble at the end of the synctest.Test function, and T.Context can return a cancellable context with a Done channel in the bubble.

You can see more examples about synctest in the Go blog—it was based on the experimental Go 1.24 version, but it’s pretty similar to what you can do with Go 1.25 using Test instead of Run. You can also check the package reference.

And that’s all folks, thanks for your attention, hope you enjoyed 🤘

And what about you? Are you using synctest already?