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