A Complete Guide to Testing in Flutter. Part 3: Mocking and Stubbing.

NALSengineering
6 min readJun 4, 2024

--

Credit: Nguyễn Thành Minh (Mobile Developer)

Introduction

In the previous section, we were able to write unit tests for static functions, top-level functions, and extension methods. In today’s lesson, we will write unit tests for class methods.

Writing Unit Tests for Class Method

I will use the example from the previous parts, but I will create the LoginViewModel class instead.

import 'package:shared_preferences/shared_preferences.dart';

class LoginViewModel {
bool login(String email, String password) {
return Validator.validateEmail(email) && Validator.validatePassword(password);
}
}

We only need 2 test cases for example:

group('login', () {
test('login should return false when the email and password are invalid', () {
final loginViewModel = LoginViewModel();
final result = loginViewModel.login('', '');
expect(result, false);
});

test('login should return true when the email and password are valid', () {
final loginViewModel = LoginViewModel();
final result = loginViewModel.login('ntminh@gmail.com', 'password123');
expect(result, true);
});
});

At this point, there is still no difference from the previous parts. Now, I will add a SharedPreferences object to LoginViewModel and update the logic of the login function.

import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';

class LoginViewModel {
final SharedPreferences sharedPreferences;

LoginViewModel({
required this.sharedPreferences,
});

bool login(String email, String password) {
final storedPassword = sharedPreferences.getString(email);

return password == storedPassword;
}

Future<bool> logout() async {
bool success = false;
try {
success = await sharedPreferences.clear();
} catch (e) {
success = false;
}

if (!success) {
throw FlutterError('Logout failed');
}

return success;
}
}

We can see that the output of the login function depends on the output of the sharedPreferences.getString(email) function. Therefore, depending on the return results of the sharedPreferences.getString(email) function, we will have the following test cases:

  1. The function sharedPreferences.getString(email) return storedPassword is different from the password passed to the login function.
  2. The function sharedPreferences.getString(email) return storedPassword is equal to the password passed to the login function.

To control the results returned from the sharedPreferences.getString(email) function, we need to use Mocking and Stubbing techniques.

Mocking and Stubbing

Mocking is creating fake objects that stand in for real objects. We often use mocking to mock the dependencies of the object that needs to be tested. Besides, we can control the return results of mock objects’ methods. This technique is called Stubbing. For example, we can mock the ApiClient object and stub its get, post, put, delete methods to return dummy data instead of making actual calls.

In our example, we need to mock the SharedPreferences object to avoid calling the clear or getString functions in reality, and more importantly, it helps us simulate the return result of the getString function. From there, we can create multiple test scenarios for the login function.

There are two popular libraries that allow us to use Mocking and Stubbing techniques: mocktail and mockito. In this series, I’ll use the mocktail library.

First, we need to add the package mocktail to dev_dependencies

dev_dependencies:
mocktail: 1.0.3

Next, we need to create a class named MockSharedPreferences that extends the Mock class and implements the SharedPreferences class.

class MockSharedPreferences extends Mock implements SharedPreferences {}

Then, we will create a mock object inside the main function.

final mockSharedPreferences = MockSharedPreferences();

After that, we can simulate mockSharedPreferences to return a dummy password of 123456 using the stubbing technique.

// Stubbing
when(() => mockSharedPreferences.getString(email)).thenReturn('123456');

Finally, we can test the case when the user enters the wrong password by simulating the function sharedPreferences.getString(email) returning storedPassword that is different from the password passed to the login function.

test('login should return false when the password are incorrect', () {
// Arrange
final mockSharedPreferences = MockSharedPreferences(); // create mock object
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
String email = 'ntminh@gmail.com';
String password = 'abc'; // incorrect password

// Stubbing
when(() => mockSharedPreferences.getString(email)).thenReturn('123456');

// Act
final result = loginViewModel.login(email, password);

// Assert
expect(result, false);
});

Similarly, we can also test the case where the user enters the correct password.

test('login should return false when the password are correct', () {
// Arrange
final mockSharedPreferences = MockSharedPreferences(); // create mock object
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
String email = 'ntminh@gmail.com';
String password = '123456'; // correct password

// Stubbing
when(() => mockSharedPreferences.getString(email)).thenReturn('123456');

// Act
final result = loginViewModel.login(email, password);

// Assert
expect(result, true);
});

Full source code

Mocktail provides us with 3 ways of stubbing:

  • when(() => functionCall()).thenReturn(T expected) is used when functionCall is not an asynchronous function like the example above.
  • when(() => functionCall()).thenAnswer(Answer<T> answer) is used when functionCall is an asynchronous function. For example, to stubbing the clear function, we do the following:
when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(true));
  • when(() => functionCall()).thenThrow(Object throwable) is used when we want the functionCall throw an exception. For example:
when(() => mockSharedPreferences.clear()).thenThrow(Exception('Clear failed'));

Now, we will use stubbing methods to test the logout function in 3 test scenarios.

group('logout', () {
test('logout should return true when the clear method returns true', () async {
// Arrange
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);

// Stubbing
when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(true));

// Act
final result = await loginViewModel.logout();

// Assert
expect(result, true);
});

test('logout should throw an exception when the clear method returns false', () async {
// Arrange
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);

// Stubbing
when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(false));

// Act
final call = loginViewModel.logout;

// Assert
expect(call, throwsFlutterError);
});

test('logout should throw an exception when the clear method throws an exception', () async {
// Arrange
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);

// Stubbing
when(() => mockSharedPreferences.clear()).thenThrow(Exception('Logout failed'));

// Act
final Future<bool> Function() call = loginViewModel.logout;

// Assert
expect(
call,
throwsA(isA<FlutterError>().having((e) => e.message, 'error message', 'Logout failed')),
);
});
});

There are a few new things in the above code:

  • When we expect to throw an error instead of a result, in the Act step, we are not allowed to invoke the logout method because invoking the function would cause any errors thrown within the logout method to be thrown to the test function, resulting in failing the test. We can only create a variable with a function type like this:
final Future<bool> Function() call = loginViewModel.logout;
  • When we expect to throw an error instead of a result, we can use available Matchers like throwsArgumentError, throwsException, etc. In the example above, I expect it will throw a FlutterError error so I will use expect(call, throwsFlutterError).
  • When we want to assert in a more specific and detailed way. For example, expect the error thrown must be FlutterError and its message must be “Logout failed”. Then, we need to use two matchers: throwsA and isA.
expect(
call,
throwsA(isA<FlutterError>().having((e) => e.message, 'error message', 'Logout failed')),
);
  • Matcher throwsA<T>() allows us to test to throw any error including custom exception classes that we create. In fact, throwsFlutterError is equivalent to throwsA(isA FlutterError()).
  • The isA<T>() matcher stands for a result of type T without caring about its specific value. For instance, when we expect the test to return either true or false, as long as it's a boolean type, we can use: expect(result, isA<bool>()). It's often paired with its having method to conduct more detailed checks beyond just the data type. For instance: isA<FlutterError>().having((e) => e.message, 'description: error message', 'Logout failed') is the same as requiring an object to be of type FlutterError and its message property to be 'Logout failed'.

Conclusion

In this article, we’ve learned the Mocking and Stubbing techniques along with some common functions like throwsA, isA, having. I believe that we could write unit tests from simple to complex by using these techniques.

--

--

NALSengineering

Knowledge sharing empowers digital transformation and self-development in the daily practices of software development at NAL Solutions.