Lawrence Pickford

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:

A dog with a smiley face

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 a Future that calls precacheImage on the WidgetTester.
  • Runs the Futures 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 "![${file##*/}](../$file)" >> 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 the test 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 the test 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: Screenshot of the generated markdown file