Testing Time-dependent Code in Flutter/Dart Reliably
Avoid the pitfall of using the current time in code during testing
Many apps search for updates, manipulate personal data, fetch newsfeeds, etc. However, the data are time-dependent. Sometimes the operations can take minutes, hours or days.
We want to do something every five minutes or every hour and create a timestamp in the database to archive change, log new data or prevent repeating the same action.
Photo by Luke van Zyl on Unsplash
You have built your perfect app, which tracks timestamps into a database. Moreover, you want to make sure everything is working correctly.
So, you start to add tests to the app, but unfortunately, while checking the database, you cannot verify timestamps. You do not have control of time in your app. You are stuck with your computer or phone system time because you have used DateTime.now() everywhere.
How to solve it?
All the DateTime.now() have to be replaced by a centralised mockable instance of the time.
An easy way how to get out of this is the clock package from the dart team: clock package
The package wraps the Dart DateTime, which mocks the behaviour of the DateTime and even returns the same object. So, you do not have to be worried about dealing with some brand-new time type.
So, we can access zoned time via clock.now(), which provides system time by default. Luckily, that means its usage does not change the point of your code.
You can even create your Clock object and manipulate time further how you like it. During the construction, you can provide your version of accessing time. Furthermore, you can use the Clock.fixed(
Example
Firstly, the project needs to be extended by these dependencies:
dependencies:
...
clock: ^1.1.0
...
dev_dependencies:
...
fake_async: ^1.3.0 // required only for testing
...
Code to test
I created some sample code to test the passing time. We have a simple repo, which needs to be updated every five minutes. If no update attempt has occurred, we want to update the repo. This dumbed-down simplified version only considers time, whereas the code uses the clock.now() instead of DateTime.now().
import 'package:clock/clock.dart';
/// simple example to show working of the [clock]
/// the purpose is simple, we want to update repository after 5 minutes
/// from last update
/// if the request comes after 5 minutes, the repository can be updated, otherwise not
class TimeRepository {
DateTime? _lastUpdate;
static const secondsToUpdate = 300;
bool get isUpdateNeeded {
final lastUpdate = _lastUpdate;
if (lastUpdate == null) {
return true;
}
return clock.now().difference(lastUpdate).abs().inSeconds > secondsToUpdate;
}
void updateStatus() {
if(!isUpdateNeeded) {
return;
}
_lastUpdate = clock.now();
// code to update
}
}
import 'package:example/test_time.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
/// [fakeAsync] - fakes process of async calls and makes the time continue
/// so you do not need to wait for 30 minutes in real time
/// but, you just elapse it artificially by [async.elapse]
void main() {
// initialisation of the repository
late TimeRepository timeRepository;
setUp(() => timeRepository = TimeRepository());
test('Given the initial state - When initialized, then it require update', () {
fakeAsync((asyncCallback) {
// at the beginning, the repo wants to update
expect(timeRepository.isUpdateNeeded, true);
// takes action
timeRepository.updateStatus();
// repository does not need update anymore
expect(timeRepository.isUpdateNeeded, false);
}, initialTime: DateTime.now());
});
test('Given the update was requested - When the update is checked later on, then it is not needed', () {
fakeAsync((asyncCallback) {
// updating repo
timeRepository.updateStatus();
// 'waiting' for 3 minutes
asyncCallback.elapse(const Duration(minutes: 3));
// update is still not required, we have to wait for more than 5 mins
expect(timeRepository.isUpdateNeeded, false);
}, initialTime: DateTime.now());
});
test('Given the update is required - When the update is checked later on, then it is needed', () {
fakeAsync((asyncCallback) {
// updating repo
timeRepository.updateStatus();
// 'waiting' for 30 minutes
asyncCallback.elapse(const Duration(minutes: 30));
// update is required as we want to update after 5 mins
expect(timeRepository.isUpdateNeeded, true);
}, initialTime: DateTime.now());
});
}
Testing
To avoid waiting in the real world, we wrap our code into the fakeAsync from the fake_async package. The fakeAsync runs time-based async code almost instantaneously because it uses the clock package. In addition, it provides a callback object with which we can call elapse method to advance time more into the future by ourselves.
The code contains three tests:
- Initial state — the repo needs an update. After the update, it does not require an update for the next five minutes.
- After updating, we add new requests within five minutes.
- After updating, we add new requests after five minutes.
Results after running the tests
In the second test, the time elapsed for 3 minutes after the update, and there was no need for the update. In the third test, the time in the test elapsed for 30 minutes and an update was needed.
Conclusion
The clock package belongs to dart tools. Thus, this means the maintenance of the code occurs occasionally. As is stated here: Dart team clock package
I do not assume that it will be significant changes in handling time in the future, but who knows what the future will bring. It makes handling time much more predictable from a testing perspective.
On the other hand, creating a custom Clock singleton class with needed features is not an enormous investment, and you will have full power above your time. It will allow you to use your dependency injection of choice later on.