Flutter MVVM Unit Testing

Madacode
7 min readJan 21, 2023

--

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:

  1. Successful posts list load from PostApi;
  2. 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 🙌.

--

--

Madacode

Senior Software Engineer @ Pinhome with 5+ years experience in various technologies including Go and Flutter. All posts are of my own opinion.