Taking screenshots during Flutter widget testing
Enhancing Visual Validation and Debugging
Before diving into this article, I want to thank Pascal Welsch for the "spot" package from which this article was inspired.
If you are familiar with Flutter widgets testing, you know they run in a black box. You don't have a visual representation of what's going on behind the curtain, thus it can be really hard to debug when there's something wrong.
You can run a flutter widget test on a device (using the flutter run command: flutter run -t <target_test_file.dart>
); the issue with this is it will take a lot more time since this will build the native app thus leading to productivity losses.
We had this issue in my team, and a solution we came across (s/o Pierre, my tech lead) was to take a screenshot of the widget tree. The initial need was to take a screenshot of failing tests so we could understand what happened, the solution would have been able to run on a CI and upload the failure screenshots as job artifacts. The issue is, that we had a lot of widget tests (+250) so, putting such a solution in place would have been a nightmare. It would have been necessary to write a wrapper over testWidgets and then migrate the existing tests to this wrapper.
We finally decided to write an extension on the WidgetTester
class that would help us take a screenshot of the current widget tree's state. Even if this didn't entirely satisfy our initial need, we could still reproduce the widget test failures that had occurred in CI locally, and understand/fix what was going wrong.
Enough chit-chat, here's some code:
Future<void> takeScreenshot({required String name}) async {
final liveElement = binding.renderViewElement!;
late final Uint8List bytes;
await binding.runAsync(() async {
final image = await _captureImage(liveElement);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
return 'Could not take screenshot';
}
bytes = byteData.buffer.asUint8List();
image.dispose();
});
final directory = Directory('./screenshots');
if (!directory.existsSync()) {
directory.createSync();
}
final file = File('./screenshots/$name.png');
file.writeAsBytesSync(bytes);
}
Future<ui.Image> _captureImage(Element element) async {
assert(element.renderObject != null);
RenderObject renderObject = element.renderObject!;
while (!renderObject.isRepaintBoundary) {
// ignore: unnecessary_cast
renderObject = renderObject.parent! as RenderObject;
}
assert(!renderObject.debugNeedsPaint);
final OffsetLayer layer = renderObject.debugLayer! as OffsetLayer;
final ui.Image image = await layer.toImage(renderObject.paintBounds);
if (element.renderObject is RenderBox) {
final expectedSize = (element.renderObject as RenderBox?)!.size;
if (expectedSize.width != image.width || expectedSize.height != image.height) {
// ignore: avoid_print
print(
'Warning: The screenshot captured of ${element.toStringShort()} is '
'larger (${image.width}, ${image.height}) than '
'${element.toStringShort()} (${expectedSize.width}, ${expectedSize.height}) itself.\n'
'Wrap the ${element.toStringShort()} in a RepaintBoundary to be able to capture only that layer. ',
);
}
}
return image;
}
As stated before, the takeScreenshot
method is an extension method of the WidgetTester
class (from the flutter_test) library. What this does is:
It gets the render view element from the WidgetTester's binding.
It then uses a _captureImage to convert the previous render view element to a PNG image
The taken screenshot will be saved at the project root (inside a
screenshots
folder).
Note: You may want to git ignore this folder
Using this extension method is as simple as calling a method:
testWidgets('My fantastic widget test', (tester) async {
...
tester.takeScreenshot(name: 'fantastic_screenshot');
});
That's it!
The full code for the extension can be found here: https://gist.github.com/stevenosse/b191d56cb4b75ed8012c3d04c1d80448
Thanks for reading.