Flutter Screenshots
When making mobile apps, it’s often useful to take screenshots of the app in different states. This can be useful for documentation, marketing, or just to keep a record of the app’s UI at different stages of development. I use this to be able to send to designers so that they can see all the important screens in the app, as they look when the screenshots are taken.
To do this using the Flutter Test framework we’ll be implementing a series of Golden Tests. Golden Tests are a way to compare the output of a widget against a previously saved image, which is useful for ensuring that the UI looks as expected. We won’t be using them for comparing the output, but rather just to take screenshots of the app in different states.
Setup
Basic App
First we need to create a new app, I’m calling mine screenshots_example
, and adding a HomePage
and ChildPage
.
HomePage
import 'package:flutter/material.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home Page')),
body: Center(
child: Text(
'Welcome to the Home Page!',
style: Theme.of(context).textTheme.bodyLarge,
),
),
);
}
}
ChildPage
import 'package:flutter/material.dart';
class ChildPage extends StatelessWidget {
const ChildPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Child Page')),
backgroundColor: Colors.blueAccent[100],
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Welcome to the Child Page!',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
Image.asset('assets/images/smiley.jpg', width: 100, height: 100),
],
),
);
}
}
Assets
We’ll also need to add an image to the assets/images
folder, you can use any image you like, but for this example I’m using a nice smiley face image:
As well as this, we’ll need to include a font for the Golden Tests to use, so we’ll add a assets/fonts
folder and include the Roboto font. I only included the Roboto-Regular.ttf
file, but you can include any other weights or styles you need.
Then update the pubspec.yaml
file to include the assets and fonts:
...
flutter:
assets:
- assets/fonts/
- assets/images/
fonts:
- family: 'Roboto'
fonts:
- asset: assets/fonts/Roboto-Regular.ttf
uses-material-design: true
Tests
Setup
Now we need to set up our tests. We’ll need a few things to get started:
- A
MaterialApp
to wrap our widgets in. - A way to load the font for the Golden Tests.
- A way to load any images inside the widgets used in the tests.
- A series of breakpoints to take the screenshots at.
- A way to take screenshots of the widgets in different states.
MaterialApp wrapper
First we’ll create a new file in test/src/material_widget.dart
to create the wrapper for our Golden Tests:
import 'package:flutter/material.dart';
import 'package:screenshots_example/src/feature/theme/data/theme_light.dart';
/// Wraps the given [child] widget in a [MaterialApp] with a light theme.
/// This is useful for testing widgets that require a Material context.
Widget materialWidget(Widget child) {
return MediaQuery(
data: const MediaQueryData(),
child: MaterialApp(
theme: themeLight.copyWith(platform: TargetPlatform.android),
debugShowCheckedModeBanner: false,
home: Material(child: child),
),
);
}
Font Loader
Next we’ll create a new file in test/util/load_test_font.dart
to load the font for the Golden Tests:
import 'dart:io';
import 'package:flutter/services.dart';
final class LoadTestFont {
/// Loads the test font for golden tests.
///
/// This method ensures that the test font is loaded and available for use
/// in golden tests. It is typically called before running any tests that
/// require the font.
static Future<void> load() async {
// Load the test font from the assets
final file = File('assets/fonts/Roboto-Regular.ttf').readAsBytesSync();
final bytes = Future<ByteData>.value(file.buffer.asByteData());
await (FontLoader('Roboto')..addFont(bytes)).load();
}
}
Image Loader
Images are a bit more complicated, as we need to ensure that the images are loaded before the tests run, but after the widget under test has been pumped into the WidgetTester
.
As we need access to the WidgetTester
to load the images, we’ll create an extension for it in test/util/tester_image_precache_extension.dart
:
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
/// Extension on [WidgetTester] to precache images in the widget tree.
/// This is useful for golden tests where images need to be loaded before
extension TesterImagePrecacheExtension on WidgetTester {
Future<void> precacheImages() async {
var elements = elementList(find.bySubtype<Image>());
final precacheFutures = elements.map((element) {
Image widget = element.widget as Image;
return precacheImage(widget.image, element);
});
await runAsync(() async {
await Future.wait(precacheFutures);
});
}
}
This extension does the following:
- Finds all
Image
widgets in the widget tree. - Maps each
Image
in the returned list to aFuture
that callsprecacheImage
on theWidgetTester
. - Runs the
Future
s in an async context to ensure that the images are loaded before the tests run.
Breakpoints
Finally, we’ll define a set of breakpoints to take the screenshots at. As this is something that can be used in the App as well we’ll create a new file in lib/src/feature/theme/data/breakpoints.dart
using the Tailwind CSS breakpoints as a reference:
/// Breakpoints class that provides a mapping of Breakpoint enum values to their corresponding pixel values as strings.
/// We're using the values from Tailwind CSS as a reference. [reference](https://tailwindcss.com/docs/responsive-design)
enum Breakpoint {
sm,
md,
lg,
xl,
xxl;
String get name => switch (this) {
Breakpoint.sm => 'small',
Breakpoint.md => 'medium',
Breakpoint.lg => 'large',
Breakpoint.xl => 'extraLarge',
Breakpoint.xxl => 'extraExtraLarge',
};
double get size => switch (this) {
Breakpoint.sm => 640,
Breakpoint.md => 768,
Breakpoint.lg => 1024,
Breakpoint.xl => 1280,
Breakpoint.xxl => 1536,
};
}
Taking Screenshots
Now we want to make a function that can take a screenshot of a widget at all given breakpoints, in landscape and portrait mode, and save it to a file. As we need to use the WidgetTester
to take the screenshot, we’ll create an extension for it in a new file in test/util/tester_screenshot_extension.dart
:
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:screenshots_example/src/feature/theme/data/breakpoints.dart';
import '../src/material_widget.dart';
import 'load_test_font.dart';
import 'tester_image_precache_extension.dart';
/// An extension on [WidgetTester] to capture screenshots of widgets.
/// This extension provides a method to take screenshots of widgets.
extension TesterScreenshotExtension on WidgetTester {
Future<void> capture(
Widget widget, {
String? name,
ScreenshotMode mode = ScreenshotMode.both,
}) async {
// Arrange
// Load the test font to ensure text rendering is correct in screenshots
await LoadTestFont.load();
// Ensure the widget is wrapped in a MaterialApp to provide necessary context
await pumpWidget(materialWidget(widget));
// Load and precache images before taking screenshots
await precacheImages();
// Get the sizes for the screenshots based on the mode
Map<String, Size> sizes = _sizes(mode);
// Create a view for each size and set the physical size
for (var size in sizes.entries) {
view.physicalSize = size.value;
final goldenFileName =
'goldens/${name ?? widget.runtimeType.toString()}_golden_${size.key}.png';
// Act
// Pump the widget with the current size
await pump();
// Assert
// Use the golden file name to match the screenshot
// This also takes a screenshot
await expectLater(
find.byType(widget.runtimeType),
matchesGoldenFile(goldenFileName),
);
}
}
}
Map<String, Size> _sizes(ScreenshotMode mode) => {
if (mode == ScreenshotMode.portrait || mode == ScreenshotMode.both) ...{
for (final breakpoint in Breakpoint.values)
'portrait_${breakpoint.name}': Size(
breakpoint.size,
breakpoint.size * 1.78, // 16:9 aspect ratio
),
},
if (mode == ScreenshotMode.landscape || mode == ScreenshotMode.both) ...{
for (final breakpoint in Breakpoint.values)
'landscape_${breakpoint.name}': Size(
breakpoint.size * 1.78, // 9:16 aspect ratio
breakpoint.size,
),
},
};
enum ScreenshotMode { portrait, landscape, both }
This extension does the following:
- Loads the test font to ensure text rendering is correct in screenshots.
- Wraps the widget in a
MaterialApp
to provide the necessary context for the widget. - Pre-caches any images used in the widget to ensure they are loaded before taking the screenshot.
- Defines a set of sizes based on the breakpoints defined earlier.
- Then in a loop:
- Sets the physical size of the view to the current size.
- Pumps the widget with the current size.
- Takes a screenshot and compares it to a golden file.
- The golden file is named based on the widget type and the size, so that it can be easily identified later.
I have also implemented a ScreenshotMode
enum to allow for taking screenshots in either portrait, landscape, or both modes.
Widget Tests
Now we can create our widget tests to take the screenshots. We’ll create a new file for the previously defined HomePage
and ChildPage
widgets in test/src/feature/home/home_page_test.dart
and test/src/feature/child/child_page_test.dart
respectively, mirroring the structure of the app:
HomePage Test
import 'package:flutter_test/flutter_test.dart';
import 'package:screenshots_example/src/feature/home/home_page.dart';
import '../../../util/tester_screenshot_extension.dart';
void main() {
group('Golden Tests', () {
testWidgets(
'Home Page golden tests',
(tester) async => await tester.capture(const HomePage()),
tags: ['golden'],
);
});
}
ChildPage Test
import 'package:flutter_test/flutter_test.dart';
import 'package:screenshots_example/src/feature/child/child_page.dart';
import '../../../util/tester_screenshot_extension.dart';
void main() {
group('Golden Tests', () {
testWidgets(
'Child Page golden tests',
(tester) async => await tester.capture(const ChildPage()),
tags: ['golden'],
);
});
}
We need to remember to add the golden
tag to the tests so that we can run them separately from the other tests. This is useful as we don’t want to run the golden tests every time we run the tests, as they can take a long time to run.
Generating the Screenshots
Now we can run the tests to generate the screenshots. To do this, we can use the following command:
flutter test -t "golden" --update-goldens
The -t "golden"
flag tells Flutter to only run the tests with the golden
tag, and the --update-goldens
flag tells Flutter to update the golden files with the new screenshots.
If we want to run our normal tests without the golden tests, we can just run:
flutter test -x "golden"
The -x "golden"
flag tells Flutter to exclude the tests with the golden
tag, so that we can run our normal tests without running the golden tests.
This will now have created a goldens
folden in the directory where each test is located, containing the screenshots for each widget at each breakpoint and in both portrait and landscape modes.
Github Actions
Ok so now we have a way to take screenshots of our widgets, we can automate this process using Github Actions. This will allow us to take screenshots of our widgets whenever we want, without having to run the tests manually. I have mine setup to run when I run the workflow manually on github, but you can also set it up to run on a schedule or when a pull request is made.
We’ll need a few things to set up the workflow:
- A way to run the tests on a Github runner which does the following:
- Checks out the code;
- Pulls in the Flutter SDK;
- Deletes any old screenshots;
- Runs the tests with the
golden
tag and updates the golden files; - Generates a markdown file with the screenshots;
- Commits the changes to the repository.
- A script to delete the old screenshots before taking new ones.
- A script to generate a markdown file with all the generated screenshots.
Generating the Markdown File
We’ll create a script to generate a markdown file with all the screenshots first, this will be run after the tests have been run and the screenshots have been generated. We’ll create a new file in scripts/update_screenshots.sh
:
#!/usr/bin/env bash
> screenshots/app_screenshots.md
echo "# Screenshots" >> screenshots/app_screenshots.md
echo "This file contains all the screenshots that are generated during the tests." >> screenshots/app_screenshots.md
kGoldenRegex='test(\/|\\).*(\/|\\)goldens(\/|\\).*.png'
function traverse {
local a file
sectionTitle=""
sectionSubtitle=""
for a; do
for file in "$a"/*; do
if [[ -d $file ]]; then
traverse "$file"
else
if [[ $file =~ $kGoldenRegex ]]; then
title=$(echo $file | sed -E 's/.*\/(.*)_golden_.*/\1/g')
subtitle=$(echo $file | sed -E 's/.*\/.*_golden_(.*)_.*/\1/g')
if [[ $title != $sectionTitle ]]; then
sectionTitle=$title
echo "## $title" >> screenshots/app_screenshots.md
fi
if [[ $subtitle != $sectionSubtitle ]]; then
sectionSubtitle=$subtitle
echo "### $subtitle" >> screenshots/app_screenshots.md
fi
name=$(echo $file | sed -E 's/.*\/.*_golden_.*_(.*)\.png/\1/g')
echo "#### $name" >> screenshots/app_screenshots.md
echo "" >> screenshots/app_screenshots.md
echo "" >> screenshots/app_screenshots.md
fi
fi
done
done
}
traverse "test"
echo "Screenshots added to document."
This script does the following:
- Initializes the
app_screenshots.md
file with a header and description. - Defines a regex to match the golden files.
- Defines a recursive function
traverse
that:- Iterates through all files in the given directory.
- If the file is a directory, it calls itself recursively.
- If the file matches the regex, it extracts the title and subtitle from the file path.
- If the title or subtitle has changed, it adds a new section to the markdown file.
- Finally, it adds the screenshot to the markdown file with a link to the image.
- Calls the
traverse
function on thetest
directory to generate the markdown file.
Deleting Old Screenshots
Next, we’ll create a script to delete the old screenshots before taking new ones. We’ll create a new file in scripts/delete_screenshots.sh
:
> screenshots/app_screenshots.md
kGoldenRegex='test(\/|\\).*(\/|\\)goldens(\/|\\).*.png'
function traverse {
local a file
for a; do
for file in "$a"/*; do
if [[ -d $file ]]; then
traverse "$file"
else
if [[ $file =~ $kGoldenRegex ]]; then
echo "Deleting: $file"
rm "$file"
fi
fi
done
done
}
traverse "test"
echo "All screenshots deleted."
This script does the following:
- Initializes the
app_screenshots.md
file to clear it. - Defines a regex to match the golden files.
- Defines a recursive function
traverse
that:- Iterates through all files in the given directory.
- If the file is a directory, it calls itself recursively.
- If the file matches the regex, it deletes the file.
- Calls the
traverse
function on thetest
directory to delete all screenshots.
Github Actions Workflow
Now we have the scripts to delete the old screenshots and generate the markdown file, we can create a Github Actions workflow to run the tests and update the screenshots. We’ll create a new file in .github/workflows/screenshots.yml
:
name: Golden
on:
workflow_dispatch:
# schedule:
# - cron: "0 0 * * *"
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout and Setup
uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:"
cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:"
- uses: oleksiyrudenko/gha-git-credentials@v2-latest
with:
token: "${{ secrets.GITHUB_TOKEN }}"
- run: flutter --version
- name: Get dependencies
run: |
flutter pub get
- name: Delete existing screenshots
run: |
chmod +x ./scripts/delete_screenshots.sh
./scripts/delete_screenshots.sh
- name: Generate screenshots
run: flutter test -t "golden" --update-goldens
- name: Generate screenshots markdown document
run: |
chmod +x ./scripts/update_screenshots.sh
./scripts/update_screenshots.sh
- run: |
git add screenshots/app_screenshots.md
git add test/*
if ! git diff --cached --quiet; then
git commit -m "Update screenshots: ${{github.run_number}} [skip ci]"
git push
fi
This workflow does the following:
- Triggers on manual dispatch or on a schedule (commented out).
- Sets up the permissions to write to the contents of the repository.
- Checks out the code and sets up the Flutter SDK.
- Sets up the Git credentials to allow the workflow to push changes back to the repository.
- Runs
flutter pub get
to get the dependencies. - Runs the script to delete the existing screenshots.
- Runs the tests with the
golden
tag and updates the golden files. - Runs the script to generate the markdown file with the screenshots.
- Commits the changes to the repository if there are any changes to the screenshots or the markdown file.
- Pushes the changes back to the repository.
The good thing about this is that now you have a hosted markdown file you can present to your designers or anyone else who needs to see the screenshots of the app in different states, and a quick and easy way to update them whenever you make changes to the app.
Issues I encountered
Script Permissions
When running the scripts in the Github Actions workflow, you may encounter issues with permissions as I use WSL to run the scripts, but created the scripts on my windows machine, so the scripts did not have the correct permissions attached for unix based systems. To fix this, I added the chmod +x
command to the workflow to ensure that the scripts have the correct permissions to be executed.
Line Endings
Again, as I use Windows as my development environment, the line endings were by default set to CRLF, so the scripts had Windows line endings. To fix this, I updated my own VSCode settings to use LF line endings, and then ran the following command in a WSL terminal to convert the line endings in the scripts:
dos2unix ./scripts/delete_screenshots.sh
dos2unix ./scripts/update_screenshots.sh
This will convert the line endings in the scripts to LF line endings, which is what is expected by the Github Actions runner.
Results
I’ve uploaded a full example of this to my github repository if you want to check it out. The screenshots are generated in the test
directory, and the markdown file is generated in the screenshots
directory.
You can view the generated markdown file here, which should look something like this: