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.
Incorporate comprehensive unit testing into your Flutter project to ensure best practices and reduce bugs before—not after—the app’s release.
Dacian is a senior full-stack mobile applications developer, a Flutter expert, and a contributor to the Flutter framework. He helps companies around the world design and implement quality software solutions and deliver excellent user experiences.
Previous Role
Senior Mobile DeveloperInterest 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 Flutter testing tutorial, we share workflow optimizations for Flutter unit testing, demonstrate a basic Flutter unit test example, 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:
- Evaluate the code.
- Set up data mocking.
- Define the test group(s).
- Define test function signature(s) for each test group.
- Write the tests.
To demonstrate how to run Flutter tests, 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:
- Each project class supports a single objective.
- 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.
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:
- Evaluate the code.
- Set up data mocking: We’ll define the server response to our API call.
- Define the test groups: We’ll have two test groups, one for each function.
- Define test function signatures for each test group.
- 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.
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 Flutter unit test 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:
- Is the
Dio
class’s function actually called bygetUniversitiesByCountry
? - If our API request returns an error, what happens?
- 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 use unit testing best practices in 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.
Further Reading on the Toptal Blog:
Understanding the basics
How do you do a unit test in Flutter?
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.
Why is unit testing important?
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.
Is MVVM good for Flutter?
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.
How do you use the MVVM pattern in Flutter?
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 associatedView
classes.What is unit testing in Flutter?
Unit testing is the process of testing individual pieces of code, typically very small units of code like classes, methods, and functions.
Bucharest, Romania
Member since November 9, 2020
About the author
Dacian is a senior full-stack mobile applications developer, a Flutter expert, and a contributor to the Flutter framework. He helps companies around the world design and implement quality software solutions and deliver excellent user experiences.