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. Много, чтобы не пропустить следующую статью!