Making your Code Testable and Writing Effective Unit Tests in Flutter: Generating Mocks and Dependency Injection

A common question that beginners often ask when starting with Open Source is: How do I understand a very large code base? A simple answer is: Understand the existing tests and write new ones. Many organizations label writing unit tests as a "good first issue." Although this blog focuses on writing unit tests in Dart, if you grasp the basics of unit testing, you can apply the same principles in any other framework. Personally, I began with Typescript and ended up writing tests in Dart for an organization called Palisadoes Foundation.

In this blog, I will select a function from their repository and write unit tests for that function. After reading this blog, you will gain an understanding of unit testing through an example. This blog might also assist you in contributing to open-source organizations.

The Given Function

/// HashMap of Firebase options for android.
late Map<String, dynamic> androidFirebaseOptions;

/// HashMap of Firebase options for android.
late Map<String, dynamic> iosFirebaseOptions;
Future<void> setUpFirebaseKeys() async {
  final androidFirebaseOptionsBox =
      await Hive.openBox('androidFirebaseOptions');
  final androidFirebaseOptionsMap = androidFirebaseOptionsBox
      .get('androidFirebaseOptions') as Map<dynamic, dynamic>?;

  final iosFirebaseOptionsBox = await Hive.openBox('iosFirebaseOptions');
  final iosFirebaseOptionsMap =
      iosFirebaseOptionsBox.get('iosFirebaseOptions') as Map<dynamic, dynamic>?;
  if (androidFirebaseOptionsMap != null) {
    androidFirebaseOptions = androidFirebaseOptionsMap.map((key, value) {
      return MapEntry(key.toString(), value);
    });
  }
  if (iosFirebaseOptionsMap != null) {
    iosFirebaseOptions = iosFirebaseOptionsMap.map((key, value) {
      return MapEntry(key.toString(), value);
    });
  }
}

This is one of the function in the Flutter Repository of Palisadoes, and this is that function for which we will write tests today.

Understanding the Function

I have provided you with this function without any context, and that's the beauty of Unit Tests; you don't need to understand other functions to write tests for this one. Remember the fundamental principle of Unit Tests:

"Unit Tests for any function should be isolated from any other function and environment."

So let's understand this function for writing tests. Non-Dart programmers, don't worry! I will define each and every Dart-specific property so that you can also understand Unit Testing well. Let's start!

Two late variables are declared; "late" means those variables will be initialized later on. Then comes our function, which is asynchronous in nature, returns a Future<void>, and doesn't take any parameters.

In Dart, Hive is a local database that operates on Boxes. Its openBox method opens a box in which you put your data by assigning it a key using the .put method. Later on, whenever you need it, you can use the .get method to retrieve that saved data from the database by passing the key. This blog is not about Hive, so I'll stop myself here. If you want, you can learn about Hive from the Hive Documentation.

Now let's move to the flow of code.

/// HashMap of Firebase options for android.
late Map<String, dynamic> androidFirebaseOptions;

/// HashMap of Firebase options for android.
late Map<String, dynamic> iosFirebaseOptions;
Future<void> setUpFirebaseKeys() async {
// Opening the androidFirebaseOptionsBox by using the Hive.openBox('boxName')
// This method returns a future of Hive Box.
  final androidFirebaseOptionsBox =
      await Hive.openBox('androidFirebaseOptions');
// Using the get method of the Opened Hive Box to get the required Map.
// Another function would be there somewhere in the codebase which have 
// saved this Map in Hive, using the 'androidFirebaseOptions'
// Now using the same key to get that map and use it further.
// This method can also return null, if nothing is saved corresponding
// to this key
  final androidFirebaseOptionsMap = androidFirebaseOptionsBox
      .get('androidFirebaseOptions') as Map<dynamic, dynamic>?;
// Same is done with iosFirebaseOptions
  final iosFirebaseOptionsBox = await Hive.openBox('iosFirebaseOptions');
  final iosFirebaseOptionsMap =
      iosFirebaseOptionsBox.get('iosFirebaseOptions') as Map<dynamic, dynamic>?;
  if (androidFirebaseOptionsMap != null) {
// As the global variables are of type Map<String,dynamic> whereas
// that returned from Hive is of type Map<dynamic,dynamic>, so to 
// convert it to required form, .map mehod is used where all keys are
// converted to String from dynamic
    androidFirebaseOptions = androidFirebaseOptionsMap.map((key, value) {
      return MapEntry(key.toString(), value);
    });
  }
  if (iosFirebaseOptionsMap != null) {
// Same methods are used here!
    iosFirebaseOptions = iosFirebaseOptionsMap.map((key, value) {
      return MapEntry(key.toString(), value);
    });
  }
}

Necessary Checks for Test

There should be some necessary checks which our test should perform so as to ensure this code is not broken by some other developer. These are those checks:

  • Whether all boxes are opened with appropriate name.

  • Keys inserted are appropriate.

  • The returned Map from Hive is assigned properly to global variables.

Now that we know what the function is and what we have to test, let's start writing the test!

Basic Components of Test

Create a file in test folder named as fileName_test.dart, this _test is important as it tells flutter that it is a test file and when flutter test is ran, this file should be there to test. Something similar is in Typescript, fileName.test.ts

// The statement to import the Flutter Test Package
import 'package:flutter_test/flutter_test.dart';
// The main function of the test which will run when test command is applied 
void main(){
// The group function which groups all the similar tests
// It requires Description and a Parameter Less Function
    group('main.dart file functions test', () {
// This is run before each and every test
        setUp(() {});
// This is run before all the Tests are run
        setUpAll((){});
// This is run after each and every test 
        tearDown((){});
// This is run after all the tests are run
        tearDownAll((){});
// This is the test in which we will writing the logic of test
    test('Description', (){
// This is the function which will be used to verify whether the 
// function is generating expcted output or not
            expect(2,2);
        });
    });
}

Now when function is running we have to test various parts of a function, take an example of this line of this Function:

final androidFirebaseOptionsBox =
      await Hive.openBox('androidFirebaseOptions');

Here, we need to test whether the openBox method is used and if the box name is correct. For this, we should have full control over the behavior of Hive. As I mentioned earlier, these tests should be isolated from any external library and function. Hence, it should also be isolated from Hive. The reason is straightforward: "Here we are testing our function, not Hive." We should ensure that this test doesn't affect the real code. If we use real Hive, the test might pass, but it could then impact our main code. Therefore, we have to mock the real Hive and use it in our code. Let me introduce you to the mocking library of Flutter: Mockito.

Mockito and it's Limitations

This is the library of Flutter which is used for Mocking the real functions to change their implementation, return type and order, so that we can control the intermediate functions and our tests of that function are isolated from other functions. Before understanding the components of Mockito, let's understand how Mocking by Mockito take place along with it's limitations:

  1. Mockito can't mock functions: It can only mock Classes and that's why most of the production code of Flutter is wrapped inside Classes. See this code snippet
import 'dart:math';
Random random = Random();
int function1(int a){
// Returns a random number between 0 and a
        return random.nextInt(a);
}
int function2(int c, int d){
int e=function1(c);
return e+d;
}

Let's say we want to test the function2, now we don't know what will be the output of function1, so how will we use expect() method. Here comes the Mocking, so for that we have to convert this code to Testable Code that is wrap in Class due to limitation of Mockito.

import 'dart:math';
Random random = Random();
class First{
int function1(int a){
// Returns a random number between 0 and a
        return random.nextInt(a);
    }
}

class Second{
First object1=First();
int function2(int c, int d){
    int e=object1.function1(c);
    return e+d;
   }
}

Now let's Mock the first class using Mockito

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MockFirst extends Mock implements First{
}

Now you would be thinking: Why we are extending Mock along the implementing First? Answers are simple

  1. The example is very small function, when Classes will become large, overriding every function of that large class will be very time taking and tough for you and extending Mock will solve this problem for you.

  2. Every property of Mockito is only applied on those classes which are extending Mock Classes in dart.

Mocking the Function and Writing Test

We will try to write the test of function2, assuming that test for function1 is already written.

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MockFirst extends Mock implements First{
}
void main(){
group('Example tests', (){
// This variable is declared here so that it can be used everywhere in test
    late Second testingObject;
    setup((){
// This is important, that a fresh object is provided to every test
// So that your every test is isolated from other tests of same group
// also
        testingObject=Second();
    });
    test('Test for function2', (){
     int parameterToBePassed=5;
// This method changes the return type of function according to our
// requirement, 'when' method takes the function, of which return 
// type has to change and 'thenReturn' changes the returning element
        when(MockFirst.function1(parameterToBePassed)).thenReturn(8);
        expect(testingObject.function2(parameterToBePassed,6), 14);
    });
});
}

But wait, this test will fail! Reason is simple, we have not injected the Mock Object in our Real Function, so no Mocking will happen (isn't it obvious?) To fix this we have to learn this Sub-Topic:

Dependency Injection

This is a process of injecting your dependency in the constructor of your To-Be-Tested-Class so that during test, when you Mock the dependency (like http Client, Hive etc.), that can be replaced with original dependency in your class. Here that dependency is class First! Let's again modify our main code:

import 'dart:math';
Random random = Random();
class First{
int function1(int a){
// Returns a random number between 0 and a
        return random.nextInt(a);
    }
}

class Second{
// Dependency injection in the Constructor
Second({this.object1});
First object1;
int function2(int c, int d){
    int e=object1.function1(c);
    return e+d;
   }
}

Let's again rewrite our test:

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MockFirst extends Mock implements First{
}
void main(){
group('Example tests', (){
late Second testingObject;
late MockFirst mockFirst;
    setup((){
        mockFirst=MockFirst();
        testingObject=Second(mockFirst);
    });
    test('Test for function2', (){
     int parameterToBePassed=5;
        when(MockFirst.function1(parameterToBePassed)).thenReturn(8);
        int output=testingObject.function2(parameterToBePassed,6);
// This function verifies that function1 of First Class is called
// If not called then test will fail!
        verify(MockFirst.function1(parameterToBePassed));
        expect(output, 14);
    });
});
}

This test shall pass!

Congratulations, now you know about Generating Mocks and Dependency Injection! Let's move back to our Primary Function but before that We have to understand Mock Generator and need of it!

Mockito's Mock Generator

Imagine a situation where there is a dependency with five functions: fun1, fun2, fun3, fun4, and fun5. Let's say that fun1 relies on the output from fun2, fun2 from fun3, and so on until fun5. When writing tests for it, you will mock that dependency using the above method. Therefore, you need to write four when functions with appropriate return statements. The situation becomes more complex when dependencies are very large. You have to navigate through various functions of that package and then put the appropriate return statement inside when. Missing any of the return statements will fail the test with a "No Stub Error." To solve this problem, Mockito provides a Mock Generator. Let's see how it works!

Let's say we want to generate mocks of Hive. After navigating through Hive packages, you'll discover that there are two classes used in Hive: HiveInterface and Box. The Hive used everywhere in our given function is an instance of HiveInterface, and openBox returns an instance of Box. So, we need to generate mocks of HiveInterface and Box. See the code below to understand how to achieve this:

// A file is created named setupFirebaseKeys_test.dart
import 'package:flutter_test/flutter_test.dart';
// This annotations package will be used for generating Mocks
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

// This annotation is used to to generate Mocks, the first parameter 
// required is an array of all those classes, of which mocks have 
// to be generated. But these Mocks will have default properties
// And we want to customize our Mocks, that's why we have kept this 
// array as empty, the second parameter is cutomMocks, this is the
// parameter which requires an array of those classes along with 
// the Customizations, the array is of type MockSpec<T>()


@GenerateMocks([], customMocks:
[MockSpec<HiveInterface>(onMissingStub: OnMissingStub.returnDefault),
 MockSpec<Box>(onMissingStub: OnMissingStub.returnDefault)]);
// This onMissingStub parameter will ensure that wherever custom 
// stub is not given, Mock have to take the default one!

Now run this command: dart run build_runner build

You will see a file named setuoFirebaseKeys.mock.dart is created! These are the Mocks which we will be using in our test.

Writing Test For the Given Function

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
// Import the function and generated mocks file here!
@GenerateMocks([], customMocks:
[MockSpec<HiveInterface>(onMissingStub: OnMissingStub.returnDefault),
 MockSpec<Box>(onMissingStub: OnMissingStub.returnDefault)]);

void main(){
group('Function tests',(){
     late MockHiveBox mockAndroidFirebaseOptionsBox;
      late MockHiveBox mockIosFirebaseOptionsBox;
      setUp(() {
        mockAndroidFirebaseOptionsBox = MockHiveBox();
        mockIosFirebaseOptionsBox = MockHiveBox();
// These when methods ensures that on using MockHiveInterface, our
// created MockBoxes are returned 
        when(mockHiveInterface.openBox('androidFirebaseOptions'))
            .thenAnswer((realInvocation) async {
          return mockAndroidFirebaseOptionsBox;
        });
        when(mockHiveInterface.openBox('iosFirebaseOptions'))
            .thenAnswer((realInvocation) async {
          return mockIosFirebaseOptionsBox;
        });
      });
}

Dependency Injection in Given Function

import 'package:hive/hive.dart'
/// HashMap of Firebase options for android.
late Map<String, dynamic> androidFirebaseOptions;

/// HashMap of Firebase options for android.
late Map<String, dynamic> iosFirebaseOptions;
// An optional parameter is used in the function
Future<void> setUpFirebaseKeys([HiveInterface? hiveInterface]) async {
final HiveInterface hive=hiveInterface==null?Hive:hiveInterface;
// This 'Hive' is used everywhere in Application and is
// imported from hive package!
// This if-else condition will not be covered in this test, but in some
// other test in which we will ensure that everywhere real Hive is used
// not any other instance of Hive.

// Now refactoring the Hive to hive!
  final androidFirebaseOptionsBox =
      await hive.openBox('androidFirebaseOptions');
  final androidFirebaseOptionsMap = androidFirebaseOptionsBox
      .get('androidFirebaseOptions') as Map<dynamic, dynamic>?;

  final iosFirebaseOptionsBox = await hive.openBox('iosFirebaseOptions');
  final iosFirebaseOptionsMap =
      iosFirebaseOptionsBox.get('iosFirebaseOptions') as Map<dynamic, dynamic>?;
  if (androidFirebaseOptionsMap != null) {
    androidFirebaseOptions = androidFirebaseOptionsMap.map((key, value) {
      return MapEntry(key.toString(), value);
    });
  }
  if (iosFirebaseOptionsMap != null) {
    iosFirebaseOptions = iosFirebaseOptionsMap.map((key, value) {
      return MapEntry(key.toString(), value);
    });
  }
}

Now let's continue with our test!

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
// Import the function and generated mocks file here!
@GenerateMocks([], customMocks:
[MockSpec<HiveInterface>(onMissingStub: OnMissingStub.returnDefault),
 MockSpec<Box>(onMissingStub: OnMissingStub.returnDefault)]);

void main(){
group('Function tests',(){
     late MockHiveBox mockAndroidFirebaseOptionsBox;
      late MockHiveBox mockIosFirebaseOptionsBox;
      setUp(() {
        mockAndroidFirebaseOptionsBox = MockHiveBox();
        mockIosFirebaseOptionsBox = MockHiveBox();
// These when methods ensures that on using MockHiveInterface, our
// created MockBoxes are returned 
        when(mockHiveInterface.openBox('androidFirebaseOptions'))
            .thenAnswer((realInvocation) async {
          return mockAndroidFirebaseOptionsBox;
        });
        when(mockHiveInterface.openBox('iosFirebaseOptions'))
            .thenAnswer((realInvocation) async {
          return mockIosFirebaseOptionsBox;
        });
      });
 test('Whether all the boxes are opened or not with appropriate name',
          () async {
        await setUpFirebaseKeys(mockHiveInterface);
        verify(mockHiveInterface.openBox('androidFirebaseOptions'));
        verify(mockHiveInterface.openBox('iosFirebaseOptions'));
      });
 test('When platform is android', () async {
        const Map<dynamic, dynamic> intermediateOutput = {
          'keyOfAndroidFirebase': 'valueOfAndroidFirebase'
        };
        const Map<String,dynamic> expectedOutput= {
          'keyOfAndroidFirebase': 'valueOfAndroidFirebase'
        };
        when(mockAndroidFirebaseOptionsBox.get('androidFirebaseOptions'))
            .thenReturn(intermediateOutput);
        when(mockIosFirebaseOptionsBox.get('iosFirebaseOptions'))
            .thenReturn(null);
        await setUpFirebaseKeys(mockHiveInterface);
        expect(androidFirebaseOptions,expectedOutput);
      });
  test('When platform is ios', () async {
        const Map<dynamic, dynamic> intermediateOutput = {
          'keyOfIosFirebase': 'valueOfIosFirebase'
        };
        const Map<String,dynamic> expectedOutput= {
           'keyOfIosFirebase': 'valueOfIosFirebase'
        };
        when(mockIosFirebaseOptionsBox.get('androidFirebaseOptions'))
            .thenReturn(intermediateOutput);
        when(mockAndroidFirebaseOptionsBox.get('iosFirebaseOptions'))
            .thenReturn(null);
        await setUpFirebaseKeys(mockHiveInterface, mockMapOptions);
        expect(iosFirebaseOptions,expectedOutput);
      });
}

These are the test cases, which I thought of! You can think more test cases and increase the effectiveness of this test.

Conclusion

In this blog I tried my best to make you understand the following topics:

  • Making your Code Testable

  • Dependency Injection

  • Mockito and it's Limitations

  • Using Mock Generator given by Mockito

  • Finally writing a Unit Test

I hope this blog was enjoyable and will be proven useful to you for contributing to Open-Sourced Organizations!