chore: add usage information to clock library README (#13594)

Adds a Usage section to the README of the clock testing library.
This commit is contained in:
Spike Curtis
2024-06-25 16:38:32 +04:00
committed by GitHub
parent 136900268e
commit 0d2f14606b

View File

@ -5,6 +5,362 @@ A Go time testing library for writing deterministic unit tests
_Note: Quartz is the name I'm targeting for the standalone open source project when we spin this _Note: Quartz is the name I'm targeting for the standalone open source project when we spin this
out._ out._
Our high level goal is to write unit tests that
1. execute quickly
2. don't flake
3. are straightforward to write and understand
For tests to execute quickly without flakes, we want to focus on _determinism_: the test should run
the same each time, and it should be easy to force the system into a known state (no races) before
executing test assertions. `time.Sleep`, `runtime.Gosched()`, and
polling/[Eventually](https://pkg.go.dev/github.com/stretchr/testify/assert#Eventually) are all
symptoms of an inability to do this easily.
## Usage
### `Clock` interface
In your application code, maintain a reference to a `quartz.Clock` instance to start timers and
tickers, instead of the bare `time` standard library.
```go
import "github.com/coder/quartz"
type Component struct {
...
// for testing
clock quartz.Clock
}
```
Whenever you would call into `time` to start a timer or ticker, call `Component`'s `clock` instead.
In production, set this clock to `quartz.NewReal()` to create a clock that just transparently passes
through to the standard `time` library.
### Mocking
In your tests, you can use a `*Mock` to control the tickers and timers your code under test gets.
```go
import (
"testing"
"github.com/coder/quartz"
)
func TestComponent(t *testing.T) {
mClock := quartz.NewMock(t)
comp := &Component{
...
clock: mClock,
}
}
```
The `*Mock` clock starts at Jan 1, 2024, 00:00 UTC by default, but you can set any start time you'd like prior to your test.
```go
mClock := quartz.NewMock(t)
mClock.Set(time.Date(2021, 6, 18, 12, 0, 0, 0, time.UTC)) // June 18, 2021 @ 12pm UTC
```
#### Advancing the clock
Once you begin setting timers or tickers, you cannot change the time backward, only advance it
forward. You may continue to use `Set()`, but it is often easier and clearer to use `Advance()`.
For example, with a timer:
```go
fired := false
tmr := mClock.Afterfunc(time.Second, func() {
fired = true
})
mClock.Advance(time.Second)
```
When you call `Advance()` it immediately moves the clock forward the given amount, and triggers any
tickers or timers that are scheduled to happen at that time. Any triggered events happen on separate
goroutines, so _do not_ immediately assert the results:
```go
fired := false
tmr := mClock.Afterfunc(time.Second, func() {
fired = true
})
mClock.Advance(time.Second)
// RACE CONDITION, DO NOT DO THIS!
if !fired {
t.Fatal("didn't fire")
}
```
`Advance()` (and `Set()` for that matter) return an `AdvanceWaiter` object you can use to wait for
all triggered events to complete.
```go
fired := false
// set a test timeout so we don't wait the default `go test` timeout for a failure
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
tmr := mClock.Afterfunc(time.Second, func() {
fired = true
})
w := mClock.Advance(time.Second)
err := w.Wait(ctx)
if err != nil {
t.Fatal("AfterFunc f never completed")
}
if !fired {
t.Fatal("didn't fire")
}
```
The construction of waiting for the triggered events and failing the test if they don't complete is
very common, so there is a shorthand:
```go
w := mClock.Advance(time.Second)
err := w.Wait(ctx)
if err != nil {
t.Fatal("AfterFunc f never completed")
}
```
is equivalent to:
```go
w := mClock.Advance(time.Second)
w.MustWait(ctx)
```
or even more briefly:
```go
mClock.Advance(time.Second).MustWait(ctx)
```
### Advance only to the next event
One important restriction on advancing the clock is that you may only advance forward to the next
timer or ticker event and no further. The following will result in a test failure:
```go
func TestAdvanceTooFar(t *testing.T) {
ctx, cancel := context.WithTimeout(10*time.Second)
defer cancel()
mClock := quartz.NewMock(t)
var firedAt time.Time
mClock.AfterFunc(time.Second, func() {
firedAt := mClock.Now()
})
mClock.Advance(2*time.Second).MustWait(ctx)
}
```
This is a deliberate design decision to allow `Advance()` to immediately and synchronously move the
clock forward (even without calling `Wait()` on returned waiter). This helps meet Quartz's design
goals of writing deterministic and easy to understand unit tests. It also allows the clock to be
advanced, deterministically _during_ the execution of a tick or timer function, as explained in the
next sections on Traps.
Advancing multiple events can be accomplished via looping. E.g. if you have a 1-second ticker
```go
for i := 0; i < 10; i++ {
mClock.Advance(time.Second).MustWait(ctx)
}
```
will advance 10 ticks.
If you don't know or don't want to compute the time to the next event, you can use `AdvanceNext()`.
```go
d, w := mClock.AdvanceNext()
w.MustWait(ctx)
// d contains the duration we advanced
```
### Traps
A trap allows you to match specific calls into the library while mocking, block their return,
inspect their arguments, then release them to allow them to return. They help you write
deterministic unit tests even when the code under test executes asynchronously from the test.
You set your traps prior to executing code under test, and then wait for them to be triggered.
```go
func TestTrap(t *testing.T) {
ctx, cancel := context.WithTimeout(10*time.Second)
defer cancel()
mClock := quartz.NewMock(t)
trap := mClock.Trap().AfterFunc()
defer trap.Close() // stop trapping AfterFunc calls
count := 0
go mClock.AfterFunc(time.Hour, func(){
count++
})
call := trap.MustWait(ctx)
call.Release()
if call.Duration != time.Hour {
t.Fatal("wrong duration")
}
// Now that the async call to AfterFunc has occurred, we can advance the clock to trigger it
mClock.Advance(call.Duration).MustWait(ctx)
if count != 1 {
t.Fatal("wrong count")
}
}
```
In this test, the trap serves 2 purposes. Firstly, it allows us to capture and assert the duration
passed to the `AfterFunc` call. Secondly, it prevents a race between setting the timer and advancing
it. Since these things happen on different goroutines, if `Advance()` completes before
`AfterFunc()` is called, then the timer never pops in this test.
Any untrapped calls immediately complete using the current time, and calling `Close()` on a trap
causes the mock clock to stop trapping those calls.
You may also `Advance()` the clock between trapping a call and releasing it. The call uses the
current (mocked) time at the moment it is released.
```go
func TestTrap2(t *testing.T) {
ctx, cancel := context.WithTimeout(10*time.Second)
defer cancel()
mClock := quartz.NewMock(t)
trap := mClock.Trap().Now()
defer trap.Close() // stop trapping AfterFunc calls
var logs []string
done := make(chan struct{})
go func(clk quartz.Clock){
defer close(done)
start := clk.Now()
phase1()
p1end := clk.Now()
logs = append(fmt.Sprintf("Phase 1 took %s", p1end.Sub(start).String()))
phase2()
p2end := clk.Now()
logs = append(fmt.Sprintf("Phase 2 took %s", p2end.Sub(p1end).String()))
}(mClock)
// start
trap.MustWait(ctx).Release()
// phase 1
call := trap.MustWait(ctx)
mClock.Advance(3*time.Second).MustWait(ctx)
call.Release()
// phase 2
call = trap.MustWait(ctx)
mClock.Advance(5*time.Second).MustWait(ctx)
call.Release()
<-done
// Now logs contains []string{"Phase 1 took 3s", "Phase 2 took 5s"}
}
```
### Tags
When multiple goroutines in the code under test call into the Clock, you can use `tags` to
distinguish them in your traps.
```go
trap := mClock.Trap.Now("foo") // traps any calls that contain "foo"
defer trap.Close()
foo := make(chan time.Time)
go func(){
foo <- mClock.Now("foo", "bar")
}()
baz := make(chan time.Time)
go func(){
baz <- mClock.Now("baz")
}()
call := trap.MustWait(ctx)
mClock.Advance(time.Second).MustWait(ctx)
call.Release()
// call.Tags contains []string{"foo", "bar"}
gotFoo := <-foo // 1s after start
gotBaz := <-baz // ?? never trapped, so races with Advance()
```
Tags appear as an optional suffix on all `Clock` methods (type `...string`) and are ignored entirely
by the real clock. They also appear on all methods on returned timers and tickers.
## Recommended Patterns
### Options
We use the Option pattern to inject the mock clock for testing, keeping the call signature in
production clean. The option pattern is compatible with other optional fields as well.
```go
type Option func(*Thing)
// WithTestClock is used in tests to inject a mock Clock
func WithTestClock(clk quartz.Clock) Option {
return func(t *Thing) {
t.clock = clk
}
}
func NewThing(<required args>, opts ...Option) *Thing {
t := &Thing{
...
clock: quartz.NewReal()
}
for _, o := range opts {
o(t)
}
return t
}
```
In tests, this becomes
```go
func TestThing(t *testing.T) {
mClock := quartz.NewMock(t)
thing := NewThing(<required args>, WithTestClock(mClock))
...
}
```
### Tagging convention
Tag your `Clock` method calls as:
```go
func (c *Component) Method() {
now := c.clock.Now("Component", "Method")
}
```
or
```go
func (c *Component) Method() {
start := c.clock.Now("Component", "Method", "start")
...
end := c.clock.Now("Component", "Method", "end")
}
```
This makes it much less likely that code changes that introduce new components or methods will spoil
existing unit tests.
## Why another time testing library? ## Why another time testing library?
Writing good unit tests for components and functions that use the `time` package is difficult, even Writing good unit tests for components and functions that use the `time` package is difficult, even
@ -18,7 +374,7 @@ Quartz shares the high level design of a `Clock` interface that closely resemble
the `time` standard library, and a "real" clock passes thru to the standard library in production, the `time` standard library, and a "real" clock passes thru to the standard library in production,
while a mock clock gives precise control in testing. while a mock clock gives precise control in testing.
Our high level goal is to write unit tests that As mentioned in our introduction, our high level goal is to write unit tests that
1. execute quickly 1. execute quickly
2. don't flake 2. don't flake
@ -27,12 +383,6 @@ Our high level goal is to write unit tests that
For several reasons, this is a tall order when it comes to code that depends on time, and we found For several reasons, this is a tall order when it comes to code that depends on time, and we found
the existing libraries insufficient for our goals. the existing libraries insufficient for our goals.
For tests to execute quickly without flakes, we want to focus on _determinism_: the test should run
the same each time, and it should be easy to force the system into a known state (no races) before
executing test assertions. `time.Sleep`, `runtime.Gosched()`, and
polling/[Eventually](https://pkg.go.dev/github.com/stretchr/testify/assert#Eventually) are all
symptoms of an inability to do this easily.
### Preventing test flakes ### Preventing test flakes
The following example comes from the README from benbjohnson/clock: The following example comes from the README from benbjohnson/clock: