Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga. Это серия статей переводов о тестировании в Flutter, предыдущие выпуски вы найдете на моей страничке. Сегодня перевод посвящен продвинутому модульному тестированию. Всем приятного чтения!
И перед тем, как приступить к самой статье, приглашаю вас в телеграмм-канал Flutter.Много. Мы ведем его всей командой мобильных разработчиком Amiga и рассказываем о личном опыте, делимся полезными плагинами\библиотеками, переводами статей и кейсами. В нашем сообществе уже 2632 участников, присоединяйтесь и вы!
В предыдущей статье мы рассмотрели использование техник Mocking и Stubbing для тестирования классов, которые зависят от других классов. В новом выпуске будет еще больше усложнен класс LoginViewModel при помощи создания переменной _cache для кеширования результата, полученного от SharedPreferences. При вызове функции login, ставится высший приоритет получению данных из кеша.
import 'package:shared_preferences/shared_preferences.dart';
class LoginViewModel {
final SharedPreferences sharedPreferences;
LoginViewModel({
required this.sharedPreferences,
});
final Map<String, String?> _cache = {};
bool login(String email, String password) {
if (_cache.containsKey(email)) {
return password == _cache[email];
}
final storedPassword = sharedPreferences.getString(email);
_cache[email] = storedPassword;
return password == storedPassword;
}
}
В коде выше содержится ошибка: переменная _cache является приватной, поэтому ее нельзя подменить. Из-за этого остается только один тестовый сценарий – когда _cache пуст. Как можно добавить больше значений к _cache для проверки разных сценариев, сохраняя переменную приватной? Для этого понадобится аннотация @visibleForTesting.
Аннотация @visibleForTesting
final Map<String, String?> _cache = {};
// Expose this method for testing purposes to set values in _cache
@visibleForTesting
void putToCache(String email, String? password) {
_cache[email] = password;
}
Когда функция помечена аннотацией, подразумевается, что ее следует использовать только в файлах с тестами и внутри файла, содержащего эту функцию. Именно таким образом она остается приватной.
Теперь, напишем тесты для 2 следующих тестовых сценариев:
group('login', () {
test('login should return true when the cache contains the password input even when the password is incorrect', () {
// Arrange
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(
sharedPreferences: mockSharedPreferences,
);
String email = 'ntminh@gmail.com';
String password = 'abc';
loginViewModel.putToCache(email, 'abc'); // NEW
// Stubbing
when(() => mockSharedPreferences.getString(email))
.thenReturn('123456');
// Act
final result = loginViewModel.login(email, password);
// Assert
expect(result, true);
});
test('login should return false when the cache does not contain the password input and the password is incorrect', () {
// Arrange
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(
sharedPreferences: mockSharedPreferences,
);
String email = 'ntminh@gmail.com';
String password = 'abc';
// Stubbing
when(() => mockSharedPreferences.getString(email))
.thenReturn('123456');
// Act
final result = loginViewModel.login(email, password);
// Assert
expect(result, false);
});
});
Тест кейсы, связанные с переменной _cache, проверены. Далее попробуем отрефакторить код, чтобы избежать дублирования. Для этого вынесем инициализацию mockSharedPreferences и loginViewModel за пределы тестовых функций.
Когда прогоним тест снова, второй тест упадет с ошибкой.
Почему актуальный результат получился true вместо false?
Когда шарится объект loginViewModel, также шарится и переменная _cache. В первом тест кейсе значение кладется в _cache через loginViewModel.putToCache(email, 'abc');, поэтому когда переходим ко второму тестовому кейсу, _cache уже содержит значение 'abc'. Таким образом, _cache содержит входные данные пароля и возвращает true.
Чтобы исправить баг, нужно удостовериться, что каждый раз, когда прогоняется новый тест, создается новый объект loginViewModel. Это можно сделать, используя функцию setUp.
void main() {
late MockSharedPreferences mockSharedPreferences;
late LoginViewModel loginViewModel;
setUp(() {
mockSharedPreferences = MockSharedPreferences();
loginViewModel = LoginViewModel(
sharedPreferences: mockSharedPreferences,
);
});
...
}
Прогнав тест еще раз, видно, что все тесты прошли.
Функции setUp, tearDown, setUpAll, tearDownAll
setUp
Функция setUp вызывается перед запуском каждого теста, поэтому она обычно используется для инициализации объектов для теста и конфигурации начальных значений. В примере выше порядок вызова функций выглядит так:
setUp (initialization) -> test case 1 -> setUp (re-initialization) -> test case 2
Таким образом, loginViewModel перед запуском второго тест кейса инициализируется заново, поэтому баг был пофикшен.
tearDown
Функция tearDown вызывается после завершения каждого теста. Обычно ее используют для задач по очистке, таких как освобождение памяти, закрыть какие-либо ресурсы или закрыть соединение с базой данных.
setUpAll
Функция setUpAll вызывается всего один раз перед прогоном абсолютно всех тестов. Ее часто используют для открытия соединения с базой данных и дальнейшего использования одной базы данных для всех тестов.
setUpAll(() async {
await Isar.initializeIsarCore(download: true);
isar = await Isar.open([JobSchema], directory: '');
});
tearDownAll
Функция tearDownAll тоже выполняется всего один раз после завершения всех тестов, поэтому часто используется для закрытия доступа к базе данных.
tearDownAll(() async {
await isar.close();
});
Далее пройдемся по нескольким примерам, чтобы понять, как применять эти 4 функции. Кроме этого, рассмотрим, как проверять Stream’ы.
Тестирование Stream’ов
Представим, что приложение использует базу данных Isar, и в ней есть таблица JobData.
import 'package:isar/isar.dart';
part 'job_data.g.dart';
@collection
class JobData {
Id id = Isar.autoIncrement;
late String title;
}
Также есть класс HomeBloc. В этом классе будем слушать данные, которые возвращает Isar.
class HomeBloc {
HomeBloc({required this.isar}) {
_streamSubscription = isar.jobDatas
.where()
.watch(fireImmediately: true)
.listen((event) {
_streamController.add(event);
});
}
final Isar isar;
final _streamController =
StreamController<List<JobData>>.broadcast();
StreamSubscription? _streamSubscription;
Stream<List<JobData>> get data => _streamController.stream;
void close() {
_streamSubscription?.cancel();
_streamController.close();
_streamSubscription = null;
}
}
Теперь создадим файл для теста, чтобы проверить геттер data из класса HomeBloc.
После этого инициализируем HomeBloc в функции setUp и базу данных Isar в функции setUpAll. Обычно, если инициализируем объект в функции setUp, то он будет очищен в функции tearDown. И наоборот, если мы инициализируем объект в функции setUpAll, то он очистится только в функции tearDownAll.
void main() {
late Isar isar;
late HomeBloc homeBloc;
setUp(() async {
await isar.writeTxn(() async => isar.clear());
homeBloc = HomeBloc(isar: isar);
});
tearDown(() {
homeBloc.close();
});
setUpAll(() async {
await Isar.initializeIsarCore(download: true);
isar = await Isar.open(
[JobDataSchema],
directory: '',
);
});
tearDownAll(() {
isar.close();
});
}
Наконец, напишем тесты для геттера data.
test('data should emit what Isar.watchData emits', () async {
expectLater(
homeBloc.data,
emitsInOrder([
[],
[JobData()..title = 'IT'],
[JobData()..title = 'IT', JobData()..title = 'Teacher'],
]));
// put data to Isar
await isar.writeTxn(() async {
isar.jobDatas.put(JobData()..title = 'IT');
});
await isar.writeTxn(() async {
isar.jobDatas.put(JobData()..title = 'Teacher');
});
});
Здесь появляется 2 новых вещи:
Функция expectLater: Она отличается от функции expect, которая используется для проверки синхронных значений, а expectLater — для тестирования асинхронных значений, таких как Stream.
Когда тестируем поток данных, нужно разместить выражение expectLater до того, как данные попадут в Stream. Таким образом можно следить за значениями, как только они попадают в поток. И не нужно использовать await перед функцией expectLater(), так как тест провалится.Функция emitsInOrder - Matcher, который используется для проверки, что данные в Stream попадают в верном порядке. Если нужно проверить все события, но вне зависимости от их порядка, можно использовать Matcher emitsInAnyOrder.
Функция resetMocktailState
Используется для сброса Mock-объектов. Для ошибки выше можно вызвать ее в методах setUp или tearDown для исправления бага вместо инициализации Mock-объекта заново в функции setUp.
tearDown(() {
resetMocktailState();
});
Заключение
Надеемся, что перевод этой статьи был для вас полезен и вы научились писать продвинутые тесты на различные сценарии. В следующей части продолжим изучать библиотеку mocktail для написания тестов для более комплексных кейсов.
Чтобы не пропустить новый выпуск, подписывайтесь на наш телеграмм-канал Flutter. Много. Там вы найдете еще больше интересного и полезного о кроссплатформенной разработке. Присоединяйтесь!