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. Много. Там вы найдете еще больше интересного и полезного о кроссплатформенной разработке. Присоединяйтесь!
ссылка на оригинал статьи https://habr.com/ru/articles/832918/
Добавить комментарий