Controlling time in Dart unit tests, the better way
In Dart, we can get the current date and time by calling DateTime.now()
.
That’s easy enough.
But what’s not that easy is testing it.
Since there’s no built-in way to override what it returns, any Dart code that directly depends on DateTime.now()
is not testable.
However, there’s a very neat way to tackle this, and it’s not what you might think.
Why testing DateTime.now() is hard
Let’s imagine that we’re making an app that tells the current day of the week.
We have a class called Greeter
that returns the appropriate greeting for each day.
import 'package:intl/intl.dart';
final _format = DateFormat('EEEE');
class Greeter {
String greet() {
final now = DateTime.now();
final dayOfWeek = _format.format(now);
return 'Happy $dayOfWeek, you!';
}
}
For example, if it’s Tuesday, Greeter().greet()
returns “Happy Tuesday, you!”.
Writing some failing tests
We want to be sure nobody introduces bugs to the very important greeting functionality, so we write a test case:
void main() {
test('returns a proper greeting on a Tuesday', () {
final greeting = Greeter().greet();
// How on Earth do we make this expectation pass
// if it's not Tuesday when we run this test?
expect(
greeting,
'Happy Tuesday, you!',
);
});
}
The problem with this test is that it passes only once a week.
Unless our team agrees to only run tests on Tuesdays, that test case is not very useful. To make it pass every day of the week, we need to be able to control the current time somehow.
Making it testable - the usual way
We could refactor Greeter
to something like this:
DateTime _getSystemTime() => DateTime.now();
class Greeter {
Greeter([this._getCurrentTime = _getSystemTime]);
final DateTime Function() _getCurrentTime;
String greet() {
final now = _getCurrentTime();
final dayOfWeek = _format.format(now);
return 'Happy $dayOfWeek, you!';
}
}
The class now has an optional constructor parameter: a function called _getCurrentTime()
.
By default, it returns the current time, but the behavior can be overridden.
In our tests, we pass Greeter
a function that returns a fixed DateTime
instead.
void main() {
test('returns a proper greeting on a Tuesday', () {
final greeting = Greeter(() => DateTime(2020, 09, 01)).greet();
expect(greeting, 'Happy Tuesday, you!');
});
}
With these changes, our tests now pass 7 days a week!
The end?
That approach works for the very simple use case we had, but it’s not a fun thing to do at scale.
We would need to do this dance with every single class or widget that needs the current datetime. This inevitably results in “prop drilling” when dealing with widgets and other nested classes. That’s not fun.
Well, what about InheritedWidget - or better yet, provider?
We could come up with a class called Clock
that encapsulates the behavior:
DateTime _defaultTime() => DateTime.now();
class Clock {
Clock([this.now = _defaultTime]);
final DateTime Function() now;
}
We could then pass Clock
down with an InheritedWidget
or provider to make it available for a subtree.
This would avoid the problem of prop drilling and still keep the logic testable.
However, we wouldn’t be able to access current time without a BuildContext
.
It would also complicate accessing the Clock
in initState()
, since context
is not available there yet.
Also, if we have a lot of Dart classes depending on the current datetime that are not Flutter widgets, we’d still have to pass the Clock
as a constructor argument over and over again.
There’s got to be a better way!
What about just making Clock a singleton?
That would work.
I did it years back myself with inKino. You can see the source code for Clock here, and here’s an example test case, although it’s a bit specific to Redux.
However, I can’t help but feel a bit off with this one.
It’s global behavior that can be overridden from anywhere. This could result in frustrating, hard to notice bugs.
The better way - controlling time with package:clock
Even though there’s no built-in way to mock DateTime.now()
, there’s a good alternative that comes pretty close.
It’s a package called clock, and it’s maintained by the Dart team.
The package exposes a top-level variable called clock
.
To get the current time, we call clock.now()
:
import 'package:clock/clock.dart';
void main() {
// prints current date and time
print(clock.now());
}
In order to override the current time, we wrap any code that calls clock.now()
with a function called withClock
:
import 'package:clock/clock.dart';
void main() {
withClock(
Clock.fixed(DateTime(2000)),
() {
// always prints 2000-01-01 00:00:00.
print(clock.now());
},
);
}
The withClock
function takes in two arguments:
- a
Clock
- in our case, a clock with a fixed date set to 2000/01/01. - a function, inside of which the
clock
getter always returns what we passed in as the first argument.
That’s pretty neat.
It also only requires minimal changes to existing code. And there’s no need to add yet another constructor parameter and pass stuff around.
How does it work?
Short answer: it leverages Zone-local values.
Here’s how the withClock
function looks like:
class Clock {
// ...
}
// An unique object to use as a key for the clock.
final _clockKey = Object();
T withClock<T>(Clock clock, T Function() callback) {
return runZoned(
callback,
zoneValues: {
// Override _clockKey with the provided clock.
_clockKey: clock,
},
);
}
It runs the passed anonymous function in a new Zone
, setting a Zone-local value for the _clockKey
to contain the provided clock.
Any code inside callback
that queries _clockKey
from the current Zone will receive an overridden Clock
.
You might see where this is going.
// Look up if the current Zone has a clock assigned, and
// return that. If not, return a fresh new instance of Clock.
Clock get clock => Zone.current[_clockKey] as Clock? ?? Clock();
The top-level clock
getter checks for the same _clockKey
in the current Zone.
If a Clock
already exists in the current Zone (=we have wrapped that piece of code with the withClock
function), it returns the provided Clock
.
Otherwise, it returns a fresh instance of Clock
that defaults to returning DateTime.now()
.
Fixing our tests
Now that we have this brand new trick up our sleeves, we’ll finally make our sample test case work properly.
Let’s implement this in our Greeter
class. To make it work, we only need to make one tiny modification to greeter.dart:
import 'package:clock/clock.dart';
class Greeter {
String greet() {
// ...
final now = clock.now();
// ...
}
}
After that, we wrap our tests with the withClock()
function:
void main() {
test('returns a proper greeting on a Tuesday', () {
final greeting = withClock(
Clock.fixed(DateTime(2020, 09, 01)),
() => Greeter().greet(),
);
expect(greeting, 'Happy Tuesday, you!');
});
}
Now the test passes on every day of the week - not just Tuesday!
Other use cases
Aside from overriding current time in just unit tests, depending on your app, you might benefit from this in your manual testing too.
For example, in Reflectly, we have some things that should be only loaded once a day.
In order to test this manually, I just wrap runApp
with withClock
that starts with the current date.
Then I bump the day by one to simulate tomorrow, the day after, and so on.
1 comment
likes