Cover image
Mobile
10 minute read

Unit Testing in Flutter: From Workflow Essentials to Complex Scenarios

Incorporate comprehensive unit testing into your Flutter project to ensure best practices and reduce bugs before—not after—the app’s release.

Interest in Flutter is at an all-time high—and it’s long overdue. Google’s open-source SDK is compatible with Android, iOS, macOS, web, Windows, and Linux. A single Flutter codebase supports them all. And unit testing is instrumental in delivering a consistent and reliable Flutter app, ensuring against errors, flaws, and defects by preemptively improving the quality of code before it is assembled.

In this tutorial, we share workflow optimizations for Flutter unit testing, demonstrate a basic Flutter unit test, then move on to more complex Flutter test cases and libraries.

The Flow of Unit Testing in Flutter

We implement unit testing in Flutter in much the same way that we do in other technology stacks:

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

To demonstrate unit testing, I’ve prepared a sample Flutter project and encourage you to use and test the code at your leisure. The project uses an external API to fetch and display a list of universities that we can filter by country.

A few notes about how Flutter works: The framework facilitates testing by autoloading the flutter_test library when a project is created. The library enables Flutter to read, run, and analyze unit tests. Flutter also autocreates the test folder in which to store tests. It is critical to avoid renaming and/or moving the test folder, as this breaks its functionality and, hence, our ability to run tests. It is also essential to include _test.dart in our test file names, as this suffix is how Flutter recognizes test files.

Test Directory Structure

To promote unit testing in our project, we implemented MVVM with clean architecture and dependency injection (DI), as evidenced in the names chosen for source code subfolders. The combination of MVVM and DI principles ensures a separation of concerns:

  1. Each project class supports a single objective.
  2. Each function within a class fulfills only its own scope.

We’ll create an organized storage space for the test files we’ll write, a system where groups of tests will have easily identifiable “homes.” In light of Flutter’s requirement to locate tests within the test folder, let’s mirror our source code’s folder structure under test. Then, when we write a test, we’ll store it in the appropriate subfolder: Just as clean socks go in the sock drawer of your dresser and folded shirts go in the shirt drawer, unit tests of Model classes go in a folder named model, for example.

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

Adopting this file system builds transparency into the project and affords the team an easy way to view which portions of our code have associated tests.

We are now ready to put unit testing into action.

A Simple Flutter Unit Test

We’ll begin with the model classes (in the data layer of the source code) and will limit our example to include just one model class, ApiUniversityModel. This class boasts two functions:

  • Initialize our model by mocking the JSON object with a Map.
  • Build the University data model.

To test each of the model’s functions, we’ll customize the universal steps described previously:

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

After evaluating our code, we’re ready to accomplish our second objective: to set up data mocking specific to the two functions within the ApiUniversityModel class.

To mock the first function (initializing our model by mocking the JSON with a Map), fromJson, we’ll create two Map objects to simulate the input data for the function. We’ll also create two equivalent ApiUniversityModel objects to represent the expected result of the function with the provided input.

To mock the second function (building the University data model), toDomain, we’ll create two University objects, which are the expected result after having run this function in the previously instantiated ApiUniversityModel objects:

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, for our third and fourth objectives, we’ll add descriptive language to define our test groups and test function signatures:

    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 have defined the signatures of two tests to check the fromJson function, and two to check the toDomain function.

To fulfill our fifth objective and write the tests, let’s use the flutter_test library’s expect method to compare the functions’ results against our expectations:

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);
            });
        });
}

Having accomplished our five objectives, we can now run the tests, either from the IDE or from 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

At a terminal, we can run all tests contained within the test folder by entering the flutter test command, and see that our tests pass.

Alternatively, we could run a single test or test group by entering the flutter test --plain-name "ReplaceWithName" command, substituting the name of our test or test group for ReplaceWithName.

Unit Testing an Endpoint in Flutter

Having completed a simple test with no dependencies, let’s explore a more interesting example: We’ll test the endpoint class, whose scope encompasses:

  • Executing an API call to the server.
  • Transforming the API JSON response into a different format.

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

group("Test University Endpoint API calls", () {
    setUp(() {
        baseUrl = "https://test.url";
        dioClient = Dio(BaseOptions());
        endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl);
    });
}

To make network requests to APIs, I prefer using the retrofit library, which generates most of the necessary code. To properly test the UniversityEndpoint class, we’ll force the dio library—which Retrofit uses to execute API calls—to return the desired result by mocking the Dio class’s behavior through a custom response adapter.

Custom Network Interceptor Mock

Mocking is possible due to our having built the UniversityEndpoint class through DI. (If the UniversityEndpoint class were to initialize a Dio class by itself, there would be no way for us to mock the class’s behavior.)

In order to mock the Dio class’s behavior, we need to know the Dio methods used within the Retrofit library—but we do not have direct access to Dio. Therefore, we’ll mock Dio using a custom network response interceptor:

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);
}

Now that we’ve created the interceptor to mock our network responses, we can define our test groups and test function signatures.

In our case, we have only one function to test (getUniversitiesByCountry), so we’ll create just one test group. We’ll test our function’s response to three situations:

  1. Is the Dio class’s function actually called by getUniversitiesByCountry?
  2. If our API request returns an error, what happens?
  3. If our API request returns the expected result, what happens?

Here’s our test group and test function signatures:

  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 {});
  });

We are ready to write our tests. For each test case, we’ll create an instance of DioMockResponsesAdapter with the corresponding configuration:

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 our endpoint testing is complete, let’s test our data source class, UniversityRemoteDataSource. Earlier, we observed that the UniversityEndpoint class is a part of the constructor UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}), which indicates that UniversityRemoteDataSource uses the UniversityEndpoint class to fulfill its scope, so this is the class we will mock.

Mocking With Mockito

In our previous example, we manually mocked our Dio client’s request adapter using a custom NetworkInterceptor. Here we are mocking an entire class. Doing so manually—mocking a class and its functions—would be time-consuming. Fortunately, mock libraries are designed to handle such situations and can generate mock classes with minimal effort. Let’s use the mockito library, the industry standard library for mocking in Flutter.

To mock through Mockito, we first add the annotation “@GenerateMocks([class_1,class_2,…])” before the test’s code—just above the void main() {} function. In the annotation, we’ll include a list of class names as a parameter (in place of class_1,class_2…).

Next, we run Flutter’s flutter pub run build_runner build command that generates the code for our mock classes in the same directory as the test. The resultant mock file’s name will be a combination of the test file name plus .mocks.dart, replacing the test’s .dart suffix. The file’s content will include mock classes whose names begin with the prefix Mock. For example, UniversityEndpoint becomes MockUniversityEndpoint.

Now, we import university_remote_data_source_test.dart.mocks.dart (our mock file) into university_remote_data_source_test.dart (the test file).

Then, in the setUp function, we’ll mock UniversityEndpoint by using MockUniversityEndpoint and initializing the UniversityRemoteDataSource class:

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);
        });
}

We successfully mocked UniversityEndpoint and then initialized our UniversityRemoteDataSource class. Now we’re ready to define our test groups and test function signatures:

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', () {});
});

With this, our mocking, test groups, and test function signatures are set up. We are ready to write the actual tests.

Our first test checks whether the UniversityEndpoint function is called when the data source initiates the fetching of country information. We begin by defining how each class will react when its functions are called. Since we mocked the UniversityEndpoint class, that’s the class we’ll work with, using the when( function_that_will_be_called ).then( what_will_be_returned ) code structure.

The functions we are testing are asynchronous (functions that return a Future object), so we’ll use the when(function name).thenanswer( (_) {modified function result} ) code structure to modify our results.

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

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

Now that we’ve mocked our response, we call the data source function and check—using the verify function—whether the UniversityEndpoint function was called:

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

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

We can use the same principles to write additional tests that check whether our function correctly transforms our endpoint results into the relevant streams of data:

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 have executed a number of Flutter unit tests and demonstrated different approaches to mocking. I invite you to continue to use my sample Flutter project to run additional testing.

Flutter Unit Tests: Your Key to Superior UX

If you already incorporate unit testing into your Flutter projects, this article may have introduced some new options you could inject into your workflow. In this tutorial, we demonstrated how straightforward it would be to incorporate unit testing into your next Flutter project and how to tackle the challenges of more nuanced test scenarios. You may never want to skip over unit tests in Flutter again.

The editorial team of the Toptal Engineering Blog extends its gratitude to Matija Bečirević and Paul Hoskins for reviewing the code samples and other technical content presented in this article.

Understanding the basics

The process for unit testing in Flutter is the same as it is in most frameworks. After defining the classes and functions to be tested (the test cases), (1) assess the code, (2) set up data mocking, (3) define the test groups, (4) define test function signature(s for each test group, and (5) Write and run the tests.

Unit testing prevents or greatly reduces bugs in an application, delivering a quality user experience from an app's first release. An added benefit: Reading unit tests helps new developers learn and understand your code.

MVVM (the Model-View-ViewModel pattern) enhances a codebase's stability and scalability. Code enhancement is a natural consequence of our writing cleaner code to conform to MVVM's architectural requirements.

MVVM architecture modularizes our code: Classes within the Model module source our data. The View module presents our data through UI widgets. Lastly, the `ViewModel` classes obtain and provide data to their associated `View` classes.