mirror of https://github.com/grafana/loki
chore: Use github.com/coder/quartz instead of time (#13542)
parent
646f42f71d
commit
6acb51d485
@ -0,0 +1 @@ |
||||
.idea/ |
||||
@ -0,0 +1,121 @@ |
||||
Creative Commons Legal Code |
||||
|
||||
CC0 1.0 Universal |
||||
|
||||
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE |
||||
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN |
||||
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS |
||||
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES |
||||
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS |
||||
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM |
||||
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED |
||||
HEREUNDER. |
||||
|
||||
Statement of Purpose |
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer |
||||
exclusive Copyright and Related Rights (defined below) upon the creator |
||||
and subsequent owner(s) (each and all, an "owner") of an original work of |
||||
authorship and/or a database (each, a "Work"). |
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for |
||||
the purpose of contributing to a commons of creative, cultural and |
||||
scientific works ("Commons") that the public can reliably and without fear |
||||
of later claims of infringement build upon, modify, incorporate in other |
||||
works, reuse and redistribute as freely as possible in any form whatsoever |
||||
and for any purposes, including without limitation commercial purposes. |
||||
These owners may contribute to the Commons to promote the ideal of a free |
||||
culture and the further production of creative, cultural and scientific |
||||
works, or to gain reputation or greater distribution for their Work in |
||||
part through the use and efforts of others. |
||||
|
||||
For these and/or other purposes and motivations, and without any |
||||
expectation of additional consideration or compensation, the person |
||||
associating CC0 with a Work (the "Affirmer"), to the extent that he or she |
||||
is an owner of Copyright and Related Rights in the Work, voluntarily |
||||
elects to apply CC0 to the Work and publicly distribute the Work under its |
||||
terms, with knowledge of his or her Copyright and Related Rights in the |
||||
Work and the meaning and intended legal effect of CC0 on those rights. |
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be |
||||
protected by copyright and related or neighboring rights ("Copyright and |
||||
Related Rights"). Copyright and Related Rights include, but are not |
||||
limited to, the following: |
||||
|
||||
i. the right to reproduce, adapt, distribute, perform, display, |
||||
communicate, and translate a Work; |
||||
ii. moral rights retained by the original author(s) and/or performer(s); |
||||
iii. publicity and privacy rights pertaining to a person's image or |
||||
likeness depicted in a Work; |
||||
iv. rights protecting against unfair competition in regards to a Work, |
||||
subject to the limitations in paragraph 4(a), below; |
||||
v. rights protecting the extraction, dissemination, use and reuse of data |
||||
in a Work; |
||||
vi. database rights (such as those arising under Directive 96/9/EC of the |
||||
European Parliament and of the Council of 11 March 1996 on the legal |
||||
protection of databases, and under any national implementation |
||||
thereof, including any amended or successor version of such |
||||
directive); and |
||||
vii. other similar, equivalent or corresponding rights throughout the |
||||
world based on applicable law or treaty, and any national |
||||
implementations thereof. |
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention |
||||
of, applicable law, Affirmer hereby overtly, fully, permanently, |
||||
irrevocably and unconditionally waives, abandons, and surrenders all of |
||||
Affirmer's Copyright and Related Rights and associated claims and causes |
||||
of action, whether now known or unknown (including existing as well as |
||||
future claims and causes of action), in the Work (i) in all territories |
||||
worldwide, (ii) for the maximum duration provided by applicable law or |
||||
treaty (including future time extensions), (iii) in any current or future |
||||
medium and for any number of copies, and (iv) for any purpose whatsoever, |
||||
including without limitation commercial, advertising or promotional |
||||
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each |
||||
member of the public at large and to the detriment of Affirmer's heirs and |
||||
successors, fully intending that such Waiver shall not be subject to |
||||
revocation, rescission, cancellation, termination, or any other legal or |
||||
equitable action to disrupt the quiet enjoyment of the Work by the public |
||||
as contemplated by Affirmer's express Statement of Purpose. |
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason |
||||
be judged legally invalid or ineffective under applicable law, then the |
||||
Waiver shall be preserved to the maximum extent permitted taking into |
||||
account Affirmer's express Statement of Purpose. In addition, to the |
||||
extent the Waiver is so judged Affirmer hereby grants to each affected |
||||
person a royalty-free, non transferable, non sublicensable, non exclusive, |
||||
irrevocable and unconditional license to exercise Affirmer's Copyright and |
||||
Related Rights in the Work (i) in all territories worldwide, (ii) for the |
||||
maximum duration provided by applicable law or treaty (including future |
||||
time extensions), (iii) in any current or future medium and for any number |
||||
of copies, and (iv) for any purpose whatsoever, including without |
||||
limitation commercial, advertising or promotional purposes (the |
||||
"License"). The License shall be deemed effective as of the date CC0 was |
||||
applied by Affirmer to the Work. Should any part of the License for any |
||||
reason be judged legally invalid or ineffective under applicable law, such |
||||
partial invalidity or ineffectiveness shall not invalidate the remainder |
||||
of the License, and in such case Affirmer hereby affirms that he or she |
||||
will not (i) exercise any of his or her remaining Copyright and Related |
||||
Rights in the Work or (ii) assert any associated claims and causes of |
||||
action with respect to the Work, in either case contrary to Affirmer's |
||||
express Statement of Purpose. |
||||
|
||||
4. Limitations and Disclaimers. |
||||
|
||||
a. No trademark or patent rights held by Affirmer are waived, abandoned, |
||||
surrendered, licensed or otherwise affected by this document. |
||||
b. Affirmer offers the Work as-is and makes no representations or |
||||
warranties of any kind concerning the Work, express, implied, |
||||
statutory or otherwise, including without limitation warranties of |
||||
title, merchantability, fitness for a particular purpose, non |
||||
infringement, or the absence of latent or other defects, accuracy, or |
||||
the present or absence of errors, whether or not discoverable, all to |
||||
the greatest extent permissible under applicable law. |
||||
c. Affirmer disclaims responsibility for clearing rights of other persons |
||||
that may apply to the Work or any use thereof, including without |
||||
limitation any person's Copyright and Related Rights in the Work. |
||||
Further, Affirmer disclaims responsibility for obtaining any necessary |
||||
consents, permissions or other rights required for any use of the |
||||
Work. |
||||
d. Affirmer understands and acknowledges that Creative Commons is not a |
||||
party to this document and has no duty or obligation with respect to |
||||
this CC0 or use of the Work. |
||||
@ -0,0 +1,632 @@ |
||||
# Quartz |
||||
|
||||
A Go time testing library for writing deterministic unit tests |
||||
|
||||
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 |
||||
``` |
||||
|
||||
`d, ok := Peek()` returns the duration until the next event, if any (`ok` is `true`). You can use |
||||
this to advance a specific time, regardless of the tickers and timer events: |
||||
|
||||
```go |
||||
desired := time.Minute // time to advance |
||||
for desired > 0 { |
||||
p, ok := mClock.Peek() |
||||
if !ok || p > desired { |
||||
mClock.Advance(desired).MustWait(ctx) |
||||
break |
||||
} |
||||
mClock.Advance(p).MustWait(ctx) |
||||
desired -= p |
||||
} |
||||
``` |
||||
|
||||
### 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? |
||||
|
||||
Writing good unit tests for components and functions that use the `time` package is difficult, even |
||||
though several open source libraries exist. In building Quartz, we took some inspiration from |
||||
|
||||
- [github.com/benbjohnson/clock](https://github.com/benbjohnson/clock) |
||||
- Tailscale's [tstest.Clock](https://github.com/coder/tailscale/blob/main/tstest/clock.go) |
||||
- [github.com/aspenmesh/tock](https://github.com/aspenmesh/tock) |
||||
|
||||
Quartz shares the high level design of a `Clock` interface that closely resembles the functions in |
||||
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. |
||||
|
||||
As mentioned in our introduction, 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 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. |
||||
|
||||
### Preventing test flakes |
||||
|
||||
The following example comes from the README from benbjohnson/clock: |
||||
|
||||
```go |
||||
mock := clock.NewMock() |
||||
count := 0 |
||||
|
||||
// Kick off a timer to increment every 1 mock second. |
||||
go func() { |
||||
ticker := mock.Ticker(1 * time.Second) |
||||
for { |
||||
<-ticker.C |
||||
count++ |
||||
} |
||||
}() |
||||
runtime.Gosched() |
||||
|
||||
// Move the clock forward 10 seconds. |
||||
mock.Add(10 * time.Second) |
||||
|
||||
// This prints 10. |
||||
fmt.Println(count) |
||||
``` |
||||
|
||||
The first race condition is fairly obvious: moving the clock forward 10 seconds may generate 10 |
||||
ticks on the `ticker.C` channel, but there is no guarantee that `count++` executes before |
||||
`fmt.Println(count)`. |
||||
|
||||
The second race condition is more subtle, but `runtime.Gosched()` is the tell. Since the ticker |
||||
is started on a separate goroutine, there is no guarantee that `mock.Ticker()` executes before |
||||
`mock.Add()`. `runtime.Gosched()` is an attempt to get this to happen, but it makes no hard |
||||
promises. On a busy system, especially when running tests in parallel, this can flake, advance the |
||||
time 10 seconds first, then start the ticker and never generate a tick. |
||||
|
||||
Let's talk about how Quartz tackles these problems. |
||||
|
||||
In our experience, an extremely common use case is creating a ticker then doing a 2-arm `select` |
||||
with ticks in one and context expiring in another, i.e. |
||||
|
||||
```go |
||||
t := time.NewTicker(duration) |
||||
for { |
||||
select { |
||||
case <-ctx.Done(): |
||||
return ctx.Err() |
||||
case <-t.C: |
||||
err := do() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
In Quartz, we refactor this to be more compact and testing friendly: |
||||
|
||||
```go |
||||
t := clock.TickerFunc(ctx, duration, do) |
||||
return t.Wait() |
||||
``` |
||||
|
||||
This affords the mock `Clock` the ability to explicitly know when processing of a tick is finished |
||||
because it's wrapped in the function passed to `TickerFunc` (`do()` in this example). |
||||
|
||||
In Quartz, when you advance the clock, you are returned an object you can `Wait()` on to ensure all |
||||
ticks and timers triggered are finished. This solves the first race condition in the example. |
||||
|
||||
(As an aside, we still support a traditional standard library-style `Ticker`. You may find it useful |
||||
if you want to keep your code as close as possible to the standard library, or if you need to use |
||||
the channel in a larger `select` block. In that case, you'll have to find some other mechanism to |
||||
sync tick processing to your test code.) |
||||
|
||||
To prevent race conditions related to the starting of the ticker, Quartz allows you to set "traps" |
||||
for calls that access the clock. |
||||
|
||||
```go |
||||
func TestTicker(t *testing.T) { |
||||
mClock := quartz.NewMock(t) |
||||
trap := mClock.Trap().TickerFunc() |
||||
defer trap.Close() // stop trapping at end |
||||
go runMyTicker(mClock) // async calls TickerFunc() |
||||
call := trap.Wait(context.Background()) // waits for a call and blocks its return |
||||
call.Release() // allow the TickerFunc() call to return |
||||
// optionally check the duration using call.Duration |
||||
// Move the clock forward 1 tick |
||||
mClock.Advance(time.Second).MustWait(context.Background()) |
||||
// assert results of the tick |
||||
} |
||||
``` |
||||
|
||||
Trapping and then releasing the call to `TickerFunc()` ensures the ticker is started at a |
||||
deterministic time, so our calls to `Advance()` will have a predictable effect. |
||||
|
||||
Take a look at `TestExampleTickerFunc` in `example_test.go` for a complete worked example. |
||||
|
||||
### Complex time dependence |
||||
|
||||
Another difficult issue to handle when unit testing is when some code under test makes multiple |
||||
calls that depend on the time, and you want to simulate some time passing between them. |
||||
|
||||
A very basic example is measuring how long something took: |
||||
|
||||
```go |
||||
var measurement time.Duration |
||||
go func(clock quartz.Clock) { |
||||
start := clock.Now() |
||||
doSomething() |
||||
measurement = clock.Since(start) |
||||
}(mClock) |
||||
|
||||
// how to get measurement to be, say, 5 seconds? |
||||
``` |
||||
|
||||
The two calls into the clock happen asynchronously, so we need to be able to advance the clock after |
||||
the first call to `Now()` but before the call to `Since()`. Doing this with the libraries we |
||||
mentioned above means that you have to be able to mock out or otherwise block the completion of |
||||
`doSomething()`. |
||||
|
||||
But, with the trap functionality we mentioned in the previous section, you can deterministically |
||||
control the time each call sees. |
||||
|
||||
```go |
||||
trap := mClock.Trap().Since() |
||||
var measurement time.Duration |
||||
go func(clock quartz.Clock) { |
||||
start := clock.Now() |
||||
doSomething() |
||||
measurement = clock.Since(start) |
||||
}(mClock) |
||||
|
||||
c := trap.Wait(ctx) |
||||
mClock.Advance(5*time.Second) |
||||
c.Release() |
||||
``` |
||||
|
||||
We wait until we trap the `clock.Since()` call, which implies that `clock.Now()` has completed, then |
||||
advance the mock clock 5 seconds. Finally, we release the `clock.Since()` call. Any changes to the |
||||
clock that happen _before_ we release the call will be included in the time used for the |
||||
`clock.Since()` call. |
||||
|
||||
As a more involved example, consider an inactivity timeout: we want something to happen if there is |
||||
no activity recorded for some period, say 10 minutes in the following example: |
||||
|
||||
```go |
||||
type InactivityTimer struct { |
||||
mu sync.Mutex |
||||
activity time.Time |
||||
clock quartz.Clock |
||||
} |
||||
|
||||
func (i *InactivityTimer) Start() { |
||||
i.mu.Lock() |
||||
defer i.mu.Unlock() |
||||
next := i.clock.Until(i.activity.Add(10*time.Minute)) |
||||
t := i.clock.AfterFunc(next, func() { |
||||
i.mu.Lock() |
||||
defer i.mu.Unlock() |
||||
next := i.clock.Until(i.activity.Add(10*time.Minute)) |
||||
if next == 0 { |
||||
i.timeoutLocked() |
||||
return |
||||
} |
||||
t.Reset(next) |
||||
}) |
||||
} |
||||
``` |
||||
|
||||
The actual contents of `timeoutLocked()` doesn't matter for this example, and assume there are other |
||||
functions that record the latest `activity`. |
||||
|
||||
We found that some time testing libraries hold a lock on the mock clock while calling the function |
||||
passed to `AfterFunc`, resulting in a deadlock if you made clock calls from within. |
||||
|
||||
Others allow this sort of thing, but don't have the flexibility to test edge cases. There is a |
||||
subtle bug in our `Start()` function. The timer may pop a little late, and/or some measurable real |
||||
time may elapse before `Until()` gets called inside the `AfterFunc`. If there hasn't been activity, |
||||
`next` might be negative. |
||||
|
||||
To test this in Quartz, we'll use a trap. We only want to trap the inner `Until()` call, not the |
||||
initial one, so to make testing easier we can "tag" the call we want. Like this: |
||||
|
||||
```go |
||||
func (i *InactivityTimer) Start() { |
||||
i.mu.Lock() |
||||
defer i.mu.Unlock() |
||||
next := i.clock.Until(i.activity.Add(10*time.Minute)) |
||||
t := i.clock.AfterFunc(next, func() { |
||||
i.mu.Lock() |
||||
defer i.mu.Unlock() |
||||
next := i.clock.Until(i.activity.Add(10*time.Minute), "inner") |
||||
if next == 0 { |
||||
i.timeoutLocked() |
||||
return |
||||
} |
||||
t.Reset(next) |
||||
}) |
||||
} |
||||
``` |
||||
|
||||
All Quartz `Clock` functions, and functions on returned timers and tickers support zero or more |
||||
string tags that allow traps to match on them. |
||||
|
||||
```go |
||||
func TestInactivityTimer_Late(t *testing.T) { |
||||
// set a timeout on the test itself, so that if Wait functions get blocked, we don't have to |
||||
// wait for the default test timeout of 10 minutes. |
||||
ctx, cancel := context.WithTimeout(10*time.Second) |
||||
defer cancel() |
||||
mClock := quartz.NewMock(t) |
||||
trap := mClock.Trap.Until("inner") |
||||
defer trap.Close() |
||||
|
||||
it := &InactivityTimer{ |
||||
activity: mClock.Now(), |
||||
clock: mClock, |
||||
} |
||||
it.Start() |
||||
|
||||
// Trigger the AfterFunc |
||||
w := mClock.Advance(10*time.Minute) |
||||
c := trap.Wait(ctx) |
||||
// Advance the clock a few ms to simulate a busy system |
||||
mClock.Advance(3*time.Millisecond) |
||||
c.Release() // Until() returns |
||||
w.MustWait(ctx) // Wait for the AfterFunc to wrap up |
||||
|
||||
// Assert that the timeoutLocked() function was called |
||||
} |
||||
``` |
||||
|
||||
This test case will fail with our bugged implementation, since the triggered AfterFunc won't call |
||||
`timeoutLocked()` and instead will reset the timer with a negative number. The fix is easy, use |
||||
`next <= 0` as the comparison. |
||||
@ -0,0 +1,43 @@ |
||||
// Package quartz is a library for testing time related code. It exports an interface Clock that
|
||||
// mimics the standard library time package functions. In production, an implementation that calls
|
||||
// thru to the standard library is used. In testing, a Mock clock is used to precisely control and
|
||||
// intercept time functions.
|
||||
package quartz |
||||
|
||||
import ( |
||||
"context" |
||||
"time" |
||||
) |
||||
|
||||
type Clock interface { |
||||
// NewTicker returns a new Ticker containing a channel that will send the current time on the
|
||||
// channel after each tick. The period of the ticks is specified by the duration argument. The
|
||||
// ticker will adjust the time interval or drop ticks to make up for slow receivers. The
|
||||
// duration d must be greater than zero; if not, NewTicker will panic. Stop the ticker to
|
||||
// release associated resources.
|
||||
NewTicker(d time.Duration, tags ...string) *Ticker |
||||
// TickerFunc is a convenience function that calls f on the interval d until either the given
|
||||
// context expires or f returns an error. Callers may call Wait() on the returned Waiter to
|
||||
// wait until this happens and obtain the error. The duration d must be greater than zero; if
|
||||
// not, TickerFunc will panic.
|
||||
TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter |
||||
// NewTimer creates a new Timer that will send the current time on its channel after at least
|
||||
// duration d.
|
||||
NewTimer(d time.Duration, tags ...string) *Timer |
||||
// AfterFunc waits for the duration to elapse and then calls f in its own goroutine. It returns
|
||||
// a Timer that can be used to cancel the call using its Stop method. The returned Timer's C
|
||||
// field is not used and will be nil.
|
||||
AfterFunc(d time.Duration, f func(), tags ...string) *Timer |
||||
|
||||
// Now returns the current local time.
|
||||
Now(tags ...string) time.Time |
||||
// Since returns the time elapsed since t. It is shorthand for Clock.Now().Sub(t).
|
||||
Since(t time.Time, tags ...string) time.Duration |
||||
// Until returns the duration until t. It is shorthand for t.Sub(Clock.Now()).
|
||||
Until(t time.Time, tags ...string) time.Duration |
||||
} |
||||
|
||||
// Waiter can be waited on for an error.
|
||||
type Waiter interface { |
||||
Wait(tags ...string) error |
||||
} |
||||
@ -0,0 +1,646 @@ |
||||
package quartz |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"slices" |
||||
"sync" |
||||
"testing" |
||||
"time" |
||||
) |
||||
|
||||
// Mock is the testing implementation of Clock. It tracks a time that monotonically increases
|
||||
// during a test, triggering any timers or tickers automatically.
|
||||
type Mock struct { |
||||
tb testing.TB |
||||
mu sync.Mutex |
||||
|
||||
// cur is the current time
|
||||
cur time.Time |
||||
|
||||
all []event |
||||
nextTime time.Time |
||||
nextEvents []event |
||||
traps []*Trap |
||||
} |
||||
|
||||
type event interface { |
||||
next() time.Time |
||||
fire(t time.Time) |
||||
} |
||||
|
||||
func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter { |
||||
if d <= 0 { |
||||
panic("TickerFunc called with negative or zero duration") |
||||
} |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
c := newCall(clockFunctionTickerFunc, tags, withDuration(d)) |
||||
m.matchCallLocked(c) |
||||
defer close(c.complete) |
||||
t := &mockTickerFunc{ |
||||
ctx: ctx, |
||||
d: d, |
||||
f: f, |
||||
nxt: m.cur.Add(d), |
||||
mock: m, |
||||
cond: sync.NewCond(&m.mu), |
||||
} |
||||
m.all = append(m.all, t) |
||||
m.recomputeNextLocked() |
||||
go t.waitForCtx() |
||||
return t |
||||
} |
||||
|
||||
func (m *Mock) NewTicker(d time.Duration, tags ...string) *Ticker { |
||||
if d <= 0 { |
||||
panic("NewTicker called with negative or zero duration") |
||||
} |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
c := newCall(clockFunctionNewTicker, tags, withDuration(d)) |
||||
m.matchCallLocked(c) |
||||
defer close(c.complete) |
||||
// 1 element buffer follows standard library implementation
|
||||
ticks := make(chan time.Time, 1) |
||||
t := &Ticker{ |
||||
C: ticks, |
||||
c: ticks, |
||||
d: d, |
||||
nxt: m.cur.Add(d), |
||||
mock: m, |
||||
} |
||||
m.addEventLocked(t) |
||||
return t |
||||
} |
||||
|
||||
func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
c := newCall(clockFunctionNewTimer, tags, withDuration(d)) |
||||
defer close(c.complete) |
||||
m.matchCallLocked(c) |
||||
ch := make(chan time.Time, 1) |
||||
t := &Timer{ |
||||
C: ch, |
||||
c: ch, |
||||
nxt: m.cur.Add(d), |
||||
mock: m, |
||||
} |
||||
if d <= 0 { |
||||
// zero or negative duration timer means we should immediately fire
|
||||
// it, rather than add it.
|
||||
go t.fire(t.mock.cur) |
||||
return t |
||||
} |
||||
m.addEventLocked(t) |
||||
return t |
||||
} |
||||
|
||||
func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
c := newCall(clockFunctionAfterFunc, tags, withDuration(d)) |
||||
defer close(c.complete) |
||||
m.matchCallLocked(c) |
||||
t := &Timer{ |
||||
nxt: m.cur.Add(d), |
||||
fn: f, |
||||
mock: m, |
||||
} |
||||
if d <= 0 { |
||||
// zero or negative duration timer means we should immediately fire
|
||||
// it, rather than add it.
|
||||
go t.fire(t.mock.cur) |
||||
return t |
||||
} |
||||
m.addEventLocked(t) |
||||
return t |
||||
} |
||||
|
||||
func (m *Mock) Now(tags ...string) time.Time { |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
c := newCall(clockFunctionNow, tags) |
||||
defer close(c.complete) |
||||
m.matchCallLocked(c) |
||||
return m.cur |
||||
} |
||||
|
||||
func (m *Mock) Since(t time.Time, tags ...string) time.Duration { |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
c := newCall(clockFunctionSince, tags, withTime(t)) |
||||
defer close(c.complete) |
||||
m.matchCallLocked(c) |
||||
return m.cur.Sub(t) |
||||
} |
||||
|
||||
func (m *Mock) Until(t time.Time, tags ...string) time.Duration { |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
c := newCall(clockFunctionUntil, tags, withTime(t)) |
||||
defer close(c.complete) |
||||
m.matchCallLocked(c) |
||||
return t.Sub(m.cur) |
||||
} |
||||
|
||||
func (m *Mock) addEventLocked(e event) { |
||||
m.all = append(m.all, e) |
||||
m.recomputeNextLocked() |
||||
} |
||||
|
||||
func (m *Mock) recomputeNextLocked() { |
||||
var best time.Time |
||||
var events []event |
||||
for _, e := range m.all { |
||||
if best.IsZero() || e.next().Before(best) { |
||||
best = e.next() |
||||
events = []event{e} |
||||
continue |
||||
} |
||||
if e.next().Equal(best) { |
||||
events = append(events, e) |
||||
continue |
||||
} |
||||
} |
||||
m.nextTime = best |
||||
m.nextEvents = events |
||||
} |
||||
|
||||
func (m *Mock) removeTimer(t *Timer) { |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
m.removeTimerLocked(t) |
||||
} |
||||
|
||||
func (m *Mock) removeTimerLocked(t *Timer) { |
||||
t.stopped = true |
||||
m.removeEventLocked(t) |
||||
} |
||||
|
||||
func (m *Mock) removeEventLocked(e event) { |
||||
defer m.recomputeNextLocked() |
||||
for i := range m.all { |
||||
if m.all[i] == e { |
||||
m.all = append(m.all[:i], m.all[i+1:]...) |
||||
return |
||||
} |
||||
} |
||||
} |
||||
|
||||
func (m *Mock) matchCallLocked(c *Call) { |
||||
var traps []*Trap |
||||
for _, t := range m.traps { |
||||
if t.matches(c) { |
||||
traps = append(traps, t) |
||||
} |
||||
} |
||||
if len(traps) == 0 { |
||||
return |
||||
} |
||||
c.releases.Add(len(traps)) |
||||
m.mu.Unlock() |
||||
for _, t := range traps { |
||||
go t.catch(c) |
||||
} |
||||
c.releases.Wait() |
||||
m.mu.Lock() |
||||
} |
||||
|
||||
// AdvanceWaiter is returned from Advance and Set calls and allows you to wait for ticks and timers
|
||||
// to complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for the
|
||||
// functions to return. For other ticks & timers, it just waits for the tick to be delivered to
|
||||
// the channel.
|
||||
//
|
||||
// If multiple timers or tickers trigger simultaneously, they are all run on separate
|
||||
// go routines.
|
||||
type AdvanceWaiter struct { |
||||
tb testing.TB |
||||
ch chan struct{} |
||||
} |
||||
|
||||
// Wait for all timers and ticks to complete, or until context expires.
|
||||
func (w AdvanceWaiter) Wait(ctx context.Context) error { |
||||
select { |
||||
case <-w.ch: |
||||
return nil |
||||
case <-ctx.Done(): |
||||
return ctx.Err() |
||||
} |
||||
} |
||||
|
||||
// MustWait waits for all timers and ticks to complete, and fails the test immediately if the
|
||||
// context completes first. MustWait must be called from the goroutine running the test or
|
||||
// benchmark, similar to `t.FailNow()`.
|
||||
func (w AdvanceWaiter) MustWait(ctx context.Context) { |
||||
w.tb.Helper() |
||||
select { |
||||
case <-w.ch: |
||||
return |
||||
case <-ctx.Done(): |
||||
w.tb.Fatalf("context expired while waiting for clock to advance: %s", ctx.Err()) |
||||
} |
||||
} |
||||
|
||||
// Done returns a channel that is closed when all timers and ticks complete.
|
||||
func (w AdvanceWaiter) Done() <-chan struct{} { |
||||
return w.ch |
||||
} |
||||
|
||||
// Advance moves the clock forward by d, triggering any timers or tickers. The returned value can
|
||||
// be used to wait for all timers and ticks to complete. Advance sets the clock forward before
|
||||
// returning, and can only advance up to the next timer or tick event. It will fail the test if you
|
||||
// attempt to advance beyond.
|
||||
//
|
||||
// If you need to advance exactly to the next event, and don't know or don't wish to calculate it,
|
||||
// consider AdvanceNext().
|
||||
func (m *Mock) Advance(d time.Duration) AdvanceWaiter { |
||||
m.tb.Helper() |
||||
w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} |
||||
m.mu.Lock() |
||||
fin := m.cur.Add(d) |
||||
// nextTime.IsZero implies no events scheduled.
|
||||
if m.nextTime.IsZero() || fin.Before(m.nextTime) { |
||||
m.cur = fin |
||||
m.mu.Unlock() |
||||
close(w.ch) |
||||
return w |
||||
} |
||||
if fin.After(m.nextTime) { |
||||
m.tb.Errorf(fmt.Sprintf("cannot advance %s which is beyond next timer/ticker event in %s", |
||||
d.String(), m.nextTime.Sub(m.cur))) |
||||
m.mu.Unlock() |
||||
close(w.ch) |
||||
return w |
||||
} |
||||
|
||||
m.cur = m.nextTime |
||||
go m.advanceLocked(w) |
||||
return w |
||||
} |
||||
|
||||
func (m *Mock) advanceLocked(w AdvanceWaiter) { |
||||
defer close(w.ch) |
||||
wg := sync.WaitGroup{} |
||||
for i := range m.nextEvents { |
||||
e := m.nextEvents[i] |
||||
t := m.cur |
||||
wg.Add(1) |
||||
go func() { |
||||
e.fire(t) |
||||
wg.Done() |
||||
}() |
||||
} |
||||
// release the lock and let the events resolve. This allows them to call back into the
|
||||
// Mock to query the time or set new timers. Each event should remove or reschedule
|
||||
// itself from nextEvents.
|
||||
m.mu.Unlock() |
||||
wg.Wait() |
||||
} |
||||
|
||||
// Set the time to t. If the time is after the current mocked time, then this is equivalent to
|
||||
// Advance() with the difference. You may only Set the time earlier than the current time before
|
||||
// starting tickers and timers (e.g. at the start of your test case).
|
||||
func (m *Mock) Set(t time.Time) AdvanceWaiter { |
||||
m.tb.Helper() |
||||
w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} |
||||
m.mu.Lock() |
||||
if t.Before(m.cur) { |
||||
defer close(w.ch) |
||||
defer m.mu.Unlock() |
||||
// past
|
||||
if !m.nextTime.IsZero() { |
||||
m.tb.Error("Set mock clock to the past after timers/tickers started") |
||||
} |
||||
m.cur = t |
||||
return w |
||||
} |
||||
// future
|
||||
// nextTime.IsZero implies no events scheduled.
|
||||
if m.nextTime.IsZero() || t.Before(m.nextTime) { |
||||
defer close(w.ch) |
||||
defer m.mu.Unlock() |
||||
m.cur = t |
||||
return w |
||||
} |
||||
if t.After(m.nextTime) { |
||||
defer close(w.ch) |
||||
defer m.mu.Unlock() |
||||
m.tb.Errorf("cannot Set time to %s which is beyond next timer/ticker event at %s", |
||||
t.String(), m.nextTime) |
||||
return w |
||||
} |
||||
|
||||
m.cur = m.nextTime |
||||
go m.advanceLocked(w) |
||||
return w |
||||
} |
||||
|
||||
// AdvanceNext advances the clock to the next timer or tick event. It fails the test if there are
|
||||
// none scheduled. It returns the duration the clock was advanced and a waiter that can be used to
|
||||
// wait for the timer/tick event(s) to finish.
|
||||
func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { |
||||
m.mu.Lock() |
||||
m.tb.Helper() |
||||
w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} |
||||
if m.nextTime.IsZero() { |
||||
defer close(w.ch) |
||||
defer m.mu.Unlock() |
||||
m.tb.Error("cannot AdvanceNext because there are no timers or tickers running") |
||||
} |
||||
d := m.nextTime.Sub(m.cur) |
||||
m.cur = m.nextTime |
||||
go m.advanceLocked(w) |
||||
return d, w |
||||
} |
||||
|
||||
// Peek returns the duration until the next ticker or timer event and the value
|
||||
// true, or, if there are no running tickers or timers, it returns zero and
|
||||
// false.
|
||||
func (m *Mock) Peek() (d time.Duration, ok bool) { |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
if m.nextTime.IsZero() { |
||||
return 0, false |
||||
} |
||||
return m.nextTime.Sub(m.cur), true |
||||
} |
||||
|
||||
// Trapper allows the creation of Traps
|
||||
type Trapper struct { |
||||
// mock is the underlying Mock. This is a thin wrapper around Mock so that
|
||||
// we can have our interface look like mClock.Trap().NewTimer("foo")
|
||||
mock *Mock |
||||
} |
||||
|
||||
func (t Trapper) NewTimer(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionNewTimer, tags) |
||||
} |
||||
|
||||
func (t Trapper) AfterFunc(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionAfterFunc, tags) |
||||
} |
||||
|
||||
func (t Trapper) TimerStop(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionTimerStop, tags) |
||||
} |
||||
|
||||
func (t Trapper) TimerReset(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionTimerReset, tags) |
||||
} |
||||
|
||||
func (t Trapper) TickerFunc(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionTickerFunc, tags) |
||||
} |
||||
|
||||
func (t Trapper) TickerFuncWait(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionTickerFuncWait, tags) |
||||
} |
||||
|
||||
func (t Trapper) NewTicker(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionNewTicker, tags) |
||||
} |
||||
|
||||
func (t Trapper) TickerStop(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionTickerStop, tags) |
||||
} |
||||
|
||||
func (t Trapper) TickerReset(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionTickerReset, tags) |
||||
} |
||||
|
||||
func (t Trapper) Now(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionNow, tags) |
||||
} |
||||
|
||||
func (t Trapper) Since(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionSince, tags) |
||||
} |
||||
|
||||
func (t Trapper) Until(tags ...string) *Trap { |
||||
return t.mock.newTrap(clockFunctionUntil, tags) |
||||
} |
||||
|
||||
func (m *Mock) Trap() Trapper { |
||||
return Trapper{m} |
||||
} |
||||
|
||||
func (m *Mock) newTrap(fn clockFunction, tags []string) *Trap { |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
tr := &Trap{ |
||||
fn: fn, |
||||
tags: tags, |
||||
mock: m, |
||||
calls: make(chan *Call), |
||||
done: make(chan struct{}), |
||||
} |
||||
m.traps = append(m.traps, tr) |
||||
return tr |
||||
} |
||||
|
||||
// NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024.
|
||||
// You may re-set the time earlier than this, but only before timers or tickers
|
||||
// are created.
|
||||
func NewMock(tb testing.TB) *Mock { |
||||
cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") |
||||
if err != nil { |
||||
panic(err) |
||||
} |
||||
return &Mock{ |
||||
tb: tb, |
||||
cur: cur, |
||||
} |
||||
} |
||||
|
||||
var _ Clock = &Mock{} |
||||
|
||||
type mockTickerFunc struct { |
||||
ctx context.Context |
||||
d time.Duration |
||||
f func() error |
||||
nxt time.Time |
||||
mock *Mock |
||||
|
||||
// cond is a condition Locked on the main Mock.mu
|
||||
cond *sync.Cond |
||||
// done is true when the ticker exits
|
||||
done bool |
||||
// err holds the error when the ticker exits
|
||||
err error |
||||
} |
||||
|
||||
func (m *mockTickerFunc) next() time.Time { |
||||
return m.nxt |
||||
} |
||||
|
||||
func (m *mockTickerFunc) fire(_ time.Time) { |
||||
m.mock.mu.Lock() |
||||
defer m.mock.mu.Unlock() |
||||
if m.done { |
||||
return |
||||
} |
||||
m.nxt = m.nxt.Add(m.d) |
||||
m.mock.recomputeNextLocked() |
||||
|
||||
m.mock.mu.Unlock() |
||||
err := m.f() |
||||
m.mock.mu.Lock() |
||||
if err != nil { |
||||
m.exitLocked(err) |
||||
} |
||||
} |
||||
|
||||
func (m *mockTickerFunc) exitLocked(err error) { |
||||
if m.done { |
||||
return |
||||
} |
||||
m.done = true |
||||
m.err = err |
||||
m.mock.removeEventLocked(m) |
||||
m.cond.Broadcast() |
||||
} |
||||
|
||||
func (m *mockTickerFunc) waitForCtx() { |
||||
<-m.ctx.Done() |
||||
m.mock.mu.Lock() |
||||
defer m.mock.mu.Unlock() |
||||
m.exitLocked(m.ctx.Err()) |
||||
} |
||||
|
||||
func (m *mockTickerFunc) Wait(tags ...string) error { |
||||
m.mock.mu.Lock() |
||||
defer m.mock.mu.Unlock() |
||||
c := newCall(clockFunctionTickerFuncWait, tags) |
||||
m.mock.matchCallLocked(c) |
||||
defer close(c.complete) |
||||
for !m.done { |
||||
m.cond.Wait() |
||||
} |
||||
return m.err |
||||
} |
||||
|
||||
var _ Waiter = &mockTickerFunc{} |
||||
|
||||
type clockFunction int |
||||
|
||||
const ( |
||||
clockFunctionNewTimer clockFunction = iota |
||||
clockFunctionAfterFunc |
||||
clockFunctionTimerStop |
||||
clockFunctionTimerReset |
||||
clockFunctionTickerFunc |
||||
clockFunctionTickerFuncWait |
||||
clockFunctionNewTicker |
||||
clockFunctionTickerReset |
||||
clockFunctionTickerStop |
||||
clockFunctionNow |
||||
clockFunctionSince |
||||
clockFunctionUntil |
||||
) |
||||
|
||||
type callArg func(c *Call) |
||||
|
||||
type Call struct { |
||||
Time time.Time |
||||
Duration time.Duration |
||||
Tags []string |
||||
|
||||
fn clockFunction |
||||
releases sync.WaitGroup |
||||
complete chan struct{} |
||||
} |
||||
|
||||
func (c *Call) Release() { |
||||
c.releases.Done() |
||||
<-c.complete |
||||
} |
||||
|
||||
func withTime(t time.Time) callArg { |
||||
return func(c *Call) { |
||||
c.Time = t |
||||
} |
||||
} |
||||
|
||||
func withDuration(d time.Duration) callArg { |
||||
return func(c *Call) { |
||||
c.Duration = d |
||||
} |
||||
} |
||||
|
||||
func newCall(fn clockFunction, tags []string, args ...callArg) *Call { |
||||
c := &Call{ |
||||
fn: fn, |
||||
Tags: tags, |
||||
complete: make(chan struct{}), |
||||
} |
||||
for _, a := range args { |
||||
a(c) |
||||
} |
||||
return c |
||||
} |
||||
|
||||
type Trap struct { |
||||
fn clockFunction |
||||
tags []string |
||||
mock *Mock |
||||
calls chan *Call |
||||
done chan struct{} |
||||
} |
||||
|
||||
func (t *Trap) catch(c *Call) { |
||||
select { |
||||
case t.calls <- c: |
||||
case <-t.done: |
||||
c.Release() |
||||
} |
||||
} |
||||
|
||||
func (t *Trap) matches(c *Call) bool { |
||||
if t.fn != c.fn { |
||||
return false |
||||
} |
||||
for _, tag := range t.tags { |
||||
if !slices.Contains(c.Tags, tag) { |
||||
return false |
||||
} |
||||
} |
||||
return true |
||||
} |
||||
|
||||
func (t *Trap) Close() { |
||||
t.mock.mu.Lock() |
||||
defer t.mock.mu.Unlock() |
||||
for i, tr := range t.mock.traps { |
||||
if t == tr { |
||||
t.mock.traps = append(t.mock.traps[:i], t.mock.traps[i+1:]...) |
||||
} |
||||
} |
||||
close(t.done) |
||||
} |
||||
|
||||
var ErrTrapClosed = errors.New("trap closed") |
||||
|
||||
func (t *Trap) Wait(ctx context.Context) (*Call, error) { |
||||
select { |
||||
case <-ctx.Done(): |
||||
return nil, ctx.Err() |
||||
case <-t.done: |
||||
return nil, ErrTrapClosed |
||||
case c := <-t.calls: |
||||
return c, nil |
||||
} |
||||
} |
||||
|
||||
// MustWait calls Wait() and then if there is an error, immediately fails the
|
||||
// test via tb.Fatalf()
|
||||
func (t *Trap) MustWait(ctx context.Context) *Call { |
||||
t.mock.tb.Helper() |
||||
c, err := t.Wait(ctx) |
||||
if err != nil { |
||||
t.mock.tb.Fatalf("context expired while waiting for trap: %s", err.Error()) |
||||
} |
||||
return c |
||||
} |
||||
@ -0,0 +1,80 @@ |
||||
package quartz |
||||
|
||||
import ( |
||||
"context" |
||||
"time" |
||||
) |
||||
|
||||
type realClock struct{} |
||||
|
||||
func NewReal() Clock { |
||||
return realClock{} |
||||
} |
||||
|
||||
func (realClock) NewTicker(d time.Duration, _ ...string) *Ticker { |
||||
tkr := time.NewTicker(d) |
||||
return &Ticker{ticker: tkr, C: tkr.C} |
||||
} |
||||
|
||||
func (realClock) TickerFunc(ctx context.Context, d time.Duration, f func() error, _ ...string) Waiter { |
||||
ct := &realContextTicker{ |
||||
ctx: ctx, |
||||
tkr: time.NewTicker(d), |
||||
f: f, |
||||
err: make(chan error, 1), |
||||
} |
||||
go ct.run() |
||||
return ct |
||||
} |
||||
|
||||
type realContextTicker struct { |
||||
ctx context.Context |
||||
tkr *time.Ticker |
||||
f func() error |
||||
err chan error |
||||
} |
||||
|
||||
func (t *realContextTicker) Wait(_ ...string) error { |
||||
return <-t.err |
||||
} |
||||
|
||||
func (t *realContextTicker) run() { |
||||
defer t.tkr.Stop() |
||||
for { |
||||
select { |
||||
case <-t.ctx.Done(): |
||||
t.err <- t.ctx.Err() |
||||
return |
||||
case <-t.tkr.C: |
||||
err := t.f() |
||||
if err != nil { |
||||
t.err <- err |
||||
return |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
func (realClock) NewTimer(d time.Duration, _ ...string) *Timer { |
||||
rt := time.NewTimer(d) |
||||
return &Timer{C: rt.C, timer: rt} |
||||
} |
||||
|
||||
func (realClock) AfterFunc(d time.Duration, f func(), _ ...string) *Timer { |
||||
rt := time.AfterFunc(d, f) |
||||
return &Timer{C: rt.C, timer: rt} |
||||
} |
||||
|
||||
func (realClock) Now(_ ...string) time.Time { |
||||
return time.Now() |
||||
} |
||||
|
||||
func (realClock) Since(t time.Time, _ ...string) time.Duration { |
||||
return time.Since(t) |
||||
} |
||||
|
||||
func (realClock) Until(t time.Time, _ ...string) time.Duration { |
||||
return time.Until(t) |
||||
} |
||||
|
||||
var _ Clock = realClock{} |
||||
@ -0,0 +1,75 @@ |
||||
package quartz |
||||
|
||||
import "time" |
||||
|
||||
// A Ticker holds a channel that delivers “ticks” of a clock at intervals.
|
||||
type Ticker struct { |
||||
C <-chan time.Time |
||||
//nolint: revive
|
||||
c chan time.Time |
||||
ticker *time.Ticker // realtime impl, if set
|
||||
d time.Duration // period, if set
|
||||
nxt time.Time // next tick time
|
||||
mock *Mock // mock clock, if set
|
||||
stopped bool // true if the ticker is not running
|
||||
} |
||||
|
||||
func (t *Ticker) fire(tt time.Time) { |
||||
t.mock.mu.Lock() |
||||
defer t.mock.mu.Unlock() |
||||
if t.stopped { |
||||
return |
||||
} |
||||
for !t.nxt.After(t.mock.cur) { |
||||
t.nxt = t.nxt.Add(t.d) |
||||
} |
||||
t.mock.recomputeNextLocked() |
||||
select { |
||||
case t.c <- tt: |
||||
default: |
||||
} |
||||
} |
||||
|
||||
func (t *Ticker) next() time.Time { |
||||
return t.nxt |
||||
} |
||||
|
||||
// Stop turns off a ticker. After Stop, no more ticks will be sent. Stop does
|
||||
// not close the channel, to prevent a concurrent goroutine reading from the
|
||||
// channel from seeing an erroneous "tick".
|
||||
func (t *Ticker) Stop(tags ...string) { |
||||
if t.ticker != nil { |
||||
t.ticker.Stop() |
||||
return |
||||
} |
||||
t.mock.mu.Lock() |
||||
defer t.mock.mu.Unlock() |
||||
c := newCall(clockFunctionTickerStop, tags) |
||||
t.mock.matchCallLocked(c) |
||||
defer close(c.complete) |
||||
t.mock.removeEventLocked(t) |
||||
t.stopped = true |
||||
} |
||||
|
||||
// Reset stops a ticker and resets its period to the specified duration. The
|
||||
// next tick will arrive after the new period elapses. The duration d must be
|
||||
// greater than zero; if not, Reset will panic.
|
||||
func (t *Ticker) Reset(d time.Duration, tags ...string) { |
||||
if t.ticker != nil { |
||||
t.ticker.Reset(d) |
||||
return |
||||
} |
||||
t.mock.mu.Lock() |
||||
defer t.mock.mu.Unlock() |
||||
c := newCall(clockFunctionTickerReset, tags, withDuration(d)) |
||||
t.mock.matchCallLocked(c) |
||||
defer close(c.complete) |
||||
t.nxt = t.mock.cur.Add(d) |
||||
t.d = d |
||||
if t.stopped { |
||||
t.stopped = false |
||||
t.mock.addEventLocked(t) |
||||
} else { |
||||
t.mock.recomputeNextLocked() |
||||
} |
||||
} |
||||
@ -0,0 +1,81 @@ |
||||
package quartz |
||||
|
||||
import "time" |
||||
|
||||
// The Timer type represents a single event. When the Timer expires, the current time will be sent
|
||||
// on C, unless the Timer was created by AfterFunc. A Timer must be created with NewTimer or
|
||||
// AfterFunc.
|
||||
type Timer struct { |
||||
C <-chan time.Time |
||||
//nolint: revive
|
||||
c chan time.Time |
||||
timer *time.Timer // realtime impl, if set
|
||||
nxt time.Time // next tick time
|
||||
mock *Mock // mock clock, if set
|
||||
fn func() // AfterFunc function, if set
|
||||
stopped bool // True if stopped, false if running
|
||||
} |
||||
|
||||
func (t *Timer) fire(tt time.Time) { |
||||
t.mock.removeTimer(t) |
||||
if t.fn != nil { |
||||
t.fn() |
||||
} else { |
||||
t.c <- tt |
||||
} |
||||
} |
||||
|
||||
func (t *Timer) next() time.Time { |
||||
return t.nxt |
||||
} |
||||
|
||||
// Stop prevents the Timer from firing. It returns true if the call stops the timer, false if the
|
||||
// timer has already expired or been stopped. Stop does not close the channel, to prevent a read
|
||||
// from the channel succeeding incorrectly.
|
||||
//
|
||||
// See https://pkg.go.dev/time#Timer.Stop for more information.
|
||||
func (t *Timer) Stop(tags ...string) bool { |
||||
if t.timer != nil { |
||||
return t.timer.Stop() |
||||
} |
||||
t.mock.mu.Lock() |
||||
defer t.mock.mu.Unlock() |
||||
c := newCall(clockFunctionTimerStop, tags) |
||||
t.mock.matchCallLocked(c) |
||||
defer close(c.complete) |
||||
result := !t.stopped |
||||
t.mock.removeTimerLocked(t) |
||||
return result |
||||
} |
||||
|
||||
// Reset changes the timer to expire after duration d. It returns true if the timer had been active,
|
||||
// false if the timer had expired or been stopped.
|
||||
//
|
||||
// See https://pkg.go.dev/time#Timer.Reset for more information.
|
||||
func (t *Timer) Reset(d time.Duration, tags ...string) bool { |
||||
if t.timer != nil { |
||||
return t.timer.Reset(d) |
||||
} |
||||
t.mock.mu.Lock() |
||||
defer t.mock.mu.Unlock() |
||||
c := newCall(clockFunctionTimerReset, tags, withDuration(d)) |
||||
t.mock.matchCallLocked(c) |
||||
defer close(c.complete) |
||||
result := !t.stopped |
||||
select { |
||||
case <-t.c: |
||||
default: |
||||
} |
||||
if d <= 0 { |
||||
// zero or negative duration timer means we should immediately re-fire
|
||||
// it, rather than remove and re-add it.
|
||||
t.stopped = false |
||||
go t.fire(t.mock.cur) |
||||
return result |
||||
} |
||||
t.mock.removeTimerLocked(t) |
||||
t.stopped = false |
||||
t.nxt = t.mock.cur.Add(d) |
||||
t.mock.addEventLocked(t) |
||||
return result |
||||
} |
||||
Loading…
Reference in new issue