Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga. В предыдущих статьях мы научились писать модульные тесты для статичных функций, верхнеуровневых функций и расширений. Сегодня перевод статьи посвящен Unit-тестам для методов класса.

Больше про кроссплатформенную разработку в телеграмм-канале Flutter.Много. Мы с командой мобильных разработчиков Amiga рассказываем о личном опыте, делимся полезными плагинами\библиотеками, переводами статей и кейсами. Присоединяйтесь!

Написание Unit-тестов для методов класса
Будем использовать пример из прошлых частей, но вместо функции создадим класс LoginViewModel.
import 'package:shared_preferences/shared_preferences.dart';
class LoginViewModel {
bool login(String email, String password) {
return Validator.validateEmail(email) && Validator.validatePassword(password);
}
}Проверим всего 2 тест кейса, например:
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);
});
});В данный момент нет никаких отличий от прошлых частей. Теперь добавим объект SharedPreferences в LoginViewModel и обновим логику функции 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;
}
}Как можно заметить, вывод функции login зависит от вывода функции sharedPreferences.getString(email). Поэтому в зависимости от возвращенного результата функции sharedPreferences.getString(email), будут следующие тест кейсы:
- Функция - sharedPreferences.getString(email)возвращает- storedPassword, который отличается от- password, переданного в функцию- login.
- Функция - sharedPreferences.getString(email)возвращает- storedPassword, который совпадает с- password, переданным в функцию- login.
Для контроля результата функции sharedPreferences.getString(email) необходимо использовать Mocking и Stubbing.
Mocking и Stubbing
Mocking — создание фейкового объекта, который заменяет реальный объект. Mock-объекты часто используются для подмены зависимостей объекта, который нужно протестировать. 
Кроме того, можно контролировать результат, который возвращают методы Mock-объекта. Эта техника называется Stubbing (заглушки). Например, подменим объект ApiClient и поставим заглушку на его методы get, post, put и delete, чтобы они возвращали фейковые данные вместо выполнения реальных запросов.
В нашем примере нужно подменить объект SharedPreferences, чтобы избежать вызова функций clear или getString в реальности. И что важно — это поможет симулировать результат выполнения функции getString. Таким образом, будет несколько тестовых сценариев для функции login.
Существует 2 популярные библиотеки, которые позволяют использовать техники Mocking и Stubbing: mocktail и mockito. В этой серии статей используется mocktail.
Для начала, добавим пакет mocktail в dev_dependencies.
dev_dependencies:
mocktail: 1.0.3Далее создадим класс с названием MockSharedPreferences, который расширяет класс Mock и реализует класс SharedPreferences.
class MockSharedPreferences extends Mock implements SharedPreferences {}Теперь создадим Mock-объект внутри функции main.
final mockSharedPreferences = MockSharedPreferences();После этого имитируем mockSharedPreferences, чтобы он возвращал фейковый пароль 123456, используя технику stubbing.
// Stubbing
when(() => mockSharedPreferences.getString(email)).thenReturn('123456');Наконец, протестируем случай, когда пользователь вводит неверный пароль, при помощи имитирования функции sharedPreferences.getString(email). Она возвращает storedPassword, который отличается от password, переданного в функцию 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);
});Аналогичным образом мы можем проверить и случай, когда пользователь вводит правильный пароль.
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);
});Полный исходный код можно найти по ссылке.
Mocktail предлагает 3 способа выполнить stubbing:
- when(() => functionCall()).thenReturn(T expected)используется, когда- functionCall— это не асинхронная функция, как в примере выше.
- when(() => functionCall()).thenAnswer(Answer<T> answer)используется, когда- functionCall— это асинхронная функция. Например, для подмены функции clear, нужно сделать следующее:
when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(true));- when(() => functionCall()).thenThrow(Object throwable)используется, когда нужно, чтобы- functionCallбросило исключение. Например:
when(() => mockSharedPreferences.clear()).thenThrow(Exception('Clear failed'));Теперь используем подменные методы для проверки функции logout в 3 тестовых сценариях.
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')),
);
});
});Небольшие изменения в коде, представленном выше:
- Когда ожидаем, что функция выкинет ошибку вместо результата, то не можем вызывать метод - logoutна шаге Act. Его вызов породит некоторые ошибки, которые перенесутся в функцию тестирования, и это вызовет провал теста. Можем только создать переменную с функцией:
final Future<bool> Function() call = loginViewModel.logout;- Когда ожидаем, что функция выкинет ошибку вместо результата, можем использовать доступные для этого Matcher’ы: - throwsArgumentError,- throwsExceptionи т.д. На примере выше ожидаем, что будет выброшена ошибка- FlutterError, поэтому используем- expect(call, throwsFlutterError).

- Когда нужно подтвердить более конкретно и подробно. Например, ожидания появления ошибки должно быть - FlutterErrorи его- messageдолжен быть “Logout failed”. Тогда нужно использовать 2 Matcher’а:- throwsAи- isA.
expect(
call,
throwsA(isA<FlutterError>().having((e) => e.message, 'error message', 'Logout failed')),
);- Matcher - throwsA<T>()позволяет проверить выбрасывается ли какая-либо ошибка, включая кастомные классы исключений. На самом деле,- throwsFlutterError— это эквивалент- throwsA(isA FlutterError()).
- Matcher - isA<T>()позволяет проверить тип результата без привязки к определенному значению. Например, когда хотим, чтобы тест вернул либо- true, либо- false, так как это тип bool, можно использовать- expect(result, isA<bool>()). Он часто используется с методом having для проведения более детальных проверок за пределами простого типа данных. Например,- isA<FlutterError>().having((e) => e.message, 'description: error message', 'Logout failed')— тоже самое, что требовать объект быть типа FlutterError и его свойства message равняться 'Logout failed'.
Заключение
В данной статье мы изучили техники Mocking и Stubbing вместе с несколькими часто встречающимися функциями: throwsA, isA и having. В следующей части мы еще больше усложним класс LoginViewModel при помощи создания переменной _cache для кеширования результата, полученного от SharedPreferences. При вызове функции login, мы ставим высший приоритет получению данных из кеша.
Пишите в комментариях, интересна ли вам данная тема?
Подписывайтесь на телеграмм-канале Flutter. Много, чтобы не пропустить следующую статью!
 
          