While exploring flutter, I tried to create a widget that renders an image from memory (at least for now) and shows an IconButton
above it. To correct the size of an IconButton
I need to get a real size of an image. So to set the iconSize
I use FutureBuilder
in combination with Completer
and Image.resolve(...).addListener
.
It works as it supposed to. But I struggle to make widget test pass as it never renders the image (while it loads it, the expect which tests the image data passes) and never creates an IconButton
widget, so my checks does not pass.
This should be something with FakeAsync that is in use by testWidgets
. tester.pump
should progress fake time, but even if I run pump
in a cycle the IconButton
widget never appears. Can ImageStream b
class Thumbnail extends StatelessWidget {
final Uint8List thumbnail;
final ImagePage mainImage;
final Image thumbnailImage;
final bool editMode;
final completer = Completer<ui.Image>();
Thumbnail(
{super.key,
required this.thumbnail,
required this.mainImage,
this.editMode = false})
: thumbnailImage = Image.memory(thumbnail);
@override
Widget build(BuildContext context) {
thumbnailImage.image
.resolve(const ImageConfiguration())
.addListener(ImageStreamListener((image, synchronousCall) {
completer.complete(image.image);
image.dispose();
}));
return Stack(alignment: Alignment.center, children: [
thumbnailImage,
FutureBuilder<ui.Image>(
future: completer.future,
builder: (context, snapshot) {
return Offstage(
offstage: !snapshot.hasData,
child: makeIconButton(context, snapshot.data));
}),
]);
}
static int minSize(ui.Image? image) {
if (image == null) {
return 2;
}
return image.width < image.height ? image.width : image.height;
}
Widget makeIconButton(BuildContext context, ui.Image? image) {
final imageMinSize = minSize(image);
return IconButton(
onPressed: () {
if (!editMode) {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => page.Page.fromWidget(mainImage)));
}
},
icon: editMode ? const Icon(Icons.edit) : const Icon(Icons.zoom_in),
iconSize: imageMinSize >= 64 ? 48.0 : imageMinSize / 2,
splashRadius: imageMinSize / 2,
color: Colors.blueGrey.withOpacity(0.8),
hoverColor: Colors.lightBlue.withOpacity(0.3),
);
}
}
And the test code is:
void main() {
testWidgets('Thumbnail', (WidgetTester tester) async {
var thumbnailPage = page.Page('Thumbnail', const [],
thumbnail_package.Thumbnail(
thumbnail: image_samples.thumbnail_64x64,
mainImage: thumbnail_package.ImagePage(
'MainImage',
MemoryImage(image_samples.thumbnail_64x64)
)
));
await tester.pumpWidget(
MaterialApp(home: thumbnailPage)
);
expect(find.byType(thumbnail_package.Thumbnail), findsOneWidget);
expect(find.byType(Image), findsOneWidget);
for (final image in find.byType(Image).evaluate().map((element) =>
element.widget as Image)) {
expect(
Uint8ListCompareByValue((image.image as MemoryImage).bytes),
Uint8ListCompareByValue(image_samples.thumbnail_64x64));
}
// while (!find
// .byType(IconButton)
// .hasFound) {
// await tester.pump();
// }
await tester.tap(find.byType(IconButton)); // <--- THIS CALL FAILS
await tester.pumpAndSettle();
expect(find.byType(thumbnail_package.Thumbnail), findsNothing);
expect(find.byType(Image), findsOneWidget);
for (final image in find.byType(Image).evaluate().map((element) =>
element.widget as Image)) {
expect(
Uint8ListCompareByValue((image.image as MemoryImage).bytes),
Uint8ListCompareByValue(image_samples.thumbnail_64x64));
}
});
I get the following error:
The following assertion was thrown running a test: The finder "Found 0 widgets with type "IconButton": []" (used in a call to "tap()") could not find any matching widgets.
Can you give me any suggestion on how to handle this case in tests?
Ok, the issue was my wrong expectations on how asynchronous calls work: do not expect any special order of execution.
The order of calls to ImageStreamListener and FutureBuilder were different in a debug run on emulator and in a test run where ImageStreamListener was called first. As a result FutureBuilder was created with already finished future.
FutureBuilder itself is made (with somewhat strange design as for me) so that it does not call its builder callback a second time if the future is ready on a first call. But on a first call it passes a waiting state and a null snapshot. So here we are, either make sure that ready later or check a future instead of a snapshot variable if you are sure it's ready.
This code works fine as I forced a callback to be called after creation of FutureBuilder:
@override
Widget build(BuildContext context) {
final widget = Stack(alignment: Alignment.center, children: [
thumbnailImage,
FutureBuilder<ui.Image>(
future: completer.future,
builder: (context, snapshot) {
return Offstage(
offstage: !snapshot.hasData,
child: makeIconButton(context, snapshot.data));
}),
]);
thumbnailImage.image
.resolve(const ImageConfiguration())
.addListener(ImageStreamListener((image, synchronousCall) {
completer.complete(image.image);
image.dispose();
}));
return widget;
}
with a simple addition of one pumpAndSettle before the tap:
await tester.pumpAndSettle(); // additional call to process async callback and rebuild widget
await tester.tap(find.byType(IconButton));
await tester.pumpAndSettle();
Though I'm not totally sure if this a stable solution and it won't fail in case of more async calls happening.