Builders: The podcast where we discuss the ups and downs of building great tech products Stream here

About Flutter unit testing

Unit testing in Flutter is a crucial aspect of ensuring the stability and reliability of your mobile applications. By systematically testing individual units or components of your code in isolation, you can identify and fix bugs early in the development process. In this comprehensive guide to Flutter unit testing, we will delve into the fundamentals, best practices, and strategies to effectively test your Flutter applications.

Whether you are a beginner looking to grasp the basics or a seasoned developer aiming to enhance your testing skills, this document will equip you with the knowledge and tools needed to streamline your unit testing process in your Flutter apps. Let's dive into the world of Flutter unit testing and elevate the quality of your mobile apps.

Flutter Unit Testing essentials

Understanding the basics

Unit testing is all about verifying the correctness of the smallest testable parts of your application, called units. In Flutter, a unit could be a single function call, method, or class. The goal is to ensure that each unit operates as intended in isolation from the rest of the codebase.

When you're working with Flutter unit testing, you start by writing test cases — these are specific conditions under which you check the behavior of your units. The flutter framework provides a powerful set of tools to help you write and run these tests, including the 'test' package, which supplies a comprehensive API for writing test cases, and 'flutter_test', which extends 'test' to include additional widgets core features and tools for testing Flutter-specific elements.

Understanding these basics sets the foundation for writing tests and implementing effective unit tests, leading to more reliable and maintainable code.

Setting up your environment

To begin flutter unit testing, you first need to set up your testing environment. This setup includes adding the necessary dependencies to your pubspec.yaml file. You'll usually include the test package for pure Dart tests and flutter_test, which comes with the Flutter SDK for unit testing important widgets and other Flutter-related functionality.

After adding the dependencies, create a test directory where you'll store all your test files. By convention, this is a top-level folder named the test folder in your Flutter project. Inside this directory, you'll mirror the structure of your lib folder. For example, if you have a file named calculator.dart in a lib/math directory, you should create a calculator_test.dart in a test/math directory.

Remember to import the necessary packages at the beginning of your test files to access testing functions and assertions. With your environment set up correctly, you're ready to write your first unit tests.

Writing your first test case

Structuring a simple test

A simple test in Flutter is structured around three primary concepts: initialization, execution, and verification. This pattern is often referred to as arrange, act, and assert (AAA). To start, you need to arrange by initializing the unit you want to test. This could involve creating an instance of a class or setting up mock dependencies.

Next, you act the test dependency by invoking the method or function you wish to test. It's essential that this step is executed in isolation, which might mean isolating the unit from external dependencies using mocking or stubbing techniques.

Finally, you assert by checking that the outcome of the execution matches your expectations. This usually involves using the expect function provided by the test package, where you pass in the result of the execution and the expected value.

Here's an example structure of a simple Flutter unit test:

test('description of test', () {
  // Arrange
  var calculator = new Calculator();

  // Act
  var result = calculator.add(2, 2);

  // Assert
  expect(result, 4);
});

By following this structure, you can create clear and concise tests for your units.

Asserting outcomes

Asserting outcomes is the final step in the testing process, where you validate the results of your unit tests. The expect function is the cornerstone of asserting in Flutter unit tests — it compares the actual result of writing unit tests with the expected result and throws an error if they do not match.

In Flutter, assertions can be simple value comparisons or more complex matchers provided by the test package. For instance, you can check for equality, whether a certain exception is thrown or if an iterable contains specific elements. The variety of matchers available allows you to write precise and meaningful assertions for a wide range of scenarios.

Here's an example of asserting different outcomes:

test('value should be positive', () {
  var result = someFunctionThatReturnsAnInteger();

  // Simple equality assertion
  expect(result, greaterThan(0));

  // Type and value assertion
  expect(result, isA<int>().having((e) => e.isEven, 'even', true));

  // Exception assertion
  expect(() => someFunctionThatThrows(), throwsA(isA<Exception>()));
});

Effective assertions are key to confirming that your code behaves as expected and are an integral part of writing reliable unit tests.

Testing widgets in Flutter

Mocking dependencies

When testing widgets in Flutter, you often encounter scenarios where you need to test a widget that depends on external classes or data. Mocking these dependencies is crucial for isolating core functionality of the widget and testing it in a controlled environment. Mocking allows you to simulate the behavior of the dependencies without using the actual implementations.

To mock dependencies in Flutter, you can use packages such as Mockito. This package enables you to create mock classes and define their behavior. Once your mock is set up, you can inject it into your widget under test, allowing for repeatable and predictable testing.

Here's a simple example of mocking in action:

import 'package:mockito/mockito.dart';
import 'package:flutter_test/flutter_test.dart';

class MockDependency extends Mock implements SomeDependency {}

void main() {
  testWidgets('Widget should use mocked dependency', (WidgetTester tester) async {
    final mockDependency = MockDependency();
    when(mockDependency.someMethod()).thenReturn('Mocked Value');

    await tester.pumpWidget(MyWidget(dependency: mockDependency));

    expect(find.text('Mocked Value'), findsOneWidget);
  });
}

Mocking is a powerful technique that can help ensure your widget integration tests are not affected by the complexities of external dependencies.

Interacting with widgets

Interacting with widgets is an integral part of testing them. It allows you to simulate how users will interact with your application. The flutter_test package provides a WidgetTester that enables you to programmatically interact with widgets by tapping, dragging, typing, and more.

To begin, you use the pumpWidget method to build and render the widget you want to test. After that, you can use methods like tap, drag, or enterText to interact with your widgets. For example, to test a button press, you would use the tap method on the button's Finder.

After the interaction, it's important to call pump to trigger a rebuild of the widget tree and to settle animations and futures. Here is an example:

testWidgets('Tapping button should increment counter', (WidgetTester tester) async {
  await tester.pumpWidget(MyApp());

  // Tap the '+' icon and trigger a frame.
  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();

  // Verify that our counter has incremented.
  expect(find.text('1'), findsOneWidget);
});

By simulating interactions, you can verify that the widgets respond correctly to user input.

Advanced Flutter testing techniques

Asynchronous testing

Asynchronous testing is vital when working with operations that don't complete immediately, such as API calls or database queries. Flutter tests need to be able to handle these operations to ensure that your app functions correctly under all conditions.

To write an asynchronous test, you use the async keyword in your test function, which allows you to use await to pause the test until the asynchronous operation completes. In Flutter, you often combine this with the pumpAndSettle method from WidgetTester, which waits for all animations and microtasks to finish.

Here's an example of an asynchronous test:

testWidgets('Async operation should complete', (WidgetTester tester) async {
  await tester.pumpWidget(MyAsyncWidget());

  // Trigger the async operation and rebuild the widget after completion.
  await tester.tap(find.byType(FloatingActionButton));
  await tester.pumpAndSettle();

  // Check for updated widget state.
  expect(find.text('Operation Completed'), findsOneWidget);
});

This approach ensures that your tests accurately reflect the user experience in scenarios where operations may not have an immediate outcome.

Testing Streams and Providers

Testing streams and providers is an important aspect of ensuring your Flutter application handles state management and asynchronous streams correctly. Streams are often used to handle real-time data, and providers manage state across your Flutter app. To test these, you make use of the StreamController and the Provider package.

You start by creating a mock stream and injecting it into your widget. Then, you can simulate the stream's outputs and test how your widgets react to the data over time. This helps ensure your UI updates correctly in response to stream changes.

Here's a basic structure for testing streams:

testWidgets('Stream should update data', (WidgetTester tester) async {
  StreamController<MyData> streamController = StreamController<MyData>();
  await tester.pumpWidget(MyWidget(stream: streamController.stream));

  streamController.add(MyData('new data'));
  await tester.pumpAndSettle();

  expect(find.text('new data'), findsOneWidget);

  streamController.close();
});

Similarly, when testing providers, you can use Provider.override to supply your test widgets with mock data. By thoroughly testing streams and providers, you safeguard against data synchronization issues in your application.

Best practices in Flutter Unit Testing

Organizing test code

Organizing test code effectively is crucial for maintainability and readability. A well-structured test suite is easier to navigate and update, especially as your application grows in complexity.

First, ensure that your test files mirror the directory structure of your lib folder. This approach makes it easier to locate the tests for a particular piece of functionality. Group-related tests use the group function, which helps outline different scenarios for a single widget or function.

Use descriptive and specific names for your test cases. The test description should clearly state what condition is being tested and what the expected outcome combining multiple tests is. This clarity is beneficial when tests fail, as it helps you quickly identify what went wrong.

Here's an example of organizing multiple tests into a test file:

group('Calculator Tests', () {
  test('Adding two numbers should return the sum', () {
    // Test code here
  });

  test('Subtracting two numbers should return the difference', () {
    // Test code here
  });

  // More Calculator related tests
});

By keeping your test code well-organized, you not only make your tests more effective but also foster a testing environment that can scale with your application.

Continuous integration for Flutter Tests

Incorporating your Flutter unit tests into a continuous integration (CI) pipeline is a best practice that can greatly improve the quality and reliability of your codebase. CI enables you to run tests automatically whenever changes are made, ensuring that new code doesn't break existing functionality.

To set up CI for Flutter, you'll need to choose a CI platform that supports Flutter environments, such as GitHub Actions, GitLab CI, or Bitrise. Once chosen, configure the pipeline to install the Flutter SDK, dependencies, and then execute your test suite.

An example of a CI pipeline might include steps like:

Checking out the code from version control.

Installing the correct Flutter SDK version.

Running Flutter pub get to fetch dependencies.

Executing flutter test to run all unit tests.

By running tests on every push or pull request, you ensure that code changes are validated, leading to more stable builds and a robust development process.

Find your next developer within days, not months

We can help you deliver your product faster with an experienced remote developer. All from €31.90/hour. Only pay if you’re happy with your first week.

In a short 25-minute call, we would like to:

  • Understand your development needs
  • Explain our process to match you with qualified, vetted developers from our network
  • Share next steps to finding the right match, often within less than a week

Not sure where to start?

Let’s have a chat

First developer starts within days. No aggressive sales pitch.