Golden testing using CachedNetworkImage

Nicolas Cuillery
Flutter Community
Published in
9 min readSep 1, 2020

cached_network_image is a popular package to handle network images in a Flutter application, it provides:

  • Cache on iOS, Android, and recently macOS,
  • Placeholder widget and nice fade-out transition to the image,
  • Progress download indicator for large images,
  • Offline support (cached images can be displayed without an active connection).

Using it on my Flutter projects has greatly improved the user experience. On the first launch, the app displays the placeholders sized as close as possible to the final images to smoothen the transition when the images are loaded. Then, the subsequent launches are blazing-fast, thanks to the cache (no network requests).

The first launch of the app (without cache)

However, such improvement doesn’t come for free, and this package introduces a slight technical overhead: the cached_network_image package relies on the popular flutter_cache_manager package (by the same author), which, in turn, relies on the yet-another-popular package (and Flutter Favorite) sqflite.

This is usually not a problem since this machinery is abstracted by the straight-forward API of the CachedNetworkImage widget… except when it comes to testing:

Stacktrace obtained by testing a widget using CachedNetworkImage

The MissingPluginException is the classic exception thrown when accessing to a native library (in our case, sqflite) in a unit test or widget test that doesn’t build any native counterparts.

Let’s see how to configure our test environment to correctly mock the cache mechanism used by the CachedNetworkImage widget and, as a bonus, configure some stubbing to return a test asset in place of the expected image, so we can generate a golden image of a widget that uses CachedNetworkImage.

Golden testing (a.k.a. visual regression testing) consists of rendering a widget into an image (the golden) and compared this image to the existing one for each subsequent test execution. Check this other article from Flutter Community for more info about golden testing:

Sandbox application

This application is a mono-page application listing some services such as https://placekitten.com that deliver images in any resolution declared in the URL itself:

The source code is available here.

The main widget is InsetCard: it is responsible for displaying the characteristics of the service as well as an example image. Moreover, its implementation is tricky and requires a double build cycle:

  • the first one to get the intrinsic height of the Inset widget,
  • the second one to center this widget at the bottom edge of the Backdrop image.

Finally, this widget has to be flexible and renders correctly with data of different lengths (short or long name/description, various amount of tags, …). Therefore it’s the perfect candidate for a widget test.

Anatomy of the InsetCard widget

The code of this widget is no interest here but can be found on the app repository. The only thing we need to be aware of is the usage of CachedNetworkImage within the Backdrop widget.

As we already know, the most basic golden test as below is failing because of the sqflite plugin.

Improve testability

Some native packages provide a stubbing API for usage in unit tests or widget tests (like shared_preferences) but this is not the case for sqflite. The cached_network_image package doesn’t provide anything either, however, it does allow injecting a custom BaseCacheManager via the cacheManager property! This class is responsible for the caching mechanism and the default implementation is the one who relies on sqflite, see DefaultCacheManager.

The first step then is to use a custom BaseCacheManager when running tests while the app still normally uses the DefaultCacheManager. This can be accomplished with get_it. This simple package is basically a singleton registry. It may don’t seem useful at first sight (after all, we can create plain Dart singletons) but it quickly becomes indispensable if you’d like to bring the test coverage to a decent level in your project.

We use get_it to register an instance of DefaultCacheManager in the project main.dart:

Then we can inject this singleton in the CachedNetworkImage widget:

By doing this (see the complete diff), the app works exactly the same, we haven’t changed how the CachedNetworkImage works, however, we now have the possibility to declare our own implementation of BaseCacheManager for the widget test, in its main function:

Writing the TestCacheManager

We follow the plan by creating a class that extends BaseCacheManager. This class needs a concrete implementation for the method getFilePath as well as a mandatory positional argument in the constructor. By using the IDE quick fixes, we can write the following without too much hassle:

This, however, leads to another error when executing the test. If not provided, the BaseCacheManager creates a CacheStore object used to wrap a CacheInfoRepository:

This abstract class is the interface between the BaseCacheManager and the storage. The default implementation is using sqflite and is responsible for the MissingPluginException. It means that we can finally get rid of this dependency by providing a dummy implementation of CacheInfoRepository.

Good news, this implementation already exists! Its name is NonStoringObjectProvider. It’s the one used to ensure flutter_cache_manager compatibility for Flutter Web. This library on the Web is basically a no-op (image caching on the Web is mostly handled by the browser) but most importantly: it doesn’t crash.

We can enhance the TestCacheManager to provide an instance of CacheStore which is, in turn, initialized with an instance of NonStoringObjectProvider :

Important note: During this step, we have imported files that are not exposed by the flutter_cache_manager package. By doing that, we introduced a coupling with the internals of the package, and therefore expose ourselves to breaking changes in future versions (changes that could not be considered as “breaking” in the version changelog or documentation since they don’t concern public API).

The last barrier that separates us from the green test result is an error due to an undisposed Timer:

A Timer is still pending even after the widget tree was disposed.

This is caused by the CacheStore that performs an asynchronous cache clean at some point in the test execution. By default, the WidgetTester asserts that all asynchronous resources must be properly closed at the end of the test. Because we don’t have control over this Timer, we can’t close it manually. We have to wrap the pumpWidget method with a runAsync method:

We finally have a successful test and a generated golden image!

There is room for improvement though…

Improve the golden image

There are indeed some aspects that require an inevitable amount of boilerplate when running golden tests. Let’s tackle them one by one:

Canvas size

The default canvas used to render the widget is 800x600. It is, by sheer coincidence, the same aspect ratio as the Backdrop widget. Therefore, the CachedNetworkImage placeholder takes the whole canvas.

We can configure a more real realistic value by running the following code on a device: print(MediaQuery.of(context).size);. In the case of the iPhone 11 simulator, it gives a resolution of 414x896.

Then we can write a utility method to configure the WidgetTester to use this resolution:

Font files

The most noticeable difference is the text lines. By default in widget tests, all the fonts are replaced by a test font named Ahem which renders all characters as a plain square. In the case of visual regression testing, it doesn’t suit our interest.

When using the MaterialApp widget, the default font is Roboto. We have to load it manually, as well as the Material icon font (the icon in the top right corner of the Inset):

Inset layout

Remember how the Inset widget is supposed to be centered around the bottom edge of the Backdrop? The golden image reveals it’s just aligned at the bottom.

This is because of an implementation detail of the InsetCard widget: it requires 2 layout passes to be correctly positioned: the first one to get the height of the Inset, and the second to add a margin equal to half the height on the Backdrop, to obtain the expected layout. Check the repository for the full implementation.

When calling pumpWidget, only the first pass is performed. For the second one, we have to call pump to trigger a second frame. To be less dependant of implementation details like this one (and more frequently used: animations), we can use pumpAndSettle, which actually means “trigger new frames until the app is idle”.

Let’s run the test command for another try:

That’s progress!

This is the corresponding updated test, using the 2 methods previously implemented:

The golden image starts to look like the real widget. In addition to the missing backdrop image, we can notice another difference: the grey border below the Inset.

This is because of the Inset shadow: shadows are not guaranteed to be pixel-perfect every time they are rendered. So they are disabled by default in golden tests and replaced by solid borders, otherwise, it could lead to false positives when running the golden tests.

Note: it’s technically possible to force the activation of the shadows (for documentation purpose for instance) by doing:

Do not use it in a classic visual regression testing workflow!

Mock image asset

This leads us to the final problem we have to take care of: the backdrop image. In widget tests, we don’t want to be dependent on any remote server, that’s why the low-level Dart HttpClient is configured by default to return an error 400 on every request (the reason why our image is not loaded). It’s possible to mock this client and stub a custom response for a given request however it is highly verbose and cumbersome to set up.

By chance, we are already using a package, cached_network_image, whose sole purpose is to spare a network request when the image is found in the cache. In addition to that, it already implements the logic to fetch an image from the filesystem (sqflite is used to store the cached URLs and associated metadata, but the image file itself is stored in the device’s temporary folder).

It would be neat then if we could trick the BaseCacheManager by faking a cache hit and configuring it to return an image file from the test/assets directory!

Let’s start by saving an image in the test/assets folder:

Mock image (original photo by Victoire Joncheray on Unsplash)

By looking at the internal of the CachedNetworkImage component, we can see a call to the method getFileStream of the BaseCacheManager class.

If the returned Stream emits a valid FileResponse object, then the widget builds the Image and starts the fade-out transition from the placeholder.

In BaseCacheManager, this method contains the cache logic (look into the cache, then download the file if not found). We can bypass that logic by overriding the method in our TestCacheManager:

That’s it! The Stream emits an event and the CachedImageWidget interprets as a ready-to-use image and build the Image widget.

If you re-run the test now, unfortunately, the image is still missing. That’s because the Image widget loads the image in an asynchronous task and the WidgetTester can’t know when the loading is complete.

But thanks to Chun-Heng Tai from the Flutter team, a workaround exists, based on precacheImage method:

And voilà!

Final golden image

Wrap up

We now have a test to guarantee the UI will never change unintentionally. In addition to that, once the moderated amount of boilerplate has been written, we can multiply the test cases endlessly for different screen sizes, text factors, landscape mode (it doesn’t look good…), different lengths of data, etc.

If you’d like to know more about golden testing in production, eBay has used it extensively for their app eBay Motors and wrote interesting feedback in this blog post:

Feel free to share any suggestions or feedback in the comments and… happy testing!

Thank you to Rene Floor @FloorRene, main contributor of cached_network_image, for his review of this article.

https://www.twitter.com/fluttercomm

--

--