Mocking and Stubbing

Hello, Amigos! This is Pavel Gershevich, Mobile Team Lead of the Amiga product development agency. In previous articles, we learned how to write unit tests for static functions, top-level functions, and extensions. Today Translation of the article dedicated to Unit tests for class methods.

More about cross-platform development in telegram channel Flutter.Mnogo. We, together with the Amiga mobile developers team, talk about personal experience, share useful plugins/libraries, article translations and cases. Join us!

Writing Unit Tests for Class Methods

We will use an example from previous partsbut instead of a function we will create a class LoginViewModel.

import 'package:shared_preferences/shared_preferences.dart';

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

Let's check just 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 the moment there are no differences from the previous parts. Now let's add an object SharedPreferences V LoginViewModel and update the function logic login.

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;
}
}

As you can see, the output of the login function depends on the output of the function sharedPreferences.getString(email). Therefore, depending on the returned result of the function sharedPreferences.getString(email)there will be the following test cases:

  1. Function sharedPreferences.getString(email) returns storedPasswordwhich is different from passwordpassed to the function login.

  2. Function sharedPreferences.getString(email) returns storedPasswordwhich coincides with passwordpassed to the function login.

To control the result of the function sharedPreferences.getString(email) Mocking and Stubbing must be used.

Mocking and Stubbing

Mocking is the creation of a fake object that replaces a real object. Mock objects are often used to replace the dependencies of an object that needs to be tested.

In addition, you can control the result returned by the methods of a Mock object. This technique is called Stubbing. For example, replace the object ApiClient and put a stub on his methods get, post, put And deleteso that they return fake data instead of performing real requests.

In our example, we need to replace the object SharedPreferencesto avoid calling functions clear or getString in reality. And what is important is that it will help simulate the result of the function execution getString. So there will be several test scenarios for the function. login.

There are 2 popular libraries that allow you to use Mocking and Stubbing techniques: mocktail And mockitoThis series of articles uses mocktail.

First, let's add a package mocktail V dev_dependencies.

dev_dependencies:
mocktail: 1.0.3

Next, we will create a class with the name MockSharedPreferenceswhich extends the class Mock and implements the class SharedPreferences.

class MockSharedPreferences extends Mock implements SharedPreferences {}

Now let's create a Mock object inside the function main.

final mockSharedPreferences = MockSharedPreferences();

After that we simulate mockSharedPreferencesso that it returns a fake password 123456using the stubbing technique.

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

Finally, we will test the case where the user enters an incorrect password by simulating the function sharedPreferences.getString(email). She returns storedPasswordwhich is different from passwordpassed to the function login.

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);
});

In a similar way, we can check 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);
});

The full source code can be found at link.

Mocktail offers 3 ways to perform stubbing:

  • when(() => functionCall()).thenReturn(T expected) used when functionCall — this is not an asynchronous function, as in the example above.

  • when(() => functionCall()).thenAnswer(Answer<T> answer) used when functionCall — is an asynchronous function. For example, to replace the clear function, you need to do the following:

when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(true));
  • when(() => functionCall()).thenThrow(Object throwable) used when it is necessary to functionCall threw an exception. For example:

when(() => mockSharedPreferences.clear()).thenThrow(Exception('Clear failed'));

Now we use replacement methods to test the function logout 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')),
);
});
});

Minor changes to the code above:

  • When we expect a function to throw an error instead of a result, we cannot call the method logout in the Act step. Calling it will generate some errors that will be carried over to the test function, and this will cause the test to fail. We can only create a variable with the function:

final Future<bool> Function() call = loginViewModel.logout;
  • When we expect a function to throw an error instead of a result, we can use the Matchers available for this: throwsArgumentError, throwsException etc. In the example above, we expect an error to be thrown FlutterErrorso we use expect(call, throwsFlutterError).

  • When you need to confirm more specifically and in detail. For example, the expectation of an error should be FlutterError and his message should be “Logout failed”. Then you need to use 2 Matchers: throwsA And isA.

expect(
call,
throwsA(isA<FlutterError>().having((e) => e.message, 'error message', 'Logout failed')),
);
  • Matcher throwsA<T>() allows you to check if any error is thrown, including custom exception classes. In fact, throwsFlutterError – this is the equivalent throwsA(isA FlutterError()).

  • Matcher isA<T>() allows you to check the result type without binding it to a specific value. For example, when we want the test to return either trueor falsesince it is a bool type, you can use expect(result, isA<bool>()). It is often used with the having method to perform more detailed checks beyond the simple data type. For example, isA<FlutterError>().having((e) => e.message, 'description: error message', 'Logout failed') — the same as requiring the object to be of type FlutterError and its message property to be equal to 'Logout failed'.

Conclusion

In this article, we explored the Mocking and Stubbing techniques along with a few commonly used functions: throwsA, isA And having. In the next section, we will further complicate the LoginViewModel class by creating a _cache variable to cache the result obtained from SharedPreferences. When calling the login function, we give the highest priority to retrieving data from the cache.

Write in the comments if this topic is interesting to you?

Subscribe to the telegram channel Flutter. A lot.so you don't miss the next article!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *