Testing a timer feature in Flutter

Manipulating time to make effective tests

Felipe Emídio
5 min readJan 23, 2023

Autonomous testing is always the first idea that comes up when talking about improving the system's quality.

Our test should emulate all scenarios that our code can face. That leads to some complicated cases to reproduce.

One of my first struggles with reproducing user journeys in the test environment was making a timer widget test. I’m here to prevent you from falling into this stressful situation.

Example App

Example app Screenshots

It’s a simple timer app with one screen and a fixed time of 10 seconds with a button to start and restart the counter.

The initial value of the text is 10, pressing the button starts the timer until it reaches 0.

While the timer is running, the button stays disabled, when it reaches 0 it becomes enabled again.

The timer widget

Our page was built with these 2 classes: TimePage and TimerController.

Controller Class

class TimerController extends ChangeNotifier {
Timer? _timer;

late int _initialTimeInSeconds;
late int currentTimeInSeconds;

bool get isActive => _timer?.isActive ?? false;

TimerController({int initialTimeInSeconds = 10}) {
_initialTimeInSeconds = initialTimeInSeconds;
currentTimeInSeconds = _initialTimeInSeconds;
}

@override
dispose() {
_timer?.cancel();
super.dispose();
}

initTimer() {
currentTimeInSeconds = _initialTimeInSeconds;
notifyListeners();

_timer = Timer.periodic(
const Duration(seconds: 1),
(timer) {
currentTimeInSeconds = currentTimeInSeconds - 1;
notifyListeners();
if (currentTimeInSeconds == 0) {
_timer?.cancel();
}
},
);
}
}

Let’s talk about the properties of our controller:

  • _initialTimeInSeconds — Fixed time to be counted. It’s used to set the initial value of the timer. The default value is 10.
  • _timer — Our Timer class to manage the time.
  • currentTimeInSeconds — Time left until the end of the timer. This value is displayed in the interface.

Note the existence of the get function isActive to check if our button will be disabled.

The controller is a ChangeNotifier class, so any change on the currentTimeInSeconds variable will be notified to our class listeners.

Widget Class

class TimerPage extends StatefulWidget {
const TimerPage({Key? key}) : super(key: key);

@override
State<TimerPage> createState() => _TimerPageState();
}

class _TimerPageState extends State<TimerPage> {
final TimerController timerController = TimerController();

@override
void initState() {
timerController.addListener(_listenTimer);
super.initState();
}

@override
void dispose() {
timerController.removeListener(_listenTimer);
timerController.dispose();
super.dispose();
}

_listenTimer() {
if(!mounted) {
return;
}
setState(() {});
}


@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Timer Demo')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
timerController.currentTimeInSeconds.toString(),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 48),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: timerController.isActive ? null : timerController.initTimer,
child: const Text('Start Counter'),
),
],
),
),
);
}
}

On the initState of the stateful widget, we add our listen function that just updates when it’s still mounted.

Never forget the disposal of the ChangeNotifier classes.

Creating our widget’s test

To verify the behavior of our timer we can create a test with the testWidgets function.

testWidgets('Widget - Test timer running', (tester) async {
await tester.pumpWidget(const MaterialApp(home: TimerPage()));

final buttonWidget = find.byType(ElevatedButton);
expect(find.text('10'), findsOneWidget);
expect(
tester.widget(buttonWidget),
isA<ElevatedButton>().having((t) => t.enabled, 'enabled', true),
);


await tester.tap(buttonWidget);
await tester.pump();

expect(find.text('10'), findsOneWidget);
expect(
tester.widget(buttonWidget),
isA<ElevatedButton>().having((t) => t.enabled, 'enabled', false),
);

await tester.pump(const Duration(seconds: 1));

expect(find.text('9'), findsOneWidget);

await tester.pump(const Duration(seconds: 2));

expect(find.text('7'), findsOneWidget);

await tester.pump(const Duration(seconds: 7));

expect(find.text('0'), findsOneWidget);
expect(
tester.widget(buttonWidget),
isA<ElevatedButton>().having((t) => t.enabled, 'enabled', true),
);
});

We build our widget using the pumpWidget function. The MaterialApp is required because it sets theme content in the Flutter context.

await tester.pumpWidget(const MaterialApp(home: TimerPage()));

We start by getting the button and checking the value of the enabled property.

Also, check if the number displayed for the user is the same as the initial timer value, which is 10.

final buttonWidget = find.byType(ElevatedButton);
expect(find.text('10'), findsOneWidget);
expect(
tester.widget(buttonWidget),
isA<ElevatedButton>().having((t) => t.enabled, 'enabled', true),
);

We tap on the button to check the first state of the running timer.

await tester.tap(buttonWidget);
await tester.pump();

expect(find.text('10'), findsOneWidget);
expect(
tester.widget(buttonWidget),
isA<ElevatedButton>().having((t) => t.enabled, 'enabled', false),
);

Now we just need to wait. We wait 1 sec and check the text and button states.

After we try wait 2 secs and check again. Now wait more than 7 secs and check if the timer stopped.

await tester.pump(const Duration(seconds: 1));

expect(find.text('9'), findsOneWidget);

await tester.pump(const Duration(seconds: 2));

expect(find.text('7'), findsOneWidget);

await tester.pump(const Duration(seconds: 7));

expect(find.text('0'), findsOneWidget);
expect(
tester.widget(buttonWidget),
isA<ElevatedButton>().having((t) => t.enabled, 'enabled', true),
);

Everything works fine! Great!

If you stop reading now you can consider your timer tested. But as professionals, making things work is just the beginning.

Testing just our controller

The main goal here is to test the timer, not the widget. Happily, the 2 concepts are separated into our 2 classes.

If you want to test the widget, you can use Dependency Injection to check if the widget state follows the controller value. We won’t create this test here because it’s out of our goal.

For the controller, we can replicate our current test by removing all widget's features.

test('Controller - Test timer running', () async {
const int initialTime = 10; // needs to be > 2
final controller = TimerController(initialTimeInSeconds: initialTime);

expect(controller.currentTimeInSeconds, initialTime);
expect(controller.isActive, false);

controller.initTimer();

await Future.delayed(const Duration(seconds: 2));

expect(controller.currentTimeInSeconds, initialTime - 2);
expect(controller.isActive, true);

await Future.delayed(const Duration(seconds: initialTime - 2));

expect(controller.currentTimeInSeconds, 0);
expect(controller.isActive, false);
});

As you can see, we switch the testWidget function for the test function. There are no more pump functions, and we wait by invoking the Future.delayed.

Separating the logical from the visual makes it easier to read and more objective. keeping the quality of our test on the timer functionality.

We have more control since we can define the initial value of the TimerController and use it to set our expected values.

But… it’s not good enough. This test is taking too long…

Improve the test with fakeAsync

Our test is taking at least 10 seconds (which is the initial time) to run. Within a project that has hundreds or thousands of tests, we can’t wait several seconds on a unit test like this.

For these situations, we have the fakeAsync lib. It creates a fake synchronous environment where it's possible to jump a lapse of time. Cool, don’t you think?

You just need to put the body of our code on a fakeAsync function and elapse the time with the elapse function of FakeAsync class.

test('Controller with fakeAsync - Test timer running', () async {
fakeAsync((FakeAsync async) {
const int initialTime = 12; // needs to be > 2
final controller = TimerController(initialTimeInSeconds: initialTime);

expect(controller.currentTimeInSeconds, initialTime);
expect(controller.isActive, false);

controller.initTimer();

async.elapse(const Duration(seconds: 2));

expect(controller.currentTimeInSeconds, initialTime - 2);
expect(controller.isActive, true);

async.elapse(const Duration(seconds: initialTime - 2));

expect(controller.currentTimeInSeconds, 0);
expect(controller.isActive, false);

});
});

Note the absence of await keyword, now our test runs like a synchronous function where the initial value does not affect the test performance.

Conclusion

We quickly solved the problem of the test, but the code’s quality is about continuous improvement.

The content covered in this article can be extended to most of the time-based features.

You can take a look at the full project here with all the test versions.

If you want to read more of my thoughts and programming tips, then check out my articles and follow me for upcoming content.

--

--

Felipe Emídio

Front-end developer searching for a better version of my code.