Advanced Guide to Unit Testing in Flutter

Rawaha Muhammad
7 min readDec 1, 2023

In the previous guide, we read what is a unit test, how to write a simple unit test, and its endless benefits for software development.

This is a bit of an advanced guide. So take off your beginner hat and put on your hacker man hat, because things are about to get serious. (It is going to be as easy as a breeze).

hacker man

In this guide, I will try to cover different scenarios. It is possible that a method is written in a way that it is hard to write tests for, or the method has an external dependency (like talking to a database, network, or file system).

So without further ado, let’s begin!

  • Writing tests for void functions

void functions are pretty hard to test since they usually rely on variables global to the class or the system they are in. So how do we test them? It’s pretty easy! Let’s say we had a DivisionService class and inside that, a divide function that looked like this

class DivisionService {
double quotient = 0;

void divide(int dividend, int divisor) {
if (divisor == 0) {
quotient = 0;
}
quotient = dividend / divisor;
}
}

The quotient variable is public and can be used outside of the class.
For this type of method, we will simply check for the global variable DivisionService.quotient, after calling the divide method.

import 'package:flutter_test/flutter_test.dart';
import 'package:unit_testing/division_service.dart';

void main() {
test('Divide with valid dividend and valid divisor', () {
//ARRANGE
DivisionService divisionService = DivisionService();
int dividend = 10;
int divisor = 5;
//ACT
divisionService.divide(dividend, divisor);
//ASSERT
expect(divisionService.quotient, 2);
});
}

If the method does not impact any global variables, then there is no way to test that function
That is why, better code is code that is testable! So we will have to refactor the code to make it testable.

  • Writing tests for functions containing network calls

Suppose the class you are writing tests for, is a UserService class and contains the method getUser which calls an API.

import 'package:dio/dio.dart';

class UserService {
Future<Response> getUser() async {
final Response response = await Dio().get('https://randomuser.me/api');
return response;
}
}

Remember the rule, that unit tests should be logically isolated, and fast! The network call goes against both of these rules, so we need to do something about it.

Whenever you are about to write a method, ask yourself, is this method logically isolated? What if Dio is only supported in Flutter apps, and not in command-line apps, we need to swap it out with another class then. But how do we take Dio out of this class, while keeping the existing functionality?
The answer is simple, decoupling.

We will decouple the Dio class from the UserService and pass it as a parameter.

import 'package:dio/dio.dart';

class UserService {
final Dio dio;
UserService({required this.dio});
Future<Response> getUser() async {
final Response response = await dio.get('https://randomuser.me/api');
return response;
}
}

But what is the advantage of doing that? Well, first, you just made your class scalable, you can swap Dio out for any other HTTP package. You made UserService independent and you also made the class testable!

Now we are ready to write the tests for the getUser method

Create a file in the test folder called user_service_test.dart and in the main method, call the test method.

import 'package:flutter_test/flutter_test.dart';

void main() {
test('UserService should getUser with correct Response', () {
//ARRANGE
//ACT
//ASSERT
});
}

Now, let’s write the actual unit test.

import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:unit_testing/user_service.dart';

void main() {
test('UserService should getUser with correct Response', () async {
//ARRANGE
Dio dio = Dio();
UserService userService = UserService(dio: dio);
//ACT
final Response response = await userService.getUser();
//ASSERT
expect(response.statusCode, 200);
});
}

We prepared the required data like Dio and UserService objects, then called the userService.getUser() method. Finally, we are expecting it to return a Response, with a status code of 200.

Run the test, and it will pass, but there is an actual API call being made to a server, and we cannot do that in unit tests. Moreover, how do we test what happens when the network call fails? We don’t know how to test that, unless by turning the server off.
So how do we never make an actual network call, and still test our method?

The solution: Mocked Classes
If we think about it, to stop Dio from making API calls, we need to Mock the Dio class in such a way that its get method would now return a Response object of our liking, to verify certain cases in our unit tests.

(More about mocking and stubbing here)

Remember, we always need to mock the external dependency of the method under test, in this case, that external dependency is Dio

We can do this through the mocktail package. Using Abstraction and Inheritance, mocktail aims to mock objects and offer values for methods without actually invoking the external dependencies, which is just what we need.

Add mocktail to your dependencies in pubspec.yaml :

mocktail: ^1.0.1

Then, go back to your test file and create a Mock class for the Dio class. We are going to call it MockDio.

class MockDio extends Mock implements Dio {}

Now, we can pass this Mocked class inside our test

import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:unit_testing/user_service.dart';

class MockDio extends Mock implements Dio {}
void main() {
test('UserService should getUser with correct Response', () async {
//ARRANGE
MockDio dio = MockDio();
UserService userService = UserService(dio: dio);
//ACT
final Response response = await userService.getUser();
//ASSERT
expect(response.statusCode, 200);
});
}

We got rid of all the compile time errors. Now let’s run the test again, but this time the test failed!

Error after implementing mocks in the test file

Why did that happen? That is because our MockDio class doesn’t have a method implemented for the get method, which is being called inside the UserService.getUser method, and it returns null, hence the error.

We need to stub the response from the MockDio.get() method so that it doesn’t return null and our unit test can pass.

To cover all the edge cases for the getUser method, we need to stub the Dio.get() method for each test case. That is because stubbing enables us to return customized values for methods of the mocked class, which will allow us to return case-specific values for each unit test. For example, we might want to return a Response object with a 200 status code for a successful unit test case, but a Response object with a 401 status code for an unsuccessful one.

How to stub
Given our UserService.getUser method. We can write a stub for the Dio.get() method like this:

when(() => dio.get('https://randomuser.me/api')).thenAnswer(
(invocation) async => Response(
statusCode: 200,
requestOptions: RequestOptions(
path: 'https://randomuser.me/api',
),
),
);

We used the when method from the mocktail package along with the thenAnswer method to return a canned Response object with the same path and status code of 200. This essentially says, “When dio.get is called with the path https://randomuser.me/api, then return this Response object”.

After adding this to our unit test, we can then run the test, and now it will pass.

Unit test with stubbed api call

Similarly, we can add a case where the response returned was not 200, but something else.

test('UserService should getUser with correct Response 2', () async {
//ARRANGE
MockDio dio = MockDio();
UserService userService = UserService(dio: dio);
when(() => dio.get('https://randomuser.me/api')).thenAnswer(
(invocation) async => Response(
statusCode: 401,
requestOptions: RequestOptions(
path: 'https://randomuser.me/api',
),
),
);

//ACT
final Response response = await userService.getUser();

//ASSERT
expect(response.statusCode, 401);
});

Now let’s try a test case where the Dio.get() call might throw an unexpected error.

test('UserService should getUser with correct Response 3', () async {
//ARRANGE
MockDio dio = MockDio();
UserService userService = UserService(dio: dio);
when(() => dio.get('https://randomuser.me/api')).thenThrow(Error());

//ACT
final Response response = await userService.getUser();

//ASSERT
expect(response.statusCode, 400);
});

We used the thenThrow instead of thenAnswer and passed it an Error object.

This will break our method! Notice how a valid unit test case has broken our method. This is a good thing, as we need our code base to be robust, and cover as many cases as possible.

It’s better to break a feature while unit testing than to break a feature in production

The modified method after handling the last unit test case scenario would look like this:

import 'package:dio/dio.dart';

class UserService {
final Dio dio;

UserService({required this.dio});

Future<Response> getUser() async {
try {
final Response response = await dio.get('https://randomuser.me/api');
return response;
} catch (ex) {
return Response(requestOptions: RequestOptions(), statusCode: 400);
}
}
}

We added a try catch block and made sure it returned a Response object with a status code of 400, as we expected in our unit test case.

Run the test now, and it will pass!

Conclusion:
That’s it! We have learned how to write unit tests for complex methods. You can use this methodology to unit-test your code base and make it robust, scalable, and clean!

Don’t forget to put on your sunglasses, because you can write unit tests for a complex code base.

If you liked the tutorial…

Please follow me here and on my socials!

Follow me:

Youtube: https://www.youtube.com/channel/UCD2BEqL0wC7leFKm4i9_aRg
LinkedIn: https://www.linkedin.com/in/rawahamuhammad/
Github: https://github.com/coffiie
Medium:

Rawaha Muhammad

Follow Runtime Snippets (bite-sized Flutter/Dart tutorials)

Youtube: https://www.youtube.com/channel/UCD2BEqL0wC7leFKm4i9_aRg
LinkedIn: https://www.linkedin.com/company/100042850
Twitter: https://twitter.com/runtimesnippets

--

--