From a93c50b5d2c764d0cee5f7e6c815e03eaacb59af Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:27:56 +0000 Subject: [PATCH 01/26] docs(README.md): add testing/synctest guidance Since Go 1.25, synctest gives tests a fake clock without injecting one, so users need to know when a *Mock is still the right tool and what happens when they combine the two. Each statement in the section was checked on Go 1.25.8, 1.26.5, and 1.27.3: NewReal() follows bubble time, a Mock keeps its own time, a Mock created with the outer t crashes the test binary at cleanup, and a real PostgreSQL query keeps bubble time from advancing. --- README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/README.md b/README.md index 1f9dc0f..2e41f8c 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,82 @@ func (c *Component) Method() { This makes it much less likely that code changes that introduce new components or methods will spoil existing unit tests. +## Quartz and `testing/synctest` + +Go 1.25 made [`testing/synctest`](https://pkg.go.dev/testing/synctest) stable. `synctest.Test` runs +a test function in a "bubble" where the `time` package uses a fake clock. Bubble time advances only +when every goroutine in the bubble is durably blocked, for example on a channel created in the +bubble or in `time.Sleep`. `synctest.Wait` blocks until every other goroutine in the bubble is +durably blocked. Go 1.27 adds `synctest.Sleep`, which calls `time.Sleep` and then `synctest.Wait`, +and `httptest.NewTestServer`, which serves HTTP over an in-memory network that works in a bubble. + +Which to use: + +- If every goroutine in the test starts in the bubble and blocks only on channels created in the + bubble, `time.Sleep`, `sync.WaitGroup`, or `sync.Cond`, use `synctest` with `quartz.NewReal()`. No + `*Mock` is needed. +- If the test does real network I/O, for example against a database, use a `*Mock` outside a + bubble. +- If you need traps, or want to move one clock without moving every timer in the bubble, use a + `*Mock`, inside or outside a bubble. + +### `NewReal()` in a bubble + +The real clock calls the `time` and `context` packages, so inside a bubble its `Now()`, timers, +tickers, and contexts use bubble time. `ctx.Deadline()` and `time.Now()` both report bubble time, so +the [`Deadline()` caveat](#deadline-reports-wall-time) for `*Mock` contexts does not apply. + +```go +func TestTimeout(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + clk := quartz.NewReal() + ctx, cancel := clk.WithTimeout(t.Context(), time.Minute) + defer cancel() + + time.Sleep(time.Minute) // returns without waiting a wall-clock minute + synctest.Wait() + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatal("expected deadline exceeded") + } + }) +} +``` + +### `*Mock` in a bubble + +A `*Mock` keeps its own time inside a bubble. Bubble time passing does not advance it, and +`Advance()` does not move bubble time. Mock timers fire and mock contexts expire only when you +advance the mock. Traps work as they do outside a bubble. + +Create the `*Mock` with the `t` that `synctest.Test` passes to your function: + +```go +synctest.Test(t, func(t *testing.T) { + mClock := quartz.NewMock(t) // the bubble's t, not the outer test's t + ... +}) +``` + +The `*Mock` registers cleanup functions on its `t`. Cleanup registered on the bubble's `t` runs +inside the bubble; cleanup registered on the outer `t` runs outside it. With the outer `t`, a mock +context left uncanceled, a mock ticker left running, or a fired timer whose channel was never read +crashes the test binary with `fatal error: close of synctest channel from outside bubble` or +`fatal error: receive on synctest channel from outside bubble`. + +A parent created inside a bubble with `context.WithTimeout` or `context.WithDeadline` runs on bubble +time. If the test blocks on a mock context that it never advances, bubble time advances to the +parent's deadline and the parent cancels the child without waiting in wall time. Test timeouts such +as `context.WithTimeout(context.Background(), 10*time.Second)` in the examples above behave the same +way: a stuck test inside a bubble gets the timeout error without waiting 10 seconds. + +### What stops bubble time + +`synctest` does not count a goroutine blocked on network I/O, a system call, or a `sync.Mutex` as +durably blocked, so bubble time does not advance while any goroutine in the bubble waits on one. In +a bubble, a 10 millisecond `context.WithTimeout` does not cancel a 1 second query to a real +PostgreSQL server. Run such tests outside a bubble with a `*Mock`: `Advance()` expires mock contexts +while the query is in flight. + ## Why another time testing library? Writing good unit tests for components and functions that use the `time` package is difficult, even From d387b5cd64d3027cddbfadf7abe78724f4521fa1 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:43:22 +0000 Subject: [PATCH 02/26] docs(README.md): state the outer-t synctest crash as a rule The list of leftovers that crash with the outer t was incomplete. A fired NewTimer registers a cleanup that receives on a bubble channel even after its channel was read or the timer was stopped, and a mock context's cleanup calls parent.Err(), which receives on the parent's Done channel when that parent came from the bubble, such as t.Context(), even after cancel. Describing the mechanism covers these without a list that has to track mock.go and timer.go. --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2e41f8c..ea6b715 100644 --- a/README.md +++ b/README.md @@ -494,10 +494,12 @@ synctest.Test(t, func(t *testing.T) { ``` The `*Mock` registers cleanup functions on its `t`. Cleanup registered on the bubble's `t` runs -inside the bubble; cleanup registered on the outer `t` runs outside it. With the outer `t`, a mock -context left uncanceled, a mock ticker left running, or a fired timer whose channel was never read -crashes the test binary with `fatal error: close of synctest channel from outside bubble` or -`fatal error: receive on synctest channel from outside bubble`. +inside the bubble. Cleanup registered on the outer `t` runs outside the bubble, where it can operate +on channels created in the bubble and crash the test binary with +`fatal error: close of synctest channel from outside bubble` or +`fatal error: receive on synctest channel from outside bubble`. For example, this happens after any +`NewTimer` fires, even if its channel was read, and for a mock context derived from `t.Context()`, +even if it was canceled. A parent created inside a bubble with `context.WithTimeout` or `context.WithDeadline` runs on bubble time. If the test blocks on a mock context that it never advances, bubble time advances to the From cd6ca2635b89321e234dc6b1c683f6f6f8a906c9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:44:13 +0000 Subject: [PATCH 03/26] docs(README.md): warn that bubble time uses up test timeouts The section only said a stuck test times out without waiting in wall time. A test timeout created in the bubble also expires on bubble time that passes while nothing is stuck, so a 10 second timeout fails a test that sleeps for an hour or whose code under test waits on an hour-long real timer. --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ea6b715..3ee30e9 100644 --- a/README.md +++ b/README.md @@ -503,9 +503,15 @@ even if it was canceled. A parent created inside a bubble with `context.WithTimeout` or `context.WithDeadline` runs on bubble time. If the test blocks on a mock context that it never advances, bubble time advances to the -parent's deadline and the parent cancels the child without waiting in wall time. Test timeouts such -as `context.WithTimeout(context.Background(), 10*time.Second)` in the examples above behave the same -way: a stuck test inside a bubble gets the timeout error without waiting 10 seconds. +parent's deadline and the parent cancels the child without waiting in wall time. + +Inside a bubble, test timeouts such as `context.WithTimeout(context.Background(), 10*time.Second)` +in the examples above also run on bubble time. A stuck test gets the timeout error without waiting +10 seconds of wall time, but bubble time that passes for any other reason, such as `time.Sleep` in +the test or real timers in the code under test, also counts toward the timeout. Make the timeout +longer than all the bubble time the test lets pass. With a 10 second timeout and a mock timer due in +an hour, `time.Sleep(time.Hour)` followed by `mClock.Advance(time.Hour).MustWait(ctx)` fails with +`context expired while waiting for clock to advance`. ### What stops bubble time From 30d1aed9768c3186c8f4ad2cf9d4b90e45512d73 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:44:24 +0000 Subject: [PATCH 04/26] docs(README.md): point HTTP tests at httptest.NewTestServer The intro named httptest.NewTestServer without saying what it changes, and the network I/O bullet sent every networked test outside the bubble. A request to a NewTestServer from its Client() advances bubble time while in flight, while the same request to a loopback httptest.NewServer keeps bubble time stopped, so on Go 1.27 HTTP tests need neither a real network nor a *Mock. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ee30e9..d6342d8 100644 --- a/README.md +++ b/README.md @@ -452,7 +452,8 @@ Which to use: bubble, `time.Sleep`, `sync.WaitGroup`, or `sync.Cond`, use `synctest` with `quartz.NewReal()`. No `*Mock` is needed. - If the test does real network I/O, for example against a database, use a `*Mock` outside a - bubble. + bubble. On Go 1.27, an HTTP test can instead serve with `httptest.NewTestServer`, send requests + with its `Client()`, and stay in the bubble. - If you need traps, or want to move one clock without moving every timer in the bubble, use a `*Mock`, inside or outside a bubble. From 6548c2a2a8a28f7ca28d9075872fcc12c0bc9740 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:44:46 +0000 Subject: [PATCH 05/26] docs(README.md): list durable blocking with the WaitGroup limits The bullet counted any sync.WaitGroup wait as durable. Wait only lets bubble time advance when Add was called in the bubble: with Add called outside, or with a package-level var WaitGroup, bubble time stayed at zero until a goroutine outside the bubble called Done. The full list now sits in the intro, and the bullet refers to it. --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d6342d8..8f10b1f 100644 --- a/README.md +++ b/README.md @@ -441,16 +441,18 @@ existing unit tests. Go 1.25 made [`testing/synctest`](https://pkg.go.dev/testing/synctest) stable. `synctest.Test` runs a test function in a "bubble" where the `time` package uses a fake clock. Bubble time advances only -when every goroutine in the bubble is durably blocked, for example on a channel created in the -bubble or in `time.Sleep`. `synctest.Wait` blocks until every other goroutine in the bubble is -durably blocked. Go 1.27 adds `synctest.Sleep`, which calls `time.Sleep` and then `synctest.Wait`, -and `httptest.NewTestServer`, which serves HTTP over an in-memory network that works in a bubble. +when every goroutine in the bubble is durably blocked: in a send, receive, or `select` on channels +created in the bubble, in `time.Sleep`, in `sync.Cond.Wait`, or in `sync.WaitGroup.Wait` after `Add` +was called in the bubble. A package-level `var wg sync.WaitGroup` cannot be associated with a +bubble, so waiting on it does not count. `synctest.Wait` blocks until every other goroutine in the +bubble is durably blocked. Go 1.27 adds `synctest.Sleep`, which calls `time.Sleep` and then +`synctest.Wait`, and `httptest.NewTestServer`, which serves HTTP over an in-memory network that +works in a bubble. Which to use: -- If every goroutine in the test starts in the bubble and blocks only on channels created in the - bubble, `time.Sleep`, `sync.WaitGroup`, or `sync.Cond`, use `synctest` with `quartz.NewReal()`. No - `*Mock` is needed. +- If every goroutine in the test starts in the bubble and blocks only in those ways, use `synctest` + with `quartz.NewReal()`. No `*Mock` is needed. - If the test does real network I/O, for example against a database, use a `*Mock` outside a bubble. On Go 1.27, an HTTP test can instead serve with `httptest.NewTestServer`, send requests with its `Client()`, and stay in the bubble. From 8a3c9f3d25428f5a16563a8f6ee6f4204f643018 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:50:46 +0000 Subject: [PATCH 06/26] docs(README.md): describe the expired timeout, not one failure The quoted failure was not the only outcome. After time.Sleep(time.Hour) the 10 second timeout is always expired, but what MustWait then does depends on the pending mock event: with AfterFunc it usually fails with the quoted message and sometimes passes, because the AdvanceWaiter can already be done when MustWait selects; with NewTimer it usually also aborts the binary with a synctest deadlock panic. A MustWait that has to wait always fails, so the text now says that. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8f10b1f..449e7d6 100644 --- a/README.md +++ b/README.md @@ -512,9 +512,9 @@ Inside a bubble, test timeouts such as `context.WithTimeout(context.Background() in the examples above also run on bubble time. A stuck test gets the timeout error without waiting 10 seconds of wall time, but bubble time that passes for any other reason, such as `time.Sleep` in the test or real timers in the code under test, also counts toward the timeout. Make the timeout -longer than all the bubble time the test lets pass. With a 10 second timeout and a mock timer due in -an hour, `time.Sleep(time.Hour)` followed by `mClock.Advance(time.Hour).MustWait(ctx)` fails with -`context expired while waiting for clock to advance`. +longer than all the bubble time the test lets pass. With a 10 second timeout, +`time.Sleep(time.Hour)` leaves the timeout expired, and a later `mClock.Advance(...).MustWait(ctx)` +or `trap.MustWait(ctx)` that has to wait fails the test. ### What stops bubble time From 526d90136c81ff7da964843a5b34997d0c0e0d4c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:50:55 +0000 Subject: [PATCH 07/26] docs(README.md): create httptest.NewTestServer inside the bubble The bullet did not say where to create the server. A NewTestServer created outside the bubble crashed the test binary on Go 1.27.3 with "select on synctest channel from outside bubble" whether Client() was called outside or inside the bubble: during the request in the first case, and when the outer test's cleanup closed the server in the second. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 449e7d6..ab067fb 100644 --- a/README.md +++ b/README.md @@ -454,8 +454,9 @@ Which to use: - If every goroutine in the test starts in the bubble and blocks only in those ways, use `synctest` with `quartz.NewReal()`. No `*Mock` is needed. - If the test does real network I/O, for example against a database, use a `*Mock` outside a - bubble. On Go 1.27, an HTTP test can instead serve with `httptest.NewTestServer`, send requests - with its `Client()`, and stay in the bubble. + bubble. On Go 1.27, an HTTP test can instead create an `httptest.NewTestServer` inside the bubble + and send requests with its `Client()`. A server created outside the bubble crashes the test binary + with `fatal error: select on synctest channel from outside bubble`. - If you need traps, or want to move one clock without moving every timer in the bubble, use a `*Mock`, inside or outside a bubble. From 313cd28b70aee865ce81da5cf7537a7771038326 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 13:33:17 +0000 Subject: [PATCH 08/26] docs(README.md): rewrite the synctest section around the choice The section listed synctest facts and failure cases, so a reader who wanted to know whether to use synctest, a Mock, or both had to work the answer out. It now starts with that choice, shows the NewReal() option, points to Mocking for the other, and keeps only the two mistakes users can still make: creating the Mock with the outer t and sizing test timeouts that run on bubble time. The synctest docs already define durable blocking, so the section links there instead of restating it. --- README.md | 88 ++++++++++++------------------------------------------- 1 file changed, 19 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index ab067fb..97145e2 100644 --- a/README.md +++ b/README.md @@ -439,32 +439,18 @@ existing unit tests. ## Quartz and `testing/synctest` -Go 1.25 made [`testing/synctest`](https://pkg.go.dev/testing/synctest) stable. `synctest.Test` runs -a test function in a "bubble" where the `time` package uses a fake clock. Bubble time advances only -when every goroutine in the bubble is durably blocked: in a send, receive, or `select` on channels -created in the bubble, in `time.Sleep`, in `sync.Cond.Wait`, or in `sync.WaitGroup.Wait` after `Add` -was called in the bubble. A package-level `var wg sync.WaitGroup` cannot be associated with a -bubble, so waiting on it does not count. `synctest.Wait` blocks until every other goroutine in the -bubble is durably blocked. Go 1.27 adds `synctest.Sleep`, which calls `time.Sleep` and then -`synctest.Wait`, and `httptest.NewTestServer`, which serves HTTP over an in-memory network that -works in a bubble. - -Which to use: - -- If every goroutine in the test starts in the bubble and blocks only in those ways, use `synctest` - with `quartz.NewReal()`. No `*Mock` is needed. -- If the test does real network I/O, for example against a database, use a `*Mock` outside a - bubble. On Go 1.27, an HTTP test can instead create an `httptest.NewTestServer` inside the bubble - and send requests with its `Client()`. A server created outside the bubble crashes the test binary - with `fatal error: select on synctest channel from outside bubble`. -- If you need traps, or want to move one clock without moving every timer in the bubble, use a - `*Mock`, inside or outside a bubble. - -### `NewReal()` in a bubble - -The real clock calls the `time` and `context` packages, so inside a bubble its `Now()`, timers, -tickers, and contexts use bubble time. `ctx.Deadline()` and `time.Now()` both report bubble time, so -the [`Deadline()` caveat](#deadline-reports-wall-time) for `*Mock` contexts does not apply. +Since Go 1.25, [`synctest.Test`](https://pkg.go.dev/testing/synctest) runs a test in a "bubble" +whose fake clock moves forward when every goroutine in the bubble is +[durably blocked](https://pkg.go.dev/testing/synctest#hdr-Blocking). To test time-dependent code: + +- If everything the test runs fits in a bubble, use `synctest` with `quartz.NewReal()`, which runs + on bubble time there. You need no `*Mock`, `Advance()`, or traps. +- If the test does real I/O, such as database queries or requests over a real network, use a + `*Mock` outside a bubble, as in [Mocking](#mocking). Bubble time stops while a goroutine waits on + I/O. On Go 1.27, HTTP tests can stay in the bubble with an `httptest.NewTestServer` created there + and its `Client()`. +- If you need traps or want to decide when time moves, use a `*Mock`, in a bubble or not. Bubble + time does not move it. ```go func TestTimeout(t *testing.T) { @@ -472,8 +458,7 @@ func TestTimeout(t *testing.T) { clk := quartz.NewReal() ctx, cancel := clk.WithTimeout(t.Context(), time.Minute) defer cancel() - - time.Sleep(time.Minute) // returns without waiting a wall-clock minute + time.Sleep(time.Minute) // moves bubble time; returns without waiting a wall-clock minute synctest.Wait() if !errors.Is(ctx.Err(), context.DeadlineExceeded) { t.Fatal("expected deadline exceeded") @@ -482,48 +467,13 @@ func TestTimeout(t *testing.T) { } ``` -### `*Mock` in a bubble - -A `*Mock` keeps its own time inside a bubble. Bubble time passing does not advance it, and -`Advance()` does not move bubble time. Mock timers fire and mock contexts expire only when you -advance the mock. Traps work as they do outside a bubble. - -Create the `*Mock` with the `t` that `synctest.Test` passes to your function: - -```go -synctest.Test(t, func(t *testing.T) { - mClock := quartz.NewMock(t) // the bubble's t, not the outer test's t - ... -}) -``` +Inside a bubble: -The `*Mock` registers cleanup functions on its `t`. Cleanup registered on the bubble's `t` runs -inside the bubble. Cleanup registered on the outer `t` runs outside the bubble, where it can operate -on channels created in the bubble and crash the test binary with -`fatal error: close of synctest channel from outside bubble` or -`fatal error: receive on synctest channel from outside bubble`. For example, this happens after any -`NewTimer` fires, even if its channel was read, and for a mock context derived from `t.Context()`, -even if it was canceled. - -A parent created inside a bubble with `context.WithTimeout` or `context.WithDeadline` runs on bubble -time. If the test blocks on a mock context that it never advances, bubble time advances to the -parent's deadline and the parent cancels the child without waiting in wall time. - -Inside a bubble, test timeouts such as `context.WithTimeout(context.Background(), 10*time.Second)` -in the examples above also run on bubble time. A stuck test gets the timeout error without waiting -10 seconds of wall time, but bubble time that passes for any other reason, such as `time.Sleep` in -the test or real timers in the code under test, also counts toward the timeout. Make the timeout -longer than all the bubble time the test lets pass. With a 10 second timeout, -`time.Sleep(time.Hour)` leaves the timeout expired, and a later `mClock.Advance(...).MustWait(ctx)` -or `trap.MustWait(ctx)` that has to wait fails the test. - -### What stops bubble time - -`synctest` does not count a goroutine blocked on network I/O, a system call, or a `sync.Mutex` as -durably blocked, so bubble time does not advance while any goroutine in the bubble waits on one. In -a bubble, a 10 millisecond `context.WithTimeout` does not cancel a 1 second query to a real -PostgreSQL server. Run such tests outside a bubble with a `*Mock`: `Advance()` expires mock contexts -while the query is in flight. +- Create a `*Mock` with the `t` that `synctest.Test` passes in. With the outer test's `t`, its + cleanup runs outside the bubble and can crash the test binary with a fatal error such as + `close of synctest channel from outside bubble`. +- Test timeouts created in a bubble, such as the `ctx` you pass to `MustWait`, run on bubble time. + Make them longer than all the bubble time the test lets pass, including `time.Sleep` calls. ## Why another time testing library? From 1d31160c004138230db71be0b76e3a62c2effe21 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 13:51:51 +0000 Subject: [PATCH 09/26] docs(README.md): scope the bubble test-timeout advice Only bubble time that passes while a timeout is in use counts against it: a 10 second timeout created after time.Sleep(time.Hour) still let MustWait return. The advice also left out why a long timeout is safe in a bubble: a stuck trap.MustWait with a 1 hour timeout failed after about 120 microseconds of wall time. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 97145e2..214a99a 100644 --- a/README.md +++ b/README.md @@ -473,7 +473,8 @@ Inside a bubble: cleanup runs outside the bubble and can crash the test binary with a fatal error such as `close of synctest channel from outside bubble`. - Test timeouts created in a bubble, such as the `ctx` you pass to `MustWait`, run on bubble time. - Make them longer than all the bubble time the test lets pass, including `time.Sleep` calls. + Make them longer than the bubble time that passes while they are in use, including `time.Sleep` + calls. A long timeout costs no wall time, because a stuck test reaches it immediately. ## Why another time testing library? From be2ceea56cda661022e4dd18e93c44bfdd81d9b8 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 13:51:57 +0000 Subject: [PATCH 10/26] docs(README.md): state the synctest condition as no real I/O "Fits in a bubble" left the reader to work out what fits. Real I/O is what keeps a test out of a bubble in the next bullet, so the first bullet now names its absence and the two read as a pair. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 214a99a..6e5937f 100644 --- a/README.md +++ b/README.md @@ -443,8 +443,8 @@ Since Go 1.25, [`synctest.Test`](https://pkg.go.dev/testing/synctest) runs a tes whose fake clock moves forward when every goroutine in the bubble is [durably blocked](https://pkg.go.dev/testing/synctest#hdr-Blocking). To test time-dependent code: -- If everything the test runs fits in a bubble, use `synctest` with `quartz.NewReal()`, which runs - on bubble time there. You need no `*Mock`, `Advance()`, or traps. +- If the test does no real I/O, use `synctest` with `quartz.NewReal()`, which runs on bubble time + there. You need no `*Mock`, `Advance()`, or traps. - If the test does real I/O, such as database queries or requests over a real network, use a `*Mock` outside a bubble, as in [Mocking](#mocking). Bubble time stops while a goroutine waits on I/O. On Go 1.27, HTTP tests can stay in the bubble with an `httptest.NewTestServer` created there From c15e02b85a2e8805e529ca3c8bedd9f217c85a7f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 13:52:05 +0000 Subject: [PATCH 11/26] docs(README.md): tie httptest.NewTestServer to the go.mod version "On Go 1.27" read as the toolchain version. With the go1.27.3 toolchain, a module declaring go 1.24, 1.25, or 1.26 fails to build with "httptest.NewTestServer requires go1.27 or later (module is go1.26)", and the same test passes once go.mod says go 1.27. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6e5937f..b7b1633 100644 --- a/README.md +++ b/README.md @@ -447,8 +447,8 @@ whose fake clock moves forward when every goroutine in the bubble is there. You need no `*Mock`, `Advance()`, or traps. - If the test does real I/O, such as database queries or requests over a real network, use a `*Mock` outside a bubble, as in [Mocking](#mocking). Bubble time stops while a goroutine waits on - I/O. On Go 1.27, HTTP tests can stay in the bubble with an `httptest.NewTestServer` created there - and its `Client()`. + I/O. With `go 1.27` or later in `go.mod`, HTTP tests can stay in the bubble with an + `httptest.NewTestServer` created there and its `Client()`. - If you need traps or want to decide when time moves, use a `*Mock`, in a bubble or not. Bubble time does not move it. From 2c1aba20caeb2c7e3af2cbee350c26dd0c521591 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:08:03 +0000 Subject: [PATCH 12/26] docs(README.md): say to keep the production NewReal() in a bubble "Which runs on bubble time there" did not say what the user does. The bullet now says to run the test in synctest.Test and keep the clock the code already uses, and why that removes the need for a *Mock. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b7b1633..2e5e5ec 100644 --- a/README.md +++ b/README.md @@ -443,8 +443,9 @@ Since Go 1.25, [`synctest.Test`](https://pkg.go.dev/testing/synctest) runs a tes whose fake clock moves forward when every goroutine in the bubble is [durably blocked](https://pkg.go.dev/testing/synctest#hdr-Blocking). To test time-dependent code: -- If the test does no real I/O, use `synctest` with `quartz.NewReal()`, which runs on bubble time - there. You need no `*Mock`, `Advance()`, or traps. +- If the test does no real I/O, run it in `synctest.Test` and keep the production + `quartz.NewReal()`. Inside a bubble it runs on bubble time, so you need no `*Mock`, `Advance()`, + or traps. - If the test does real I/O, such as database queries or requests over a real network, use a `*Mock` outside a bubble, as in [Mocking](#mocking). Bubble time stops while a goroutine waits on I/O. With `go 1.27` or later in `go.mod`, HTTP tests can stay in the bubble with an From 33322826ccfa2a21be74d983522b31d05a552786 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:08:13 +0000 Subject: [PATCH 13/26] docs(README.md): say what stopped bubble time does to timeouts The bullet said bubble time stops during I/O but not why that matters. Inside a bubble, a 1 second timeout did not stop a 2 second request to a real HTTP server, and a 10 millisecond timeout did not stop a 1 second PostgreSQL query; both returned normally with 0s of bubble time passed. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2e5e5ec..e7a7f07 100644 --- a/README.md +++ b/README.md @@ -448,8 +448,8 @@ whose fake clock moves forward when every goroutine in the bubble is or traps. - If the test does real I/O, such as database queries or requests over a real network, use a `*Mock` outside a bubble, as in [Mocking](#mocking). Bubble time stops while a goroutine waits on - I/O. With `go 1.27` or later in `go.mod`, HTTP tests can stay in the bubble with an - `httptest.NewTestServer` created there and its `Client()`. + I/O, so a timeout does not stop a slow query or request. With `go 1.27` or later in `go.mod`, HTTP + tests can stay in the bubble with an `httptest.NewTestServer` created there and its `Client()`. - If you need traps or want to decide when time moves, use a `*Mock`, in a bubble or not. Bubble time does not move it. From 72aea501c19e36f4e2425a4426c453cbd2fb17a1 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:08:20 +0000 Subject: [PATCH 14/26] docs(README.md): name what moves a *Mock in a bubble "Bubble time does not move it" left open what does. In a bubble, an hour of time.Sleep left the mock at its start time, and Advance(), AdvanceNext(), and Set() each moved the mock without moving bubble time. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e7a7f07..568e3c9 100644 --- a/README.md +++ b/README.md @@ -450,8 +450,8 @@ whose fake clock moves forward when every goroutine in the bubble is `*Mock` outside a bubble, as in [Mocking](#mocking). Bubble time stops while a goroutine waits on I/O, so a timeout does not stop a slow query or request. With `go 1.27` or later in `go.mod`, HTTP tests can stay in the bubble with an `httptest.NewTestServer` created there and its `Client()`. -- If you need traps or want to decide when time moves, use a `*Mock`, in a bubble or not. Bubble - time does not move it. +- If you need traps or want to decide when time moves, use a `*Mock`, in a bubble or not. A `*Mock` + ignores bubble time; only `Advance()`, `AdvanceNext()`, and `Set()` move it. ```go func TestTimeout(t *testing.T) { From 2f09d5f0c71d1b387e8701ffb599986310f4eabe Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:08:50 +0000 Subject: [PATCH 15/26] docs(README.md): explain synctest.Wait in the NewReal() example Without the comment, synctest.Wait reads as optional. context.WithTimeout cancels from a time.AfterFunc callback that runs in its own goroutine, so after time.Sleep returns, ctx may not be done yet: without Wait the example failed in roughly half of 2000 runs on Go 1.25.8, 1.26.5, and 1.27.3, and with it every run passed. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 568e3c9..fc63156 100644 --- a/README.md +++ b/README.md @@ -460,7 +460,7 @@ func TestTimeout(t *testing.T) { ctx, cancel := clk.WithTimeout(t.Context(), time.Minute) defer cancel() time.Sleep(time.Minute) // moves bubble time; returns without waiting a wall-clock minute - synctest.Wait() + synctest.Wait() // let the context's timer callback mark ctx done if !errors.Is(ctx.Err(), context.DeadlineExceeded) { t.Fatal("expected deadline exceeded") } From a499aee94e27c3b6125eeffc81c288b077feb5b6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:08:58 +0000 Subject: [PATCH 16/26] docs(README.md): show a test timeout expiring during time.Sleep "Including time.Sleep calls" did not show the failure the reader should expect. A 10 second context created in a bubble reported DeadlineExceeded after a one-minute time.Sleep on Go 1.25.8, 1.26.5, and 1.27.3. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fc63156..699496f 100644 --- a/README.md +++ b/README.md @@ -474,8 +474,9 @@ Inside a bubble: cleanup runs outside the bubble and can crash the test binary with a fatal error such as `close of synctest channel from outside bubble`. - Test timeouts created in a bubble, such as the `ctx` you pass to `MustWait`, run on bubble time. - Make them longer than the bubble time that passes while they are in use, including `time.Sleep` - calls. A long timeout costs no wall time, because a stuck test reaches it immediately. + Make them longer than the bubble time that passes while they are in use: a 10-second `ctx` expires + during a `time.Sleep(time.Minute)` in the test. A long timeout costs no wall time, because a stuck + test reaches it immediately. ## Why another time testing library? From 3c4bd18919223923ba62ef80495fb1ce8dda8c97 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:09:05 +0000 Subject: [PATCH 17/26] docs(README.md): limit the free-timeout claim to durable blocking "A stuck test reaches it immediately" is false when the stuck goroutine waits on a mutex, which synctest does not count as durably blocked: a trap.MustWait with a 1 hour timeout hung until go test -timeout on Go 1.25.8, 1.26.5, and 1.27.3. With every goroutine durably blocked, the same wait failed after about 120 microseconds of wall time. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 699496f..74f195b 100644 --- a/README.md +++ b/README.md @@ -475,8 +475,8 @@ Inside a bubble: `close of synctest channel from outside bubble`. - Test timeouts created in a bubble, such as the `ctx` you pass to `MustWait`, run on bubble time. Make them longer than the bubble time that passes while they are in use: a 10-second `ctx` expires - during a `time.Sleep(time.Minute)` in the test. A long timeout costs no wall time, because a stuck - test reaches it immediately. + during a `time.Sleep(time.Minute)` in the test. A long timeout costs no wall time: when every + goroutine in the bubble is durably blocked, bubble time jumps straight to it. ## Why another time testing library? From 9aed4337095beb94616c43b6ebeafa24ae04a03a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:09:56 +0000 Subject: [PATCH 18/26] docs(README.md): tighten the synctest section after the audit fixes The audit fixes added about 50 words. This shortens the wording around them without dropping any of the added facts. --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 74f195b..2a55c7e 100644 --- a/README.md +++ b/README.md @@ -446,11 +446,11 @@ whose fake clock moves forward when every goroutine in the bubble is - If the test does no real I/O, run it in `synctest.Test` and keep the production `quartz.NewReal()`. Inside a bubble it runs on bubble time, so you need no `*Mock`, `Advance()`, or traps. -- If the test does real I/O, such as database queries or requests over a real network, use a - `*Mock` outside a bubble, as in [Mocking](#mocking). Bubble time stops while a goroutine waits on - I/O, so a timeout does not stop a slow query or request. With `go 1.27` or later in `go.mod`, HTTP - tests can stay in the bubble with an `httptest.NewTestServer` created there and its `Client()`. -- If you need traps or want to decide when time moves, use a `*Mock`, in a bubble or not. A `*Mock` +- If the test does real I/O, such as database queries or real network requests, use a `*Mock` + outside a bubble, as in [Mocking](#mocking). Bubble time stops while a goroutine waits on I/O, so + a timeout does not stop a slow query or request. With `go 1.27` or later in `go.mod`, HTTP tests + can stay in the bubble with an `httptest.NewTestServer` created there and its `Client()`. +- If you need traps or want to decide when time moves, use a `*Mock`, in a bubble or not. It ignores bubble time; only `Advance()`, `AdvanceNext()`, and `Set()` move it. ```go @@ -459,7 +459,7 @@ func TestTimeout(t *testing.T) { clk := quartz.NewReal() ctx, cancel := clk.WithTimeout(t.Context(), time.Minute) defer cancel() - time.Sleep(time.Minute) // moves bubble time; returns without waiting a wall-clock minute + time.Sleep(time.Minute) // moves bubble time; no wall-clock wait synctest.Wait() // let the context's timer callback mark ctx done if !errors.Is(ctx.Err(), context.DeadlineExceeded) { t.Fatal("expected deadline exceeded") @@ -470,13 +470,13 @@ func TestTimeout(t *testing.T) { Inside a bubble: -- Create a `*Mock` with the `t` that `synctest.Test` passes in. With the outer test's `t`, its - cleanup runs outside the bubble and can crash the test binary with a fatal error such as +- Create a `*Mock` with the `t` that `synctest.Test` passes in. Cleanup on the outer test's `t` runs + outside the bubble and can crash the test binary with a fatal error such as `close of synctest channel from outside bubble`. - Test timeouts created in a bubble, such as the `ctx` you pass to `MustWait`, run on bubble time. Make them longer than the bubble time that passes while they are in use: a 10-second `ctx` expires - during a `time.Sleep(time.Minute)` in the test. A long timeout costs no wall time: when every - goroutine in the bubble is durably blocked, bubble time jumps straight to it. + during a `time.Sleep(time.Minute)`. A long timeout costs no wall time: when every goroutine in the + bubble is durably blocked, bubble time jumps straight to it. ## Why another time testing library? From c0deeb4e5c524b2b3d84167385b34ee661ee9bda Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:33:58 +0000 Subject: [PATCH 19/26] docs(README.md): warn that network waits in a bubble hang the test Probes on Go 1.26.5: with an httptest.NewServer open in the bubble, time.Sleep, a context timeout, and synctest.Wait each hung until go test's timeout; so did time.Sleep with a goroutine reading an idle TCP connection, and synctest.Test itself when an HTTP client's idle keep-alive connection was left open. A server created and closed with no wait in between, and a connection nobody reads, did not hang. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 2a55c7e..c650b7a 100644 --- a/README.md +++ b/README.md @@ -470,6 +470,11 @@ func TestTimeout(t *testing.T) { Inside a bubble: +- Do not create an `httptest.NewServer` or leave open a connection that a goroutine reads from, such + as an HTTP client's idle keep-alive connection. While a goroutine waits on the network, bubble + time does not move and `synctest.Wait()` does not return, so the test hangs at its next + `time.Sleep`, timeout, or `synctest.Wait()`, and `synctest.Test` does not return until that + goroutine exits. - Create a `*Mock` with the `t` that `synctest.Test` passes in. Cleanup on the outer test's `t` runs outside the bubble and can crash the test binary with a fatal error such as `close of synctest channel from outside bubble`. From 8630169b434d585128894961cda24e3ce4832b71 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:34:06 +0000 Subject: [PATCH 20/26] docs(README.md): say synctest.Wait can replace waiting-only traps In a bubble, a *Mock's traps that only wait for the code to reach a clock call can be replaced by synctest.Wait. In 2,000 runs each, Advance missed a timer created by another goroutine in 1,999 runs with neither, and in none with synctest.Wait or a trap. The traps bullet no longer says "in a bubble or not": the choice it describes does not depend on the bubble. --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c650b7a..be37e08 100644 --- a/README.md +++ b/README.md @@ -450,8 +450,8 @@ whose fake clock moves forward when every goroutine in the bubble is outside a bubble, as in [Mocking](#mocking). Bubble time stops while a goroutine waits on I/O, so a timeout does not stop a slow query or request. With `go 1.27` or later in `go.mod`, HTTP tests can stay in the bubble with an `httptest.NewTestServer` created there and its `Client()`. -- If you need traps or want to decide when time moves, use a `*Mock`, in a bubble or not. It - ignores bubble time; only `Advance()`, `AdvanceNext()`, and `Set()` move it. +- If you want to decide when time moves, or need traps, use a `*Mock`. It ignores bubble time; only + `Advance()`, `AdvanceNext()`, and `Set()` move it. ```go func TestTimeout(t *testing.T) { @@ -478,6 +478,9 @@ Inside a bubble: - Create a `*Mock` with the `t` that `synctest.Test` passes in. Cleanup on the outer test's `t` runs outside the bubble and can crash the test binary with a fatal error such as `close of synctest channel from outside bubble`. +- With a `*Mock`, `synctest.Wait()` replaces traps that only wait for code to reach a clock call. It + returns once every other goroutine in the bubble is durably blocked, for example on the timer it + just created, so a following `Advance()` fires that timer. - Test timeouts created in a bubble, such as the `ctx` you pass to `MustWait`, run on bubble time. Make them longer than the bubble time that passes while they are in use: a 10-second `ctx` expires during a `time.Sleep(time.Minute)`. A long timeout costs no wall time: when every goroutine in the From 32ca6c21853fa9424cbd7396c8136384b5cf08a2 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:34:12 +0000 Subject: [PATCH 21/26] docs(README.md): quote only the common part of the outer-t crash The fatal error names the channel operation that crossed the bubble, for example "receive on synctest channel from outside bubble" in a probe that created a context and a ticker in the bubble, so the README quotes only the part every variant shares. The intro paragraph is refilled to 100 columns. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index be37e08..27f80df 100644 --- a/README.md +++ b/README.md @@ -440,8 +440,8 @@ existing unit tests. ## Quartz and `testing/synctest` Since Go 1.25, [`synctest.Test`](https://pkg.go.dev/testing/synctest) runs a test in a "bubble" -whose fake clock moves forward when every goroutine in the bubble is -[durably blocked](https://pkg.go.dev/testing/synctest#hdr-Blocking). To test time-dependent code: +whose fake clock moves forward when every goroutine in the bubble is [durably +blocked](https://pkg.go.dev/testing/synctest#hdr-Blocking). To test time-dependent code: - If the test does no real I/O, run it in `synctest.Test` and keep the production `quartz.NewReal()`. Inside a bubble it runs on bubble time, so you need no `*Mock`, `Advance()`, @@ -476,8 +476,8 @@ Inside a bubble: `time.Sleep`, timeout, or `synctest.Wait()`, and `synctest.Test` does not return until that goroutine exits. - Create a `*Mock` with the `t` that `synctest.Test` passes in. Cleanup on the outer test's `t` runs - outside the bubble and can crash the test binary with a fatal error such as - `close of synctest channel from outside bubble`. + outside the bubble and can crash the test binary with a fatal error about a + `synctest channel from outside bubble`. - With a `*Mock`, `synctest.Wait()` replaces traps that only wait for code to reach a clock call. It returns once every other goroutine in the bubble is durably blocked, for example on the timer it just created, so a following `Advance()` fires that timer. From 1552e684f346d99c812ee61c896857434c1b9875 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:56:29 +0000 Subject: [PATCH 22/26] docs(README.md): say when synctest.Wait can stand in for a trap synctest.Wait returns at the first point where every goroutine is durably blocked. A worker that sleeps or waits on a channel before its clock call is blocked there, so the timer does not exist yet and a following Advance misses it: on Go 1.25.8, 1.26.5, and 1.27.3, Peek after Wait found no timer in both cases, and found it when the worker made the call first. A trap waits for the call itself. --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 27f80df..b7552e2 100644 --- a/README.md +++ b/README.md @@ -478,9 +478,12 @@ Inside a bubble: - Create a `*Mock` with the `t` that `synctest.Test` passes in. Cleanup on the outer test's `t` runs outside the bubble and can crash the test binary with a fatal error about a `synctest channel from outside bubble`. -- With a `*Mock`, `synctest.Wait()` replaces traps that only wait for code to reach a clock call. It - returns once every other goroutine in the bubble is durably blocked, for example on the timer it - just created, so a following `Advance()` fires that timer. +- With a `*Mock`, `synctest.Wait()` can replace a trap that only waits for the code to reach a clock + call, if the code makes that call before it first durably blocks. `synctest.Wait()` returns once + every other goroutine in the bubble is durably blocked or has exited. If a goroutine is then + blocked on the timer that goroutine just created, a following `Advance()` fires that timer; if it + is blocked in `time.Sleep` or on a channel before the call, the timer does not exist yet. In that + case, keep the trap. - Test timeouts created in a bubble, such as the `ctx` you pass to `MustWait`, run on bubble time. Make them longer than the bubble time that passes while they are in use: a 10-second `ctx` expires during a `time.Sleep(time.Minute)`. A long timeout costs no wall time: when every goroutine in the From 2a38969fefef2b3bc77366bf3a1ff94a0194e47b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:56:38 +0000 Subject: [PATCH 23/26] docs(README.md): state the I/O wait rule once in the synctest section The real-I/O bullet and the network bullet both explained that bubble time stops while a goroutine waits on I/O. The real-I/O bullet now states it, including that synctest.Wait does not return, and the network bullet refers to it. --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b7552e2..f321189 100644 --- a/README.md +++ b/README.md @@ -447,9 +447,10 @@ blocked](https://pkg.go.dev/testing/synctest#hdr-Blocking). To test time-depende `quartz.NewReal()`. Inside a bubble it runs on bubble time, so you need no `*Mock`, `Advance()`, or traps. - If the test does real I/O, such as database queries or real network requests, use a `*Mock` - outside a bubble, as in [Mocking](#mocking). Bubble time stops while a goroutine waits on I/O, so - a timeout does not stop a slow query or request. With `go 1.27` or later in `go.mod`, HTTP tests - can stay in the bubble with an `httptest.NewTestServer` created there and its `Client()`. + outside a bubble, as in [Mocking](#mocking). While a goroutine waits on I/O, bubble time does not + move and `synctest.Wait()` does not return, so a timeout does not stop a slow query or request. + With `go 1.27` or later in `go.mod`, HTTP tests can stay in the bubble with an + `httptest.NewTestServer` created there and its `Client()`. - If you want to decide when time moves, or need traps, use a `*Mock`. It ignores bubble time; only `Advance()`, `AdvanceNext()`, and `Set()` move it. @@ -471,10 +472,9 @@ func TestTimeout(t *testing.T) { Inside a bubble: - Do not create an `httptest.NewServer` or leave open a connection that a goroutine reads from, such - as an HTTP client's idle keep-alive connection. While a goroutine waits on the network, bubble - time does not move and `synctest.Wait()` does not return, so the test hangs at its next - `time.Sleep`, timeout, or `synctest.Wait()`, and `synctest.Test` does not return until that - goroutine exits. + as an HTTP client's idle keep-alive connection. That goroutine waits on the network, which stops + bubble time and `synctest.Wait()` as described above, so the test hangs at its next `time.Sleep`, + timeout, or `synctest.Wait()`, and `synctest.Test` does not return until that goroutine exits. - Create a `*Mock` with the `t` that `synctest.Test` passes in. Cleanup on the outer test's `t` runs outside the bubble and can crash the test binary with a fatal error about a `synctest channel from outside bubble`. From 00497a320cf649e2641a84582fb80a7adabc2f42 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:56:46 +0000 Subject: [PATCH 24/26] docs(README.md): say bubble time jumps to the earliest pending timer "Jumps straight to it" read as if bubble time skipped to the long timeout itself. It moves to the earliest pending timer, so a long timeout fires without wall time only once no earlier timer is pending; in a probe, a 24-hour timeout with nothing else pending expired after about 60 microseconds of wall time. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f321189..7fa560f 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,7 @@ Inside a bubble: - Test timeouts created in a bubble, such as the `ctx` you pass to `MustWait`, run on bubble time. Make them longer than the bubble time that passes while they are in use: a 10-second `ctx` expires during a `time.Sleep(time.Minute)`. A long timeout costs no wall time: when every goroutine in the - bubble is durably blocked, bubble time jumps straight to it. + bubble is durably blocked, bubble time jumps to the earliest pending timer. ## Why another time testing library? From 8fc031e58261d0fb1e0f4e8e10ae8a6f874d320d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 19:14:33 +0000 Subject: [PATCH 25/26] docs(README.md): tie synctest to go.mod and place the Mock bullet synctest.Test needs go 1.25 in go.mod, not only a Go 1.25 toolchain: with go 1.24 in go.mod, go vet on Go 1.26.5 reports "synctest.Test requires go1.25 or later". The Mock bullet now says it applies inside or outside a bubble. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7fa560f..c33e547 100644 --- a/README.md +++ b/README.md @@ -439,8 +439,8 @@ existing unit tests. ## Quartz and `testing/synctest` -Since Go 1.25, [`synctest.Test`](https://pkg.go.dev/testing/synctest) runs a test in a "bubble" -whose fake clock moves forward when every goroutine in the bubble is [durably +With `go 1.25` or later in `go.mod`, [`synctest.Test`](https://pkg.go.dev/testing/synctest) runs a +test in a "bubble" whose fake clock moves forward when every goroutine in the bubble is [durably blocked](https://pkg.go.dev/testing/synctest#hdr-Blocking). To test time-dependent code: - If the test does no real I/O, run it in `synctest.Test` and keep the production @@ -451,8 +451,8 @@ blocked](https://pkg.go.dev/testing/synctest#hdr-Blocking). To test time-depende move and `synctest.Wait()` does not return, so a timeout does not stop a slow query or request. With `go 1.27` or later in `go.mod`, HTTP tests can stay in the bubble with an `httptest.NewTestServer` created there and its `Client()`. -- If you want to decide when time moves, or need traps, use a `*Mock`. It ignores bubble time; only - `Advance()`, `AdvanceNext()`, and `Set()` move it. +- If you want to decide when time moves, or need traps, use a `*Mock`, inside or outside a bubble. + It ignores bubble time; only `Advance()`, `AdvanceNext()`, and `Set()` move it. ```go func TestTimeout(t *testing.T) { From 23344f029a62580832ec4b5f038e33d4091e6e03 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 19:14:39 +0000 Subject: [PATCH 26/26] docs(README.md): shorten the synctest.Wait bullet --- README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c33e547..a217cd2 100644 --- a/README.md +++ b/README.md @@ -478,12 +478,10 @@ Inside a bubble: - Create a `*Mock` with the `t` that `synctest.Test` passes in. Cleanup on the outer test's `t` runs outside the bubble and can crash the test binary with a fatal error about a `synctest channel from outside bubble`. -- With a `*Mock`, `synctest.Wait()` can replace a trap that only waits for the code to reach a clock - call, if the code makes that call before it first durably blocks. `synctest.Wait()` returns once - every other goroutine in the bubble is durably blocked or has exited. If a goroutine is then - blocked on the timer that goroutine just created, a following `Advance()` fires that timer; if it - is blocked in `time.Sleep` or on a channel before the call, the timer does not exist yet. In that - case, keep the trap. +- With a `*Mock`, `synctest.Wait()` can replace a trap whose only job is to wait until the code has + made a clock call, such as `NewTimer`, if the code makes that call before it first blocks. If the + code blocks first, for example on a channel or in `time.Sleep`, `synctest.Wait()` returns before + the call and a following `Advance()` misses the timer; keep the trap. - Test timeouts created in a bubble, such as the `ctx` you pass to `MustWait`, run on bubble time. Make them longer than the bubble time that passes while they are in use: a 10-second `ctx` expires during a `time.Sleep(time.Minute)`. A long timeout costs no wall time: when every goroutine in the