-
Notifications
You must be signed in to change notification settings - Fork 582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
testtime: add mockable timers for use in tests #14672
testtime: add mockable timers for use in tests #14672
Conversation
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## master #14672 +/- ##
==========================================
+ Coverage 78.95% 79.02% +0.07%
==========================================
Files 1084 1087 +3
Lines 146638 147570 +932
==========================================
+ Hits 115773 116624 +851
- Misses 23667 23719 +52
- Partials 7198 7227 +29
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
It may be worth For example, see the extra gymnastics performed here to extract the timer returned by Returning a Alternatively, the caller to |
testtime/testtime.go
Outdated
// accesses of C must be replaced with C(). | ||
// | ||
// For more information about time.Timer, see: https://pkg.go.dev/time#Timer | ||
type Timer interface { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think anyone will import a test package to use the interface, but what you could do is simply make sure that the fake timer and stdlib timer match the interface so that both look similar enough in the context of the test code may want to do.
var _ Timer = (*TestTimer)(nil)
var _ Timer = (*time.Timer)(nil)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That was the original thought behind the package (see commits prior to e196095), but then production code which uses the C
field of time.Timer
cannot be replaced by a TestTimer
nor the testtime.Timer
interface, as interfaces cannot have instance variables.
And even if time.Timer
implemented the testtime.Timer
interface, in order to have production code which uses time.Timer
use a mockable timer instead, wouldn't they have to use testtime.Timer
as the type anyway, and just replace time.AfterFunc
with testtime.AfterFunc
etc.? There's no way to have a variable of type *time.Timer
populated with a *testtime.TestTimer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Either the package locally defines a Timer interface using the one here as a reference (i.e. makes a copy), or the Timer interface could be moved to eg. timeutil which other packages can import. Various *test
packages are meant to provide things you need for testing only.
Just for the record, we have a single place right now where the Timer interface is used, so I don't mind simply keeping it in the prompting code until we have more opportunities to use it (and the test helpers)
testtime/testtime.go
Outdated
// time.AfterFunc. See here for more details: https://pkg.go.dev/time#AfterFunc | ||
func AfterFunc(d time.Duration, f func()) Timer { | ||
if useMockedTimers { | ||
return afterFuncTest(d, f) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not always return a fake timer? if I want a fake timer in a test I'll mock relevant code to call testtime.AfterFunc
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See my reply above. Before e196095, that's exactly what I used, but then any usage of *time.Timer
in production code which leveraged the C
field could not be replaced with testtime.Timer
, since the latter cannot have a non-method field.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
aah, I see. Maye this would work:
package timeutil
..
type Timer interface {
// Equivalent to t.C for stdlib timer
ExpiredC() <-chan time.Time
Reset(d time.Duration) bool
Stop() bool
}
type stdlibTimer *time.Timer
func (t stdlibTimer) ExpiredC() <-chan time.Time {
return t.C
}
func TimeTimerAdapter(t *time.Timer) Timer {
return stdlibTimer(t)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahhhh yes, I think that solves the problems (uses the time.Timer
constructors directly, timer.ExpiredC()
doesn't conflict with timer.C
. Then the only code change needed to replace production code using time.Timer
with this would be calling timeutil.TimeTimerAdapter
. Nice!
testtime/testtime.go
Outdated
func AfterFunc(d time.Duration, f func()) *TestTimer { | ||
currTime := time.Now() | ||
return &TestTimer{ | ||
currTime: currTime, | ||
expiration: currTime.Add(d), | ||
active: true, | ||
callback: f, | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
with this you should be able to drop the code in run-checks
func AfterFunc(d time.Duration, f func()) *TestTimer { | |
currTime := time.Now() | |
return &TestTimer{ | |
currTime: currTime, | |
expiration: currTime.Add(d), | |
active: true, | |
callback: f, | |
} | |
} | |
func AfterFunc(d time.Duration, f func()) *TestTimer { | |
osutil.MustBeTestBinary("testtime timers cannot be used outside of unit test code") | |
currTime := time.Now() | |
return &TestTimer{ | |
currTime: currTime, | |
expiration: currTime.Add(d), | |
active: true, | |
callback: f, | |
} | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if this works... If I make this change, plus the ones you suggest, and then run go test
from inside client/
, the test just hangs (because the timer doesn't advance without manually being advanced):
diff --git a/client/client.go b/client/client.go
index d9a936549c..ecb84b83e9 100644
--- a/client/client.go
+++ b/client/client.go
@@ -38,6 +38,7 @@ import (
"github.com/snapcore/snapd/features"
"github.com/snapcore/snapd/httputil"
"github.com/snapcore/snapd/jsonutil"
+ "github.com/snapcore/snapd/testtime"
)
func unixDialer(socketPath string) func(string, string) (net.Conn, error) {
@@ -391,7 +392,7 @@ func (client *Client) do(method, path string, query url.Values, headers map[stri
}
retry := time.NewTicker(opts.Retry)
defer retry.Stop()
- timeout := time.NewTimer(opts.Timeout)
+ timeout := testtime.NewTimer(opts.Timeout)
defer timeout.Stop()
for {
@@ -409,7 +410,7 @@ func (client *Client) do(method, path string, query url.Values, headers map[stri
select {
case <-retry.C:
continue
- case <-timeout.C:
+ case <-timeout.ExpiredC():
}
break
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I see, anything that imports client
then panics immediately, but testing client
itself doesn't panic... hmm
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I'd prefer to leave the static check in place as well as the osutil.MustBeTestBinary()
call, since the former can catch usage of testtime.{AfterFunc,NewTimer}
even when the user is a package which is not imported elsewhere in the codebase.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@olivercalder can you also port all the standard libs tests for timers using Elapse where they use Sleep?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be clear, do you mean the tests from the Go standard library (e.g. https://cs.opensource.google/go/go/+/refs/tags/go1.23.3:src/time/sleep_test.go;l=75) or those from StdlibTimer
in timeutil/timer_test.go
?
Only the go standard lib tests use sleep, so I'll port those :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, I meant the golang stdlib
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we want to make sure that we can pass similar tests as the stdlib timers
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bit wondering about the usages of currTime and time.Now here
As @pedronis brought up, it could be desirable to have a way of controlling the passage of time for all timers which were created, either by having a As it stands now, users of I worry about namespacing timers which are created during different runs. I think it would be great if there were a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thank you
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks
This PR adds a new package which is (currently) unused in other packages and, since it cannot occur in non-test code, by definition cannot be reached in spread tests (other than unit tests). The current test failures are as follows:
I think this PR can be force-merged, which will make review of #14390 easier. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have one question about run-checks.
I don't particularly love it, can we do what we did with osuser changes recently?
run-checks
Outdated
@@ -241,6 +241,21 @@ if [ "$STATIC" = 1 ]; then | |||
exit 1 | |||
fi | |||
|
|||
echo "Checking for usages of testtime outside of test code" | |||
got="" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use one of the linters instead? We did that for osuser changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For some reason it seems my golangci-lint
is not catching the new lint I'm trying to add, nor the existing lint for osuser. I'm using client/client.go
here as a testing ground to verify whether the lints catch package misuse:
diff --git a/.golangci.yml b/.golangci.yml
index 71a0cecb78..fef62554f0 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -129,6 +129,14 @@ linters-settings:
deny:
- pkg: "os/user"
desc: "Please use osutil/user instead. See https://github.com/canonical/snapd/pull/13776"
+ testtime:
+ files:
+ - "!$test"
+ deny:
+ - pkg: "github.com/snapcore/snapd/testtime"
+ desc: "Cannot use testtime outside of test code"
+ - pkg: "github.com/canonical/snapd/testtime"
+ desc: "Cannot use testtime outside of test code"
misspell:
# Correct spellings using locale preferences for US or UK.
diff --git a/client/client.go b/client/client.go
index d9a936549c..7c054cb191 100644
--- a/client/client.go
+++ b/client/client.go
@@ -30,6 +30,7 @@ import (
"net/http"
"net/url"
"os"
+ "os/user"
"path"
"strconv"
"time"
@@ -38,9 +39,11 @@ import (
"github.com/snapcore/snapd/features"
"github.com/snapcore/snapd/httputil"
"github.com/snapcore/snapd/jsonutil"
+ "github.com/snapcore/snapd/testtime"
)
func unixDialer(socketPath string) func(string, string) (net.Conn, error) {
+ user.Current()
if socketPath == "" {
socketPath = dirs.SnapdSocket
}
@@ -391,7 +394,7 @@ func (client *Client) do(method, path string, query url.Values, headers map[stri
}
retry := time.NewTicker(opts.Retry)
defer retry.Stop()
- timeout := time.NewTimer(opts.Timeout)
+ timeout := testtime.NewTimer(opts.Timeout)
defer timeout.Stop()
for {
@@ -409,7 +412,7 @@ func (client *Client) do(method, path string, query url.Values, headers map[stri
select {
case <-retry.C:
continue
- case <-timeout.C:
+ case <-timeout.ExpiredC():
}
break
}
When I run golangci-lint run
from the project root, it doesn't catch anything wrong.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interestingly, when I run golangci-lint linters
, it shows depguard
under the list of Disabled by your configuration linters
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Running with golangci-lint run --config .golangci.yml
also does not catch the expected lint errors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're missing depguard
in enabled linters. Let me open a quick PR for that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See #14717
With depguard enabled, an intentional use of os/user is flagged:
maciek@galeon:work/canonical/snapd (git)-[bboozzoo/enable-depguard] golangci-lint run
cmd/snap/main.go:28:2: import 'os/user' is not allowed from list 'osuser': Please use osutil/user instead. See https://github.com/canonical/snapd/pull/13776 (depguard)
"os/user"
^
Signed-off-by: Oliver Calder <[email protected]>
Rather than returning whether the timer had fired, `Stop` and `Reset` should return whether the timer is currently active. If it has fired or been stopped manually, these should return false. Additionally, add the `Active` method to report whether the timer is currently active, and add the `FireCount` method to report the number of times that the timer has fired. This is useful to directly check whether a timer's callback has been called. Signed-off-by: Oliver Calder <[email protected]>
Signed-off-by: Oliver Calder <[email protected]>
The `time.Timer` type uses an instance variable `C` for the channel over which the expiration time is sent when a timer which was created via `time.NewTimer` fires. Interfaces in Go cannot have instance variables, so we must use a `C()` method instead. However, this means `time.Timer` cannot implement our `testtime.Timer` interface fully. Thus, this commit adds `testtime.RealTimer` as a wrapper around `time.Timer` which exposes the latter's inner `C` variable as `C()`. Since we now need to construct `testtime.RealTimer` instances in place of `time.Timer` instances in production code, the `testtime.AfterFunc` and `testtime.NewTimer` functions now return `testtime.RealTimer`s by default, which are thin wrappers around `time.Timer`s. In test code, tests can call `testtime.MockTimers` to make all subsequent invocations of `testtime.AfterFunc` and `testtime.NewTimer` return `testtime.TestTimer`s instead of `testtime.RealTimer`s. It is important that `testtime.MockTimers` is never used in non-test code, so this commit adds a static check to ensure this is the case. Signed-off-by: Oliver Calder <[email protected]>
… timer Signed-off-by: Oliver Calder <[email protected]>
Signed-off-by: Oliver Calder <[email protected]>
…tover test Signed-off-by: Oliver Calder <[email protected]>
Signed-off-by: Oliver Calder <[email protected]>
Signed-off-by: Oliver Calder <[email protected]>
… test helper Signed-off-by: Oliver Calder <[email protected]>
Signed-off-by: Oliver Calder <[email protected]>
Since `TestTimer` is designed to allow the passage of time to be controlled manually, it makes sense to represent this internally using a duration and elapsed time, rather than storing the timestamp at the creation of the timer and advancing an internal "current time". This way, the time at which the timer was created has no effect on the expiration timestamp which may be sent over the C channel when it fires, and timers don't each have their own conflicting view of what time it is. When a timer expires, the current time is sent over the channel, or if `Fire` is called directly, then the given time is sent instead. Signed-off-by: Oliver Calder <[email protected]>
Signed-off-by: Oliver Calder <[email protected]>
Signed-off-by: Oliver Calder <[email protected]>
8eaf3c2
to
1612bba
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Woot, thank you so much for doing this.
I hope that we can slowly reduce the number of tests that rely on <100ms sleeps mocked via timing hacks.
* master: (44 commits) wrappers: do not reload activation units (canonical#14724) gadget/install: support for no{exec,dev,suid} mount flags interfaces/builtin/mount_control: add support for nfs mounts (canonical#14694) tests: use gojq - part 1 (canonical#14686) interfaces/desktop-legacy: allow DBus access to com.canonical.dbusmenu interfaces/builtin/fwupd.go: allow access to nvmem for thunderbolt plugin tests: removing uc16 executions (canonical#14575) tests: Added arm github runner to build snapd (canonical#14504) tests: no need to run spread when there are not tests matching the filter (canonical#14728) tests/lib/tools/store-state: exit on errors, update relevant tests (canonical#14725) tests: udpate the github workflow to run tests suggested by spread-filter tool (canonical#14519) testtime: add mockable timers for use in tests (canonical#14672) interface/screen_inhibit_control: Improve screen inhibit control for use on core (canonical#14134) tests: use images with 20G disk in openstack (canonical#14720) i/builtin: allow @ in custom-device filepaths (canonical#14651) tests: refactor test-snapd-desktop-layout-with-content tests: fix broken app definition tests: capitalize sentences in comments tests: wrap very long shell line tests: fix raciness in async startup and sync install ...
Introduce a
testtime
package with aTimer
interface which can be used in place oftime.Timer
when we want direct control over timers in tests.The
testtime.Timer
interface is fully compatible withtime.Timer
, except that the former has aC()
method instead of instance variable, as interfaces cannot have instance variables. As such,time.Timer
does not directly implementtesttime.Timer
, so we must define a minimal wrapper (testtime.RealTimer
) which directly calls the functions/methods associated withtime.Timer
, and exposes the latter'sC
instance variable asC()
.The
testtime.TestTimer
struct simulates the behavior oftime.Timer
, except that control of the passage of time and the ability to fire the timer are provided directly to the user. This way, tests which depend on timer behavior can avoid race conditions and sleeps.