Unit Testing in Flutter: Covering Workflow Essentials and Complex Scenarios

There’s a surge of interest in Flutter right now, and it’s about time! This open-source SDK from Google is incredibly versatile, working seamlessly with https://trends.google.com/trends/explore?date=today%205-y&q=%2Fg%2F11f03_rzbg,%2Fg%2F11h03gfxy9,%2Fm%2F0gtv959,Cordova,%2Fg%2F1q6l_n0n0)—and. What’s even better is that you only need a single codebase to support all these platforms. Unit testing plays a crucial role in building robust and reliable Flutter apps. By catching errors early on, it helps improve the code quality before the app is even put together.

This Flutter testing tutorial is your guide to optimizing your unit testing workflow. We’ll start with a basic Flutter unit test example and then dive into more intricate test cases and libraries.

How Unit Testing Works in Flutter

Unit testing in Flutter follows a similar pattern to other tech stacks:

  1. Analyze the code.
  2. Set up data mocking.
  3. Define the test group(s).
  4. Outline test function signatures for each test group.
  5. Write the tests.

To illustrate the process of running Flutter tests, I’ve created a sample Flutter project](https://github.com/dacianf/flutter_rxdart_state_management) that you’re welcome to use. This project focuses on [test the code at your leisure. The project uses an external API and allows filtering by country.

Here’s a peek into Flutter’s inner workings: When a project is created, Flutter automatically loads the flutter_test library to facilitate testing. This library allows Flutter to read, run, and analyze your unit tests. It also automatically creates a test folder for storing your tests. It’s vital not to rename or move this folder, as it would disrupt the testing functionality. Similarly, always use the _test.dart suffix in your test file names, as Flutter uses this to identify test files.

Structuring Your Test Directory

To streamline unit testing in our project, we’ve adopted MVVM with clean architecture and dependency injection (DI)](https://stackify.com/dependency-injection/), as reflected in our source code subfolders. Using both [MVVM and DI ensures that:

  1. Each class in the project has a single, well-defined purpose.
  2. Each function within a class operates solely within its intended scope.

We’ll organize our test files in a way that mirrors our source code structure within the test folder. This means that tests for Model classes will go into a folder named model, much like how you would organize your belongings.

File folder structure with two first-level folders: lib and test. Nested beneath lib we have the features folder, further nested is universities_feed, and further nested is data. The data folder contains the repository and source folders. Nested beneath the source folder is the network folder. Nested beneath network are the endpoint and model folders, plus the university_remote_data_source.dart file. In the model folder is the api_university_model.dart file. At the same level as the previously-mentioned universities_feed folder are the domain and presentation folders. Nested beneath domain is the usecase folder. Nested beneath presentation are the models and screen folders. The previously-mentioned test folder's structure mimics that of lib. Nested beneath the test folder is the unit_test folder which contains the universities_feed folder. Its folder structure is the same as the above universities_feed folder, with its dart files having "_test" appended to their names.
The Project’s Test Folder Structure Mirroring the Source Code Structure

This structured approach enhances project transparency and allows the team to easily identify which parts of the code have corresponding tests.

Now, let’s put unit testing into practice.

A Simple Flutter Unit Test Example

Starting with the model classes in the data layer of our source code, we’ll focus on the ApiUniversityModel model class for this example. This class has two main functions:

  • Initializing the model by mimicking a JSON object using a Map.
  • Building the University data model.

We’ll follow our five-step process to test each function:

  1. Evaluate the code.
  2. Set up data mocking: We’ll simulate the server response to our API call.
  3. Define the test groups: One for each function.
  4. Define test function signatures for each test group.
  5. Write the tests.

With the code evaluation done, let’s move on to data mocking. For the fromJson function (initializing the model with a Map), we’ll create two Map objects to simulate input data. We’ll also create two corresponding ApiUniversityModel objects to represent the expected output.

To mock the toDomain function (building the University data model), we’ll create two University objects representing the expected outcome after running this function on the previously created ApiUniversityModel objects:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void main() {
    Map<String, dynamic> apiUniversityOneAsJson = {
        "alpha_two_code": "US",
        "domains": ["marywood.edu"],
        "country": "United States",
        "state-province": null,
        "web_pages": ["http://www.marywood.edu"],
        "name": "Marywood University"
    };
    ApiUniversityModel expectedApiUniversityOne = ApiUniversityModel(
        alphaCode: "US",
        country: "United States",
        state: null,
        name: "Marywood University",
        websites: ["http://www.marywood.edu"],
        domains: ["marywood.edu"],
    );
    University expectedUniversityOne = University(
        alphaCode: "US",
        country: "United States",
        state: "",
        name: "Marywood University",
        websites: ["http://www.marywood.edu"],
        domains: ["marywood.edu"],
    );
 
    Map<String, dynamic> apiUniversityTwoAsJson = {
        "alpha_two_code": "US",
        "domains": ["lindenwood.edu"],
        "country": "United States",
        "state-province":"MJ",
        "web_pages": null,
        "name": "Lindenwood University"
    };
    ApiUniversityModel expectedApiUniversityTwo = ApiUniversityModel(
        alphaCode: "US",
        country: "United States",
        state:"MJ",
        name: "Lindenwood University",
        websites: null,
        domains: ["lindenwood.edu"],
    );
    University expectedUniversityTwo = University(
        alphaCode: "US",
        country: "United States",
        state: "MJ",
        name: "Lindenwood University",
        websites: [],
        domains: ["lindenwood.edu"],
    );
}

Next, let’s define our test groups and function signatures:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    void main() {
    // Previous declarations
        group("Test ApiUniversityModel initialization from JSON", () {
            test('Test using json one', () {});
            test('Test using json two', () {});
        });
        group("Test ApiUniversityModel toDomain", () {
            test('Test toDomain using json one', () {});
            test('Test toDomain using json two', () {});
        });
}

We’ve now defined the signatures for two tests to verify the fromJson function and two more for the toDomain function.

To write the actual tests, we’ll use the expect method from the flutter_test library to compare the function outputs with our expectations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void main() {
    // Previous declarations
        group("Test ApiUniversityModel initialization from json", () {
            test('Test using json one', () {
                expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson),
                    expectedApiUniversityOne);
            });
            test('Test using json two', () {
                expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson),
                    expectedApiUniversityTwo);
            });
        });

        group("Test ApiUniversityModel toDomain", () {
            test('Test toDomain using json one', () {
                expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson).toDomain(),
                    expectedUniversityOne);
            });
            test('Test toDomain using json two', () {
                expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson).toDomain(),
                    expectedUniversityTwo);
            });
        });
}

With all five steps completed, we can run our tests, either from our IDE or the command line.

Screenshot indicating that five out of five tests passed. Header reads: Run: tests in api_university_model_test.dart. Left panel of the screen reads: Test results---loading api_university_model_test.dart---api_university_model_test.dart---Test ApiUniversityModel initialization from json---Test using json one---Test using json two---Tests ApiUniversityModel toDomain---Test toDomain using json one---Test toDomain using json two. The right panel of the screen reads: Tests passed: five of five tests---flutter test test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

To run all tests within the test folder from a terminal, use the flutter test command. You’ll see that our tests pass.

To run a specific test or test group, use flutter test --plain-name "ReplaceWithName", replacing ReplaceWithName with the name of your test or test group.

Testing an Endpoint in Flutter

Let’s move on to a more engaging Flutter unit test example. This time, we’ll test the endpoint class, which handles:

  • Making an API call to the server.
  • Converting the API’s JSON response into a different format.

After evaluating the code, we’ll use the setUp method from flutter_test library to initialize the classes within our test group:

1
2
3
4
5
6
7
group("Test University Endpoint API calls", () {
    setUp(() {
        baseUrl = "https://test.url";
        dioClient = Dio(BaseOptions());
        endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl);
    });
}

For API requests, I prefer using retrofit library, as it automates much of the code generation. To test the UniversityEndpoint class effectively, we’ll manipulate the dio library (used by Retrofit for API calls) to return our desired result. We’ll achieve this by mocking the Dio class’s behavior using a custom response adapter.

Mocking Network Responses With a Custom Interceptor

This mocking is possible because we built the UniversityEndpoint class with DI. (If it directly initialized a Dio class, mocking wouldn’t be possible.)

To mock the Dio class, we need to know which Dio methods the Retrofit library uses. However, we lack direct access to Dio. So, we’ll mock it using a custom network response interceptor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class DioMockResponsesAdapter extends HttpClientAdapter {
  final MockAdapterInterceptor interceptor;

  DioMockResponsesAdapter(this.interceptor);

  @override
  void close({bool force = false}) {}

  @override
  Future<ResponseBody> fetch(RequestOptions options,
      Stream<Uint8List>? requestStream, Future? cancelFuture) {
    if (options.method == interceptor.type.name.toUpperCase() &&
        options.baseUrl == interceptor.uri &&
        options.queryParameters.hasSameElementsAs(interceptor.query) &&
        options.path == interceptor.path) {
      return Future.value(ResponseBody.fromString(
        jsonEncode(interceptor.serializableResponse),
        interceptor.responseCode,
        headers: {
          "content-type": ["application/json"]
        },
      ));
    }
    return Future.value(ResponseBody.fromString(
        jsonEncode(
              {"error": "Request doesn't match the mock interceptor details!"}),
        -1,
        statusMessage: "Request doesn't match the mock interceptor details!"));
  }
}

enum RequestType { GET, POST, PUT, PATCH, DELETE }

class MockAdapterInterceptor {
  final RequestType type;
  final String uri;
  final String path;
  final Map<String, dynamic> query;
  final Object serializableResponse;
  final int responseCode;

  MockAdapterInterceptor(this.type, this.uri, this.path, this.query,
      this.serializableResponse, this.responseCode);
}

With our interceptor ready, let’s define our test groups and function signatures.

Since we only need to test the getUniversitiesByCountry function, we’ll create a single test group with tests for three scenarios:

  1. Does getUniversitiesByCountry actually call the Dio class’s function?
  2. What happens if our API request returns an error?
  3. What happens if our API request returns the expected result?

Here’s our test group and function signatures:

1
2
3
4
5
6
7
8
  group("Test University Endpoint API calls", () {

    test('Test endpoint calls dio', () async {});

    test('Test endpoint returns error', () async {});

    test('Test endpoint calls and returns 2 valid universities', () async {});
  });

For each test case, we’ll create a DioMockResponsesAdapter instance with the appropriate configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
group("Test University Endpoint API calls", () {
    setUp(() {
        baseUrl = "https://test.url";
        dioClient = Dio(BaseOptions());
        endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl);
    });

    test('Test endpoint calls dio', () async {
        dioClient.httpClientAdapter = _createMockAdapterForSearchRequest(
            200,
            [],
        );
        var result = await endpoint.getUniversitiesByCountry("us");
        expect(result, <ApiUniversityModel>[]);
    });

    test('Test endpoint returns error', () async {
        dioClient.httpClientAdapter = _createMockAdapterForSearchRequest(
            404,
            {"error": "Not found!"},
        );
        List<ApiUniversityModel>? response;
        DioError? error;
        try {
            response = await endpoint.getUniversitiesByCountry("us");
        } on DioError catch (dioError, _) {
            error = dioError;
        }
        expect(response, null);
        expect(error?.error, "Http status error [404]");
    });

    test('Test endpoint calls and returns 2 valid universities', () async {
        dioClient.httpClientAdapter = _createMockAdapterForSearchRequest(
            200,
            generateTwoValidUniversities(),
        );
        var result = await endpoint.getUniversitiesByCountry("us");
        expect(result, expectedTwoValidUniversities());
    });
});

Now that we’ve thoroughly tested our endpoint, let’s move on to the UniversityRemoteDataSource data source class. We know that the UniversityEndpoint class is part of the UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) constructor. This tells us that UniversityRemoteDataSource relies on UniversityEndpoint, making it the ideal candidate for mocking.

Mocking With Mockito

In the previous example, we manually mocked our Dio client’s request adapter. Now, we’re mocking an entire class. Doing this manually would be tedious. Thankfully, mock libraries can generate mock classes with minimal effort. We’ll be using mockito library, the go-to library for mocking in Flutter.

To use Mockito, add the @GenerateMocks([class_1,class_2,…]) annotation before the test code (above void main() {}). Replace class_1,class_2… with a list of class names.

Next, run Flutter’s flutter pub run build_runner build command. This generates mock class code in the same directory as the test file. The generated file will be named <test_file_name>.mocks.dart, and it will contain mock classes prefixed with Mock (e.g., UniversityEndpoint becomes MockUniversityEndpoint).

Import this newly created file (university_remote_data_source_test.dart.mocks.dart) into your test file (university_remote_data_source_test.dart).

In the setUp function, mock UniversityEndpoint using MockUniversityEndpoint and initialize the UniversityRemoteDataSource class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import 'university_remote_data_source_test.mocks.dart';

@GenerateMocks([UniversityEndpoint])
void main() {
    late UniversityEndpoint endpoint;
    late UniversityRemoteDataSource dataSource;

    group("Test function calls", () {
        setUp(() {
            endpoint = MockUniversityEndpoint();
            dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint);
        });
}

With UniversityEndpoint mocked and UniversityRemoteDataSource initialized, let’s define our test groups and function signatures:

1
2
3
4
5
6
7
8
group("Test function calls", () {

  test('Test dataSource calls getUniversitiesByCountry from endpoint', () {});

  test('Test dataSource maps getUniversitiesByCountry response to Stream', () {});

  test('Test dataSource maps getUniversitiesByCountry response to Stream with error', () {});
});

Our mocking, test groups, and function signatures are ready. Time to write the tests!

Our first test checks if the getUniversitiesByCountry function calls its counterpart within the UniversityEndpoint class. We start by defining how each class should behave when its functions are called. Since we mocked UniversityEndpoint, we’ll use it with the when( function_that_will_be_called ).then( what_will_be_returned ) structure.

As we’re testing asynchronous functions (returning a Future), we’ll use when(function name).thenAnswer( (_) {modified function result} ) to manipulate the results.

To see if getUniversitiesByCountry calls the UniversityEndpoint function, we’ll use when(...).thenAnswer( (_) {...} ) to mock the getUniversitiesByCountry function within the UniversityEndpoint class:

1
2
when(endpoint.getUniversitiesByCountry("test"))
    .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

With our response mocked, we call the data source function and use the verify function to check if the UniversityEndpoint function was called:

1
2
3
4
5
6
7
test('Test dataSource calls getUniversitiesByCountry from endpoint', () {
    when(endpoint.getUniversitiesByCountry("test"))
        .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

    dataSource.getUniversitiesByCountry("test");
    verify(endpoint.getUniversitiesByCountry("test"));
});

Following the same principles, we can write more tests to verify that our function correctly transforms endpoint results into the appropriate data streams:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import 'university_remote_data_source_test.mocks.dart';

@GenerateMocks([UniversityEndpoint])
void main() {
    late UniversityEndpoint endpoint;
    late UniversityRemoteDataSource dataSource;

    group("Test function calls", () {
        setUp(() {
            endpoint = MockUniversityEndpoint();
            dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint);
        });

        test('Test dataSource calls getUniversitiesByCountry from endpoint', () {
            when(endpoint.getUniversitiesByCountry("test"))
                    .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

            dataSource.getUniversitiesByCountry("test");
            verify(endpoint.getUniversitiesByCountry("test"));
        });

        test('Test dataSource maps getUniversitiesByCountry response to Stream',
                () {
            when(endpoint.getUniversitiesByCountry("test"))
                    .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

            expect(
                dataSource.getUniversitiesByCountry("test"),
                emitsInOrder([
                    const AppResult<List<University>>.loading(),
                    const AppResult<List<University>>.data([])
                ]),
            );
        });

        test(
                'Test dataSource maps getUniversitiesByCountry response to Stream with error',
                () {
            ApiError mockApiError = ApiError(
                statusCode: 400,
                message: "error",
                errors: null,
            );
            when(endpoint.getUniversitiesByCountry("test"))
                    .thenAnswer((realInvocation) => Future.error(mockApiError));

            expect(
                dataSource.getUniversitiesByCountry("test"),
                emitsInOrder([
                    const AppResult<List<University>>.loading(),
                    AppResult<List<University>>.apiError(mockApiError)
                ]),
            );
        });
    });
}

We’ve covered various Flutter unit tests and mocking approaches. Feel free to experiment with my sample Flutter project for further testing.

Flutter Unit Tests: Enhance Your User Experience

Whether you’re already familiar with unit testing in Flutter or just starting, this article provided insights and techniques to enhance your workflow. We’ve demonstrated how to apply unit testing best practices to your next Flutter project and tackle complex testing scenarios. Now, skipping unit tests might be a thing of the past!

A special thanks to Matija Bečirević and Paul Hoskins from the Toptal Engineering Blog editorial team for reviewing the code samples and technical content in this article.

Licensed under CC BY-NC-SA 4.0