Skip to content

Integration Testing in Flutter: Part I

Here at IntelliTect, we love building mobile apps. We’re quite fond of frameworks like Flutter, Xamarin, and .NET MAUI that help developers create native mobile apps with a single codebase, helping you launch your next app into the app store faster. Building an app? Give us a call!

I recently worked on an app using Flutter that required device permissions, like access to the camera and microphone. I wanted to be able to verify that the full functionality of the app was working when making code changes, so I set out to add some integration tests.

Integration tests are great because they mimic a real user interacting with the app as closely as possible. The downside is that they are typically slower and more complex to write and maintain. Integration tests can be especially complicated because you’re testing multiple components working together in a realistic environment.

This app targeted multiple platforms: iOS, Android, and web. I chose to target the web platform in the integration tests because that’s how most of the users use the app. One advantage of writing tests that target the web is that they doesn’t require a third-party service to test in CI/CD pipelines, unlike when testing against mobile device platforms.

Let’s dive into writing integration tests in Flutter for the web!

Running a Local Flutter App

To write an integration test, we first need something to test. We’ll use IntelliTect’s new, cutting-edge Flutter app for video conferencing, IntelliCrews™️ (not at all inspired by Microsoft Teams).

At the time of this writing, we are using Flutter version 3.22. Any patch version of Flutter 3.22 should work with the code examples (3.22.x). If you do not have Flutter installed and want to follow along, follow the official installation steps.

Clone the git repo and fire up the app.

Note: All following commands assume a Unix-like shell.

$ git clone 'https://github.com/IntelliTect-Samples/Flutter-IntelliCrews.git'
$ cd Flutter-IntelliCrews
$ git fetch --tags
$ git checkout tags/blog-post-1 -b local
$ cd app
$ flutter pub get
$ flutter run -d chrome
Code language: Bash (bash)

The resulting app should look like this:

Note: The port in the URL will likely be different every time you start the app locally.

Click the floating action button (FAB) and accept the browser permission requests for your camera and microphone. It may take a few seconds for the browser to prompt you to allow the permissions. After accepting, you should see your beautiful, smiling face (you are smiling, right?).

Let’s look at the code required to get us to this point. All source code is available at https://github.com/IntelliTect-Samples/Flutter-IntelliCrews.git. We’ll only focus on the interesting sections of code in this article. The most interesting code is in _MyHomePageState:

Note: Some sections of the code are omitted with “…” for brevity.

class _MyHomePageState extends State<MyHomePage> {
  bool _isCameraOn = false;
  String? _cameraError;
  List<CameraDescription> _cameras = [];

  Future<void> _onCameraToggled() async {
    ...
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(...),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            if (_cameraError != null)
              Text(_cameraError!)
            else if (_isCameraOn)
              SizedBox.fromSize(
                size: const Size(600, 400),
                child: Camera(cameras: _cameras),
              ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _onCameraToggled,
        tooltip: _isCameraOn ? 'Turn off camera' : 'Turn on camera',
        child: _isCameraOn
            ? const Icon(Icons.toggle_on)
            : const Icon(Icons.toggle_off_outlined),
      ),
    );
  }
}
Code language: Dart (dart)

The user’s camera is rendered when the camera toggle is enabled. To setup the camera, we run some code in the callback _onCameraToggled:

Future<void> _onCameraToggled() async {
    final isCameraOn = !_isCameraOn;
    if (isCameraOn && _cameras.isEmpty) {
      try {
        final cameras = await availableCameras();
        setState(() {
          _cameras = cameras;
          _cameraError = null;
        });
      } on CameraException catch (error) {
        setState(() {
          _cameraError = error.description;
        });
      }
    }
    setState(() {
      _isCameraOn = isCameraOn;
    });
  }
Code language: Dart (dart)

This code attempts to retrieve the available cameras using the function availableCameras from Flutter’s camera package. If the user denies the camera permission or some error occurs, we catch it and display a message to the user.

At this point, we have enough functionality to write our first integration test.

Standing Up Integration Tests

Now that we have a ✨fancy✨ app, let’s write an integration test for it. Open up the repository in your favorite IDE. I’ll be using VS Code with the Flutter extension

First, we’ll need to add the integration_test package as a dependency from the Flutter SDK:

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter
Code language: YAML (yaml)

Note: If you’re using VS Code, your Dart dependencies will automatically be retrieved in the background after editing and saving pubspec.yaml. Otherwise, you can run flutter pub get.

Now we’ll create integration_test/main_test.dart inside the app folder. The file path should look like this from the root of the repository:

$ tree -P main_test.dart --prune                                                                           
.                                                                                                                                     
└── app                                                                                                                               
    └── integration_test                                                                                                              
        └── main_test.dart                                                                                                            
                                                                                                                                      
3 directories, 1 file                                                                                                                 
Code language: Bash (bash)

Let’s add a simple, failing test and try to run it. First, add the following code to the test file we created:

// integration_test/main_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets("turn on camera", (WidgetTester tester) async {
    // Arrange

    // Act

    // Assert
    expect(true, false, reason: "Simple failing test");
  });
}
Code language: Dart (dart)

Run the integration test, targeting Chrome:

$ flutter -d chrome test integration_test                                                                 
Web devices are not supported for integration tests yet.                                                                    
Code language: Bash (bash)

Welp… unfortunately, running integration tests for web with the standard flutter test is not yet supported. Support for that feature is tracked in this GitHub issue. Until that feature is added, we need to run integration tests using flutter drive, a tool for running integration tests on attached devices or emulators. In our case, the attached device is the browser.

Run the integration test with flutter drive:

$ flutter drive --no-pub -d chrome --target integration_test/main_test.dart                                         
Test file not found: /Users/josephriddle/source/flutter/IntelliCrews/app/test_driver/main_test_test.dart
Code language: Bash (bash)

Argh! The drive tool is looking for a file that we have not created yet, test_driver/main_test_test.dart. It turns out that we have to either explicitly tell the driver tool where our driver code is, or it will attempt to use a default path of the target’s filename with “_test” appended. This is documented in the help text under the --driver argument:

$ flutter drive --help                                                                                              
    ...
    --driver=<path>
        The test file to run on the host (as opposed to the target file to run on the device). By default, this file has the same base name as the target file, but in the "test_driver/" directory instead, and with "_test" inserted just before the extension, so e.g. if the target is "lib/main.dart", the driver will be "test_driver/main_test.dart". 
Code language: Bash (bash)

If we name our integration test file main_test.dart, then we need to name the driver test file main_test_test.dart for it to be used automatically (twice the “_test”, double the tests passing… well, not quite since two times zero is still zero… anyhow).

Maybe we should just follow the steps in the documentation? No, we’ll press forward. Let’s create test_driver/main_test_test.dart with the following content:

// test_driver/main_test_test.dart
import 'package:integration_test/integration_test_driver.dart';

Future<void> main() => integrationDriver();
Code language: Dart (dart)

The newly created integrationDriver is an adapter to run integration tests using flutter drive. Now that we’ve created that file, let’s try running the test again:

$ flutter drive --no-pub -d chrome --target integration_test/main_test.dart                                                                          
Launching integration_test/main_test.dart on Chrome in debug mode...                                                                                                   
Waiting for connection from debug service on Chrome...             10.6s                                                                                               
This app is linked to the debug service: ws://127.0.0.1:59504/tyfZILw6RAs=/ws                                                                                          
Debug service listening on ws://127.0.0.1:59504/tyfZILw6RAs=/ws                                                                                                        
00:00 +0: turn on camera                                                                                                                                               
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════                                                                   
The following TestFailure was thrown running a test:                                                                                                                   
Expected: <false>                                                                                                                                                      
  Actual: <true>
Code language: Bash (bash)

Awesome—we’ve got our first failing test! The test starts up the browser and loads the app, but then fails immediately. Let’s make it do something more interesting and get it passing.

Adding Logic to Our Integration Test

Now we’ll update the test to click the button and assert that the camera appears.

// integration_test/main_test.dart
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:intellicrews/main.dart';

import 'test_utils.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets("turn on camera", (WidgetTester tester) async {
    // Arrange
    await tester.pumpWidget(const MyApp());

    // Act
    final fabButton = find.byType(FloatingActionButton);
    expect(fabButton, findsOneWidget);
    await tester.tap(fabButton);
    await tester.pumpAndSettle();

    // Give the camera time to load
    await tester.pumpFor(duration: const Duration(seconds: 5));

    // Assert
    final camera = find.byType(CameraPreview);
    await tester.pumpUntilFound(camera);
  });
}
Code language: Dart (dart)

If we rerun the test, it fails with the following error:

$ flutter drive --no-pub -d chrome --target integration_test/main_test.dart
...
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞═════════════════                                                                                               
The following TestFailure was thrown running a test:                                                                                                            
Expected: exactly one matching candidate                                                                                                                        
  Actual: _TypeWidgetFinder:<Found 0 widgets with type "Camera":                                                                                                
[]>                                                                                                                                                             
   Which: means none were found but one was expected                                                                                                            
Code language: Bash (bash)

If we watch the Chrome browser to verify that the test starts, we’ll see that it gets stuck on prompting the user for camera permissions.

We need to instruct the browser to automatically grant the Flutter app the requested permissions. Fortunately, we can use the Chrome DevTools Protocol (CDP) to grant permissions.

Using Chrome DevTools Protocol to Grant Permissions

You can inspect and interact with Chrome via the Chrome DevTools Protocol. One method available to us via CDP is Browser.grantPermissions. As the name suggests, this method grants the given permissions to the browser.

To use CDP in Flutter, we must add the webkit_inspection_protocol package. Chrome DevTools Protocol used to be named Webkit Inspection Protocol, thus the name of the package. Add it to pubspec.yaml as a dev dependency:

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter
  webkit_inspection_protocol: ^1.2.1
Code language: YAML (yaml)

We’ll use CDP in the test driver file, before the tests are started. Update test_driver/main_main_test.dart to grant audio and video permissions:

// test_drvier/main_test_test.dart
import 'package:integration_test/integration_test_driver.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';

Future<void> main() async {
  final chromeConnection = ChromeConnection('localhost');
  final chromeTab = (await chromeConnection.getTabs()).first;
  final connection = await chromeTab.connect();
  final page = WipPage(connection);
  await page.sendCommand("Browser.grantPermissions", params: {
    'permissions': [
      'audioCapture',
      'videoCapture',
    ]
  });

  await integrationDriver();
}
Code language: Dart (dart)

If we run the tests, we’ll see a new error:

$ flutter drive --no-pub -d chrome --target integration_test/main_test.dart
...
Unhandled exception:                                                                                                                                            
SocketException: Connection refused (OS Error: Connection refused, errno = 61), address = localhost, port = 63961                                               
Code language: Bash (bash)

This is because we’re attempting to connect to Chrome via CDP without telling Chrome that we’re using it. To start CDP, we need to use some new --remote-debugging-* arguments with chromedriver.

At this point, it’s best that we create a script as our Flutter commands are getting more complex. Create the file scripts/integration-test.sh and add execute permissions with chmod u+x scripts/integration-test.sh.

There are some best practices for creating shell scripts that I’ll omit in the code snippet below. Let’s focus on starting chromedriver and running the integration test with CDP:

# scripts/integration-test.sh
...

# Start chromedriver in the background with CDP enabled
chromedriver \
  --port=4444 \
  --remote-debugging-pipe \
  --remote-debugging-port=9222 \
  &

# Run the integration tests using our own chromedriver
flutter drive \
  --no-pub \
  -d web-server \
  --target integration_test/main_test.dart \
  --no-headless \
  --web-browser-flag=--remote-debugging-port=9222
Code language: Bash (bash)

Note: Make sure chromedriver is installed and in your system PATH.
You can install it by running npx @puppeteer/browsers install chromedriver@stable or by downloading it directly from https://googlechromelabs.github.io/chrome-for-testing/.

Let’s run the tests again with the new script. Make sure to run it from the root of the repository.

$ ./scripts/integration-test.sh                                                                                                      
Starting ChromeDriver 126.0.6478.126 (d36ace6122e0a59570e258d82441395206d60e1c-refs/branch-heads/6478@{#1591}) on port 4444                                     
Only local connections are allowed.                                                                                                                             
Please see https://chromedriver.chromium.org/security-considerations for suggestions on keeping ChromeDriver safe.                                              
Launching integration_test/main_test.dart on Web Server in debug mode...                                                                                        
Waiting for connection from debug service on Web Server...         10.3s                                                                                        
integration_test/main_test.dart is being served at http://localhost:64683                                                                                       
The web-server device requires the Dart Debug Chrome extension for debugging. Consider using the Chrome or Edge devices for an improved development workflow.   
All tests passed.                                                                                                                                               
Application finished.
Code language: Bash (bash)

Note: If you see an error about bind() failed: Address already in use, you can ignore it so long as your tests still pass.

Our automated integration test is now passing!

TL;DR

If you just wanted to skip to the good stuff for running an integration test in Flutter with web permissions, here it is:

  • Use flutter drive instead of flutter test because flutter test does not yet support integration tests.
  • Add integration_test and webkit_inspection_protocol to dev_dependencies in pubspec.yaml.
  • Use Browser.grantPermissions from the webkit_inspection_protocol package (AKA Chrome DevTools Protocol) to grant the required browser permissions in your test driver.

What’s next?

We’ve successfully written an integration test that relies on permissions from the browser. We automated granting those permissions using the Chrome DevTools Protocol. The next step is to run the integration test as part of a CI/CD pipeline. It can’t be that complicated… right?

Stay tuned for the next post in our series on integration testing with Flutter to learn how to run tests in GitHub Actions. In the meantime, throw your questions in the comments below!

Resources