Making sure things work our way
With Dart language unit test basics covered, now let’s get back to testing our retrofit_dio_example
app’s HomeViewModel
and make a unit test for it. For this article, we need to install a new dev dependency called mockito
. Open up your pubspec.yaml
file and add this line in dev_dependencies
section:
mockito: ^5.0.11
Save the file and run flutter pub get
.
After installing mockito
, create a new file in test/core/viewmodels/home_view_model_test.dart
and begin with the basic unit test structure. Since HomeViewModel
are just a simple list view page, which loads the data from PostApi
, there are only 2 possibilities of test case:
- Successful posts list load from
PostApi
; - Failed posts list load from
PostApi
.
With the test cases possibilities, let’s write the unit test structure down:
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('init model HomeViewModel test', () {
test('success init HomeViewModel', () async {
});
test('fail init HomeViewModel', () async {
});
});
}
Now, we need the HomeViewModel
object to do the testing on, let’s create one by defining a HomeViewModel
variable as late init in main()
function. Why late init the HomeViewModel
? It is because we may want to reset the HomeViewModel
after each unit test case being executed. We want to start with clean slate for every unit test case so that previous value does not taint our unit test case and drive them to fail due to dirty values.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:retrofit_dio_example/core/viewmodels/home_view_model.dart';
void main() {
late HomeViewModel model;
group('init model HomeViewModel test', () {
test('success init HomeViewModel', () async {
});
test('fail init HomeViewModel', () async {
});
});
}
Since the model
variable is a late init one, we can’t use it right away in our unit test cases, we need to initialize the HomeViewModel
, to set up a brand new clean HomeViewModel
for each unit test case, we need to use setUp
function in our unit test group, this setUp
call will be executed before every unit test case being run. Let’s write our setUp
function to initialize HomeViewModel
:
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:retrofit_dio_example/core/viewmodels/home_view_model.dart';
void main() {
late HomeViewModel model;
group('init model HomeViewModel test', () {
setUp(() {
model = HomeViewModel(
);
});
test('success init HomeViewModel', () async {
});
test('fail init HomeViewModel', () async {
});
});
}
Uh oh, the IDE throws an error of missing required parameter of postApi
. Turns out we need PostApi
in order to instantiate HomeViewModel
in our unit test, but the actual instance of PostApi
will certainly make a real HTTP request to the JSON Placeholder API, which is something we do not want and avoid at all costs in unit testing.
Instead, we will mock PostApi
with the help of mockito
package we installed earlier, we can define our mock version of PostApi
by making a class with any name which extends from Mock
and implements PostApi
. In this case we will import mockito
and name our mock PostApi
class as _MockPostApi
.
import 'package:mockito/mockito.dart';
class _MockPostApi extends Mock implements PostApi {}
Then, we can make a new instance of _MockPostApi
in main()
and pass it to the HomeViewModel
constructor in our unit test group setUp
function.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:retrofit_dio_example/core/api/post_api.dart';
import 'package:retrofit_dio_example/core/viewmodels/home_view_model.dart';
class _MockPostApi extends Mock implements PostApi {}
void main() {
final _MockPostApi postApi = _MockPostApi();
late HomeViewModel model;
group('init model HomeViewModel test', () {
setUp(() {
model = HomeViewModel(
postApi: postApi,
);
});
test('success init HomeViewModel', () async {
});
test('fail init HomeViewModel', () async
});
});
}
With model
variable being set up, we can begin working on our unit test cases. We will begin by writing the test case for success init HomeViewModel
. Since HomeViewModel
initModel
function calls postApi.getPosts
, therefore we need to define our mock PostApi
response to such call, otherwise it will return null
and returns a funny, confusing error in the unit test. We’ll take a look at PostApi.getPosts
function and see its return value, then we create a mock response for PostApi.getPosts
function call.
The function PostApi.getPosts
does return a future of HttpResponse<List<Post>>
as indicated below:
@GET('/posts')
Future<HttpResponse<List<Post>>> getPosts();
Now a question arises what if the List<Post> is null given the JSON Placeholder API may be going down or the user’s network connection may be problematic at the time of usage? We need to do a little refactor in lib/core/api/post_api.dart
and update HttpResponse<List<Post>
> to HttpResponse<List<Post>?>
@GET('/posts')
Future<HttpResponse<List<Post>?>> getPosts();
and run flutter pub run build_runner build --delete-conflicting-outputs
to regenerate all generated Retrofit and model codes to reflect our latest changes.
With the changes we make recently, HomeViewModel
fetchPosts
function will throw an error in line 32 where it assigns the response data to posts
state variable due to expecting the [response.data](<http://response.data>)
to be not null while it is now nullable. To eliminate the compile error, simple replace the line posts = response.data
with posts = response.data ?? <Post>[];
.
With refactoring out of the way, we can continue filling out first unit test case, that is success init HomeViewModel
. We begin writing the test case with declaring the mock response of PostApi.getPosts
method then we “teach” our mock version of PostApi
, _MockPostApi
to return our predefined mock response for getPosts
function call, and finally we can do assertions to see if the HomeViewModel
has successfully loaded posts list from our _MockPostApi
.
Defining mock response is as simple as writing normal variables, with PostApi.getPosts
function returns HttpResponse<List<Post>?>
type, we can make such mock to be:
import 'package:retrofit/dio.dart';
import 'package:retrofit_dio_example/core/models/post.dart';
import 'package:dio/dio.dart' as dio;
final HttpResponse<List<Post>> successFetchPostResponse = HttpResponse<List<Post>>(
<Post>[
Post(id: 1),
Post(id: 2),
],
dio.Response<dynamic>(
statusCode: HttpStatus.ok,
requestOptions: dio.RequestOptions(path: ''),
),
);
With the first parameter being the actual response object, a list of Post
objects and the latter parameter being Dio-related stuff for storing HTTP status codes for the mock response. Now our unit test file shall be looking like:
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:retrofit/dio.dart';
import 'package:retrofit_dio_example/core/api/post_api.dart';
import 'package:retrofit_dio_example/core/models/post.dart';
import 'package:dio/dio.dart' as dio;
import 'package:retrofit_dio_example/core/viewmodels/home_view_model.dart';
class _MockPostApi extends Mock implements PostApi {}
void main() {
final _MockPostApi postApi = _MockPostApi();
final HttpResponse<List<Post>> successFetchPostResponse =
HttpResponse<List<Post>>(
<Post>[
Post(id: 1),
Post(id: 2),
],
dio.Response<dynamic>(
statusCode: HttpStatus.ok,
requestOptions: dio.RequestOptions(path: ''),
),
);
late HomeViewModel model;
group('init model HomeViewModel test', () {
setUp(() {
model = HomeViewModel(
postApi: postApi,
);
});
test('success init HomeViewModel', () async {
});
test('fail init HomeViewModel', () async {
});
});
}
Now that we have our mock http response for successful PostApi.getPosts
call in HomeViewModel
initModel
function, let’s move on to teaching our _MockPostApi
postApi
variable to respond with successFetchPostResponse
whenever getPosts
is called upon them.
We can use when
function and pass the function call, yes the actual function call to the parameter of when
and then with a dot, you can either teach them to respond with thenReturn
or thenAnswer
. The difference between these two are thenReturn
is for non-async functions and thenAnswer
is for async, Future-based functions.
In this case, we will use thenAnswer
to teach our _MockPostApi
in test case success init HomeViewModel
:
when(postApi.getPosts()).thenAnswer(
(_) async => successFetchPostResponse,
);
After teaching _MockPostApi
we can actually invoke initModel
of our HomeViewModel
model
variable:
test('success init HomeViewModel', () async {
when(postApi.getPosts()).thenAnswer(
(_) async => successFetchPostResponse,
);
await model.initModel();
});
Then do our assertions after model.initModel
has done its job, given the function of initModel
is to load posts from PostApi
and storing it in variable state posts
, we need to assert that:
- The
posts
state variable length is the same as the mock response we taught earlier; - The sample post id in
posts
are the same as the mock response we taught earlier.
Given those asserts, let’s add the asserts right after model.initModel
statement:
expect(model.posts.length, 2);
expect(model.posts[0].id, 1);
expect(model.posts[1].id, 2);
And our test case now looks like:
test('success init HomeViewModel', () async {
when(postApi.getPosts()).thenAnswer(
(_) async => successFetchPostResponse,
);
await model.initModel();
expect(model.posts.length, 2);
expect(model.posts[0].id, 1);
expect(model.posts[1].id, 2);
});
Try running the unit test and if you’re doing it correctly, it will pass quickly.
After filling the successful load posts list unit test case, we can now fill the failing case unit test, named fail init HomeViewModel
. Same as our successful unit test case, we need to define the failing response for HttpResponse<List<Post>?>
and teach _MockPostApi
to return failing response upon PostApi.getPosts
being called upon.
Defining failing response is fairly easy, since we can simply copy the previously succeeding mock response and replacing the first parameter with null
value and changing the HttpStatus.ok
into HttpStatus.internalServerError
and name it failFetchPostResponse
.
final HttpResponse<List<Post>?> failFetchPostResponse =
HttpResponse<List<Post>?>(
null,
dio.Response<dynamic>(
statusCode: HttpStatus.internalServerError,
requestOptions: dio.RequestOptions(path: ''),
),
);
With failing mock response defined, we can fill in the failing test case of fail init HomeViewModel
by teaching _MockPostApi
to return failing response upon having getPosts
being called and make only one assertions, whether or not the posts
state variable remains empty after model.initModel
statement.
test('fail init HomeViewModel', () async {
when(postApi.getPosts()).thenAnswer(
(_) async => failFetchPostResponse,
);
await model.initModel();
expect(model.posts.length, 0);
});
And the final unit test file after filling both successful and failing HomeViewModel
model initialization test cases shall look like:
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:retrofit/dio.dart';
import 'package:retrofit_dio_example/core/api/post_api.dart';
import 'package:retrofit_dio_example/core/models/post.dart';
import 'package:dio/dio.dart' as dio;
import 'package:retrofit_dio_example/core/viewmodels/home_view_model.dart';
class _MockPostApi extends Mock implements PostApi {}
void main() {
final _MockPostApi postApi = _MockPostApi();
final HttpResponse<List<Post>> successFetchPostResponse =
HttpResponse<List<Post>>(
<Post>[
Post(id: 1),
Post(id: 2),
],
dio.Response<dynamic>(
statusCode: HttpStatus.ok,
requestOptions: dio.RequestOptions(path: ''),
),
);
final HttpResponse<List<Post>?> failFetchPostResponse =
HttpResponse<List<Post>?>(
null,
dio.Response<dynamic>(
statusCode: HttpStatus.internalServerError,
requestOptions: dio.RequestOptions(path: ''),
),
);
late HomeViewModel model;
group('init model HomeViewModel test', () {
setUp(() {
model = HomeViewModel(
postApi: postApi,
);
});
test('success init HomeViewModel', () async {
when(postApi.getPosts()).thenAnswer(
(_) async => successFetchPostResponse,
);
await model.initModel();
expect(model.posts.length, 2);
expect(model.posts[0].id, 1);
expect(model.posts[1].id, 2);
});
test('fail init HomeViewModel', () async {
when(postApi.getPosts()).thenAnswer(
(_) async => failFetchPostResponse,
);
await model.initModel();
expect(model.posts.length, 0);
});
});
}
Try running the unit test and if you’ve done it correctly, all unit test cases shall pass like this example screenshot:
That’s all for this post, and I hope this post gives an introductory explanation to unit testing in Flutter app with MVVM pattern and Provider state management.
You can also check out the code repo in the GitHub repo here: https://github.com/aramadsanar/flutter_retrofit_example/tree/test/UnitTestHomeViewModel
Thanks for reading and see you later in the next post 🙌.