Вернуться к блогу
07.03.2025
Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga. Мы с командой подготовили для вас статью о тестировании во Flutter. Рассмотрим Unit-тестирование, Widget-тестирование, Golden-тесты и интеграционное тестирование. Всем приятного чтения!
В этой статье приведено много примеров и часто встречаемых ошибок, с которыми сталкиваются специалисты, когда пишут тесты. Также показано, как писать код, чтобы сделать тестирование проще, и как использовать AI-инструменты, такие как ChatGPT или GitHub Copilot для увеличения скорости написания тестов.
И перед тем, как приступить к самой статье, хочу вас познакомить с нашим телеграмм-каналом Flutter.Много. Мы ведем его всей командой мобильных разработчиком Amiga и рассказываем о личном опыте, делимся полезными плагинами\библиотеками, переводами статей и кейсами. Присоединяйтесь!
Есть 3 основных причины, почему написание тестов необходимо:
Существует 4 основных метода тестирования на Flutter: модульные или Unit-тесты, Widget-тесты, Golden тесты и интеграционные тесты. Они различаются по назначению, масштабу и времени выполнения.
Unit-тесты используются для тестирования функций и методов. Например, для статичных функций, высокоуровневых функций или методов отдельно.
Цель Unit-тестов — проверить корректность работы функции или метода с разными вводными условиями.Например, представим, что у нас есть 3 функции: saveToken, getToken, и login:
bool saveToken( String token) {
return sharedPreferences.saveToken(token); }
String get token => secureStorage.token; // как получить код
bool login(String email, String password) {
final token = apiClient.login(email, password);
return saveToken(token);
}
С модульным тестированием нужно написать тесты для каждой функции по отдельности.
Например, для функции saveToken, когда приходит token, ответ функции должен быть true или false в зависимости от тестового сценария. Когда вызываем геттер token, функция должна вернуть токен, хранящийся в SecureStorage. Для функции login, нужно вернуть true или false в зависимости от переданных на вход email и password.
Помимо тестирования ответа, зависящего от входных данных, также можно протестировать, была ли вызвана функция apiClient.login или сколько раз она была вызвана. Если ее не вызывали или делали это больше одного раза, то в коде скорее всего есть баг.
Написание Unit-тестов — лишь необходимое условие для того, чтобы удостовериться, что приложение работает корректно.
На примере выше намеренно допущена ошибка, где сохранили его в SharedPreferences и попытались достать токен из SecureStorage, из которого точно не получится достать нужный.
Почему Unit-тесты не могут поймать эту ошибку? Они сфокусированы на тестировании каждой функции по отдельности. Функция saveToken используется для сохранения токена в SharedPreferences, и она не знает, будет ли он получен именно оттуда.
Аналогично, геттер token используется только для получения токена из SecureStorage и ему все равно, где он был сохранен. Когда каждая функция выполняется корректно, проходят и Unit-тесты. Но при запуске приложения эти 2 функции будут вызывать ошибку, тогда на помощь приходят интеграционные тесты.
Интеграционные тесты используются для проверки совместной работы отдельных классов и функций или для тестирования производительности приложения, которое запущено на реальном устройстве.
Например, нам нужно протестировать функционал входа в аккаунт. Когда пользователь вводит корректные email и пароль, мы попадаем на Главный экран (Home screen).
При запуске интеграционных тестов приложение запустится на реальном устройстве или эмуляторе и будет автоматически работать так, как будто тестировщик тестирует приложение. Таким образом, приложение будет работать точнее, чем при использовании только Unit-тестов. Но есть и недостаток — время выполнения интеграционных тестов намного дольше, чем у Unit-тестов. Кроме того, когда попадается баг, бывает очень сложно отследить, в какой именно функции он находится.
Unit-тесты и интеграционные тесты в основном используются для проверки логики приложения. Если нужно протестировать пользовательский интерфейс, например, «цвет кнопки соответствует дизайн», «кнопка включена или выключена» или «кнопку видно», то необходимо применить Widget-тесты и Golden тесты.
Цель Widget-тестов — проверить соответствие пользовательского интерфейса дизайну и корректную работу взаимодействия с ним.
Также как Unit-тесты, Widget-тестам не нужно запущенное приложение на реальном устройстве или эмуляторе.
Стоить отметить, что не все виджеты у нас получится протестировать при помощи Widget-тестов.
Golden тесты — по сути те же Widget-тесты, но еще они позволяют проверить корректное расположение виджета на экране.
Например, кроме соответствия цвета кнопки дизайну, можно проверить включена, выключена или видно ли её пользователю.
Golden-тесты также проверяют расположение кнопки на экране в соответствии с дизайном. Это делается при помощи генерации изображения пользовательского интерфейса виджета, известных как Golden Images, и сравнения их с текущими. Если обе картинки совпадают, то тест пройдет. Сгенерировать golden images можно на нескольких устройствах с разным размером, таких как телефоны и планшеты.
Например, вот golden images, на которых изображен ожидаемый пользовательский интерфейс в начальном состоянии и после однократного нажатия на Floating Action Button на двух различных устройствах: смартфоне и планшете в альбомной ориентации.
Если поменять цвет Floating Action Button на красный и передвинуть вверх 2 виджета Text:
то Golden тесты найдут эти различия и уведомят об ошибках при сравнении изображений, как показано ниже:
Golden тесты экономят много времени и денег, так как проверяют правильность пользовательского интерфейса при помощи сравнения изображений, чего не могут обычные Widget-тесты.
Представим, что нам нужно протестировать функцию входа в приложение с функциями валидации email и пароля.
Используем две статичные функции:
class Validator {
static bool validateEmail(String value) {
return value.isNotEmpty;
}
static bool validatePassword(String value) {
return value.isNotEmpty;
}
}
Затем метод расширения под названием isNullOrEmpty:
extension StringExtension on String? {
bool get isNullOrEmpty => this == null || this!.isEmpty;
}
И наконец, функция входа:
import 'package:testing_examples/part2/ext/extension.dart';
import 'package:testing_examples/part2/util/utils.dart';
bool login(String? email, String? password) {
if (email.isNullOrEmpty || password.isNullOrEmpty)
return false;
}
return Validator.validateEmail(email!) && Validator.validatePassword(password!);
}
Полный исходный код можно найти по ссылке: https://github.com/ntminhdn/testing_examples/tree/main/lib/part2
Необходимо протестировать 4 функции, которые находятся в 3 разных файлах, поэтому создаем 3 файла для тестов в папке test. Чтобы различать файлы unit-тестов и файлы, относящиеся к другим методам тестирования, таким как Widget-тесты, назовем папку unit_test внутри папки test.
Naming convention для файлов с тестами гласит, что для названия файла нужно использовать код и суффикс _test.dart. И еще одно правило — структура папки test должна повторять структуру папки lib, как показано в примере:
Сначала напишем тесты для функции validateEmail. Каждый файл должен начинаться с функции main() как точки входа. Для написания Unit-тестов нужно импортировать пакет flutter_test.
import 'package:flutter_test/flutter_test.dart';
void main() {
}
Для того, чтобы создать Unit-тест, используем функцию test, передавая ей 2 параметра — description и body:
void main() {
test('validateEmail should return true when the email is not empty', () {
// body });
}
Неважно короткие или длинные наименования самих тестов. Главное, чтобы они были простыми для понимания без чтения кода.
Для тела теста обычно используется паттерн ААА: сначала, подготавливаем все необходимое (Arrange), потом выполняем нужное действие (Act) и проверяем его результат (Assert).
test('validateEmail should return true when the email is not empty', () {
// Arrange
String validEmail = 'test@example.com';
// Act
bool result = Validator.validateEmail(validEmail);
// Assert
expect(result, true);
});
Итак, один Unit-тест описан. Теперь функция validateEmail должна быть протестирована еще для одного случая: когда email пустой, она должна вернуть false.
test('validateEmail should return false when the email is empty', () {
// Arrange
String invalidEmail = '';
// Act
bool result = Validator.validateEmail(invalidEmail);
// Assert
expect(result, false);
});
Напишем Unit-тесты для функции validatePassword в похожем виде.
test('validatePassword should return true when the password is not empty', () { // Arrange String validPassword = 'password123';
// Act bool result = Validator.validatePassword(validPassword);
// Assert expect(result, true); });
test('validatePassword should return false when the password is empty', () { // Arrange String invalidPassword = '';
// Act bool result = Validator.validatePassword(invalidPassword);
// Assert expect(result, false); });
Теперь в файле utils_test.dart есть 4 тестовых кейса. Нужно сгруппировать их по самим функциям, используя group.
group('validateEmail', () { test('validateEmail should return true when the email is not empty', () { // body });
test('validateEmail should return false when the email is empty', () { // body }); });
group('validatePassword', () { test('validatePassword should return true when the password is not empty', // body });
test('validatePassword should return false when the password is empty', () { // body }); });
Для того, чтобы запустить Unit-тесты, набираем в консоли команду flutter test или нажимаем Run или Debug в IDE, как на картинке ниже. Если в консоль выводится фраза «All tests passed!», то все тесты успешно прошли. Если какой-то из них провалится, то появится лог в консоли.
group('login', () { test('login should return false when the email is empty', (){ // Arrange String? email; String password = 'password123';
// Act bool result = login(email, password);
// Assert expect(result, false); });
test('login should return false when the password is empty', () { // Arrange String email = 'ntminh@gmail.vn'; String? password;
// Act bool result = login(email, password);
// Assert expect(result, false); });
test('login should return false when the email and password are empty', () { // Arrange String? email; String? password;
// Act bool result = login(email, password);
// Assert expect(result, false); });
test('login should return true when the email and password are not empty', () { // Arrange String email = 'ntminh@gmail.vn'; String password = 'password123';
// Act bool result = login(email, password);
// Assert expect(result, true);
});
});
Не стоит волноваться, если кто-то случайно удалит код, который сейчас тестируется. Во время рефакторинга тесты покажут ошибку!
Функция expect используется для проверки соответствия результата условию (matcher).
expect(actual, matcher);
Matcher может быть булевым значением, например true, false, строковым значением, например «OK», или числом: 0, -1 и т. д. Также он может быть комплексным выражением, таким как:
Мы уже научились писать модульные тесты для статичных функций, верхнеуровневых функций и расширений.Теперь изучим 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 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) необходимо использовать 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 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 Function() call = loginViewModel.logout;
// Assert
expect(
call,
throwsA(isA().having((e) => e.message, 'error message', 'Logout failed')),
);
});
});
Небольшие изменения в коде, представленном выше:
– Когда ожидаем, что функция выкинет ошибку вместо результата, то не можем вызывать метод logout на шаге Act. Его вызов породит некоторые ошибки, которые перенесутся в функцию тестирования, и это вызовет провал теста.
Можем только создать переменную с функцией:
final Future Function() call = loginViewModel.logout;
– Когда ожидаем, что функция выкинет ошибку вместо результата, можем использовать доступные для этого Matcher’ы: throwsArgumentError, throwsException и т.д. На примере выше ожидаем, что будет выброшена ошибка FlutterError, поэтому используем expect(call, throwsFlutterError).
– Когда нужно подтвердить более конкретно и подробно. Например, ожидания появления ошибки должно быть FlutterError и его message должен быть “Logout failed”. Тогда нужно использовать 2 Matcher’а: throwsA и isA.
expect(
call,
throwsA(isA().having((e) => e.message, 'error message', 'Logout failed')),
);
– Matcher throwsA() позволяет проверить выбрасывается ли какая-либо ошибка, включая кастомные классы исключений. На самом деле, throwsFlutterError — это эквивалент throwsA(isA FlutterError()).
– Matcher isA() позволяет проверить тип результата без привязки к определенному значению. Например, когда хотим, чтобы тест вернул либо true, либо false, так как это тип bool, можно использовать expect(result, isA()). Он часто используется с методом having для проведения более детальных проверок за пределами простого типа данных. Например, isA().having((e) => e.message, 'description: error message', 'Logout failed') — тоже самое, что требовать объект быть типа FlutterError и его свойства message равняться 'Logout failed'.
Теперь будет еще больше усложнен класс LoginViewModel при помощи создания переменной _cache для кеширования результата, полученного от SharedPreferences. При вызове функции login, ставится высший приоритет получению данных из кэша.
import 'package:shared_preferences/shared_preferences.dart';
class LoginViewModel {
final SharedPreferences sharedPreferences;
LoginViewModel({
required this.sharedPreferences,
});
final Map _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.
final Map _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 вызывается перед запуском каждого теста, поэтому она обычно используется для инициализации объектов для теста и конфигурации начальных значений. В примере выше порядок вызова функций выглядит так:
setUp (initialization) -> test case 1 -> setUp (re-initialization) -> test case 2
Таким образом, loginViewModel перед запуском второго тест кейса инициализируется заново, поэтому баг был пофикшен.
Функция tearDown вызывается после завершения каждого теста. Обычно ее используют для задач по очистке, таких как освобождение памяти, закрыть какие-либо ресурсы или закрыть соединение с базой данных.
Функция setUpAll вызывается всего один раз перед прогоном абсолютно всех тестов. Ее часто используют для открытия соединения с базой данных и дальнейшего использования одной базы данных для всех тестов.
setUpAll(() async {
await Isar.initializeIsarCore(download: true);
isar = await Isar.open([JobSchema], directory: '');
});
Функция tearDownAll тоже выполняется всего один раз после завершения всех тестов, поэтому часто используется для закрытия доступа к базе данных.
tearDownAll(() async {
await isar.close();
});
Далее пройдемся по нескольким примерам, чтобы понять, как применять эти 4 функции. Кроме этого, рассмотрим, как проверять 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>.broadcast();
StreamSubscription? _streamSubscription;
Stream> 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 новых вещи:
Используется для сброса Mock-объектов. Для ошибки выше можно вызвать ее в методах setUp или tearDown для исправления бага вместо инициализации Mock-объекта заново в функции setUp.
tearDown(() {
resetMocktailState();
});
Unit-тесты часто проверяют результат выполнения функции, основанный на входных данных. А что делать, если функция не возвращает результат (void методы)? Как тогда их протестировать?
Mocktail предоставляет функцию verify для проверки, был ли вызван метод и сколько раз это произошло.
Предположим, есть функция login, которая после успешной авторизации перенаправляет пользователя на домашний экран.
class LoginViewModel {
final Navigator navigator;
LoginViewModel({
required this.navigator,
});
void login(String email) {
if (email.isNotEmpty) {
navigator.push('home');
}
}
}
Проверим эту функцию в 2 тест кейсах:
Для первого случая используем функцию verifyNever(), чтобы удостовериться, что навигатор не вызывает функцию push.
test(
'navigator.push should not be called when the email is empty',
() {
// Arrange
String email = '';
// Act
loginViewModel.login(email);
// Assert
verifyNever(() => mockNavigator.push('home'));
},
);
Для второго случая используем функцию verify().called(1), чтобы проверить, что навигатор вызвал push всего один раз:
verify(() => mockNavigator.push('home')).called(1);
Предположим, если обновить код функции login, чтобы после успешной авторизации он переводил пользователя не только на домашний экран, но и пушил экран профиля.
void login(String email) {
if (email.isNotEmpty) {
navigator.push('home');
navigator.push('profile');
}
}
Тогда нужно обновить и тест тоже:
verify(() => mockNavigator.push('home')).called(2);
Но при запуске теста получаем ошибку.
Была ли функция push вызвана всего один раз? Нет, один раз была вызвана функция push с аргументом 'home', но сама функция 'home' вызывается дважды. Есть 2 способа исправления такой ошибки:
Первый способ - вызывать функцию verify дважды, вместо одного раза:
verify(() => mockNavigator.push('home')).called(1);
verify(() => mockNavigator.push('profile')).called(1);
Второй способ - передать any() функции verify:
verify(() => mockNavigator.push(any())).called(2);
Функция any используется для сравнения любого значения с определенным типом данных. На примере выше, any() может соответствовать и 'home', и 'profile'.
В двух исправлениях, упомянутых выше, если передать определенное значение, такое как ‘home’ или ‘profile’, то тест будет более строгим и намного более точным, по сравнению с any(). Это происходит потому, что any() соответствует любому значению, которое может быть 'home', 'profile', или любое другое, например, 'login' или 'register'.
Для того, чтобы сделать any() строже, нужно использовать ее с параметром that. Параметр that используется для соответствия любого аргумента, который удовлетворяет определенным условиям.
verify(
() => mockNavigator.push(
any(that: isA()
.having((e) => e.isNotEmpty, 'isNotEmpty', true)),
),
).called(2);
Например, поменяем функцию push для использования именованных параметров:
void push({
required String screenName,
}) {}
После этого модифицируем тест:
verify(() => mockNavigator.push(screenName: any())).called(2);
Когда запустим тест снова, обнаружим ошибку:
Это потому, что когда используется any(), как аргумент для именованных параметров, то нужно передать имя этого параметра в параметре named функции any(). Конкретно здесь, необходимо вызвать any(named: 'screenName') вместо any().
verify(() => mockNavigator.push(
screenName: any(named: 'screenName'),
)).called(2);
Обратите внимание, что нужно использовать функцию any() как аргумент к методам when для stubbing или verify для верификации. В противном случае, получится ошибка:
Invalid argument(s): The "any" argument matcher is used outside of method
stubbing (via `when`) or verification (via `verify` or `untilCalled`).
Например, когда это используется в функции login, оно вызовет ошибку.
loginViewModel.login(any());
Если нужно проверить, что функция push была вызвана сначала с аргументом «home», а потом с «profile». В таком случае, если вызывать функцию verify дважды, она будет неспособна проверить, так как нет ничего, что гарантирует, что push('home') была вызвана раньше, чем push('profile').
// BAD
verify(() => mockNavigator.push('home')).called(1);
verify(() => mockNavigator.push('profile')).called(1);
Тогда нужно использовать функцию captureAny.
test('navigator.push should be called with the correct argument when the email is not empty', () {
// Arrange
String email = 'ntminh@gmail.com';
// Act
loginViewModel.login(email);
// Verify that the navigator.push method is called with the correct argument
final capturedArguments = verify(() =>
mockNavigator.push(captureAny())).captured;
expect(capturedArguments, ['home', 'profile']);
expect(capturedArguments[0], 'home');
expect(capturedArguments[1], 'profile');
});
Функция captureAny используется для захвата всех значений аргументов. После захвата, можно проверить были ли переданы функции нужные аргументы. Также как функция any(), у captureAny() есть 2 параметра - named и that, которые по своему функционалу повторяют такие же, как в any().
Предположим, что теперь обновили функцию push так, что вместо передачи String, будет передаваться кастомный тип Screen.
class Navigator {
void push(Screen name) {}
}
class Screen {
final String name;
Screen(this.name);
}
Далее в тесте будет использоваться функция any(), чтобы представить любое значение Screen:
verify(() => mockNavigator.push(any())).called(2);
Когда тест запустится, возникнет ошибка.
Есть указания, как исправить ее при помощи функции registerFallbackValue и объекта с типом Screen.
setUpAll(() {
registerFallbackValue(Screen('login'));
});
Почему функция any() кидает ошибку, когда заменяем ей кастомный тип, и что за функция registerFallbackValue?
Когда используем функции any() и captureAny(), Mocktail нужно зарегистрировать значения fallback по умолчанию. Для примитивных типов данных Mocktail сделает это автоматически. Однако, для кастомных типов нужно использовать registerFallbackValue(), чтобы зарегистрировать значения fallback. Если этого не сделать, то это приведет к ошибке, упомянутой выше.
Нужно вызвать registerFallbackValue() один раз для каждого типа, чтобы зарегистрировать его fallback значение. Оно будет использоваться во всех тестах. Поэтому лучшим местом для вызова функции registerFallbackValue() является setUpAll().
Что произойдет, если добавить параметр с типом BuildContext в функцию push?
import 'package:flutter/material.dart';
class LoginViewModel {
final Navigator navigator;
LoginViewModel({
required this.navigator,
});
void login(BuildContext context, String email) {
if (email.isNotEmpty) {
navigator.push(context, 'home');
}
}
}
class Navigator {
void push(BuildContext context, String name) {}
}
Но BuildContext — абстрактный класс, тогда как можно его инициализировать?
На этом этапе необходимо создать новый фейковый тип, расширив класс Fake.
class FakeBuildContext extends Fake implements BuildContext {}
Вместо создания реального объекта BuildContext(), нужно создать только фейковый объект FakeBuildContext().
registerFallbackValue(FakeBuildContext());
...
loginViewModel.login(FakeBuildContext(), email);
Однако, если наследоваться от класса Mock вместо Fake, то тест все равно пройдет. Так в чем же разница между Fake и Mock?
Термины Fake и Mock называют «Тестовыми двойниками». Тестовые двойники — объекты, которые заменяют реальные во время тестирования. Другими словами, обе техники используются для создания фейковых классов и объектов. Также они применяются для имитации методов фейковых объектов и для контроля возвращаемых значений этими методами.
Если техника Mocking использует Stubbing для имитации и контроля результата функций, то с Faking её применять нельзя. Faking позволяет переопределять методы реального класса в нужном для тестирования виде.
Давайте снова напишем тесты для класса LoginViewModel из части 3, но будем использовать технику Faking вместо Mocking.
Сначала создадим класс FakeSharedPreferences, который наследуется от Fake. Если использовать Mock, то необходимо подменить методы getString и clear, но при использовании Faking их нужно переопределить.
class FakeSharedPreferences extends Fake implements SharedPreferences {
@override
String? getString(String key) {
if (key == 'ntminh@gmail.com') {
return '123456';
}
return null;
}
@override
Future clear() {
return Future.value(true);
}
}
Далее, нам нужно будет убрать строки кода, которые используют Stubbing.
test('login should return false when the password are incorrect', () {
// Arrange
final fakeSharedPreferences = FakeSharedPreferences();
final loginViewModel = LoginViewModel(
sharedPreferences: fakeSharedPreferences,
);
String email = 'ntminh@gmail.com';
String password = 'abc';
// Stubbing -> remove this line
// when(() =>
mockSharedPreferences.getString(email)).thenReturn('123456');
// Act
final result = loginViewModel.login(email, password);
// Assert
expect(result, false);
});
Однако при запуске теста он упадет
test('logout should throw an exception when the clear method returns false', () async {
// Arrange
final fakeSharedPreferences = FakeSharedPreferences();
final loginViewModel = LoginViewModel(
sharedPreferences: fakeSharedPreferences,
);
// Stubbing -> remove this line
// when(() => mockSharedPreferences.clear())
// .thenAnswer((_) => Future.value(false));
// Act
final Future Function() call = loginViewModel.logout;
// Assert
expect(call, throwsFlutterError);
});
Это произошло из-за переопределения функции clear для возвращения Future.value(true), но ожидается, что вернется Future.value(false). Поэтому не нужно использовать класс FakeSharedPreferences для проверки этого тест кейса. Вместо этого создадим новый класс, чтобы переопределить функцию clear, чтобы она возвращала Future.value(false).
class SecondFakeSharedPreferences extends Fake implements SharedPreferences {
@override
String? getString(String key) {
if (key == 'ntminh@gmail.com') {
return '123456';
}
return null;
}
@override
Future clear() {
return Future.value(false);
}
}
Тогда для падающего теста, показанного выше, будем использовать класс SecondFakeSharedPreferences.
final fakeSharedPreferences = SecondFakeSharedPreferences();
Можно заметить, когда используется Faking, можно создать несколько классов Fake, чтобы достичь этого. Это недостаток использования Faking. А какие есть плюсы у Faking?
Чтобы узнать преимущества Faking, давайте перейдем к другому примеру. Допустим, есть классы JobViewModel, JobRepository и JobData:
class JobRepository {
final Isar isar;
JobRepository({required this.isar});
Future addJob(JobData jobData) async {
await isar.writeTxn(() async {
isar.jobDatas.put(jobData);
});
}
Future updateJob(JobData jobData) async {
await isar.writeTxn(() async {
isar.jobDatas.put(jobData);
});
}
Future deleteJob(int id) async {
await isar.writeTxn(() async {
isar.jobDatas.delete(id);
});
}
Future> getAllJobs() async {
return await isar.jobDatas.where().findAll();
}
}
Это класс JobViewModel.
class JobViewModel {
JobRepository jobRepository;
JobViewModel({required this.jobRepository});
final Map jobMap = {};
Future addJob({
required JobData jobData,
}) async {
await jobRepository.addJob(jobData);
}
Future updateJob({
required JobData jobData,
}) async {
await jobRepository.updateJob(jobData);
}
Future deleteJob(int id) async {
await jobRepository.deleteJob(id);
}
Future getAllJobs() async {
final jobs = await jobRepository.getAllJobs();
jobMap.clear();
for (var post in jobs) {
jobMap[post.id] = post;
}
}
}
Теперь напишем тест для него.
Сначала необходимо создать класс FakeJobRepository. Создадим переменную jobDataInDb с типом List для имитации реальных данных в базе данных Isar. Тогда можно переопределить все 4 метода в JobRepository.
class FakeJobRepository extends Fake implements JobRepository {
// Suppose initially there are 3 jobs in the database.
final jobDataInDb = [
JobData()..id = 1..title = 'Job 1',
JobData()..id = 2..title = 'Job 2',
JobData()..id = 3..title = 'Job 3',
];
@override
Future addJob(JobData jobData) async {
jobDataInDb.add(jobData);
}
@override
Future updateJob(JobData jobData) async {
jobDataInDb
.firstWhere((element) => element.id == jobData.id)
.title = jobData.title;
}
@override
Future deleteJob(int id) async {
jobDataInDb.removeWhere((element) => element.id == id);
}
@override
Future> getAllJobs() async {
return jobDataInDb;
}
}
Далее протестируем функцию addJob в классе JobViewModel.
group('addJob', () {
test('should add job to jobMap', () async {
// before adding job
await jobViewModel.getAllJobs();
expect(jobViewModel.jobMap, {
1: JobData()..id = 1..title = 'Job 1',
2: JobData()..id = 2..title = 'Job 2',
3: JobData()..id = 3..title = 'Job 3',
});
await jobViewModel
.addJob(jobData: JobData()..id = 4..title = 'Job 4');
// after adding job
await jobViewModel.getAllJobs();
expect(jobViewModel.jobMap, {
1: JobData()..id = 1..title = 'Job 1',
2: JobData()..id = 2..title = 'Job 2',
3: JobData()..id = 3..title = 'Job 3',
4: JobData()..id = 4..title = 'Job 4',
});
});
});
Больше тест кейсов можно найти здесь.
Таким образом, проверку прошли не только отдельные функции по типу getAllJobs и addJob, но и тест кейсы, где эти функции работают вместе. Это помогает сделать тестирование более похожим на запуск в реальном окружении.
Если использовать Mocking и Stubbing для тестирования функции addJob, то код будет выглядеть так:
test('should add job to jobMap', () async {
// Arrange
final jobData = JobData()..id = 4..title = 'Job 4';
// Stub
when(() => mockJobRepository.addJob(jobData))
.thenAnswer((_) async {});
// Act
await jobViewModel.addJob(jobData: jobData);
// Assert
verify(() => mockJobRepository.addJob(jobData)).called(1);
});
Больше тест кейсов можно найти здесь.
Таким образом, проверку прошли не только отдельные функции по типу getAllJobs и addJob, но и тест кейсы, где эти функции работают вместе. Это помогает сделать тестирование более похожим на запуск в реальном окружении.
Если использовать Mocking и Stubbing для тестирования функции addJob, то код будет выглядеть так:
test('should add job to jobMap', () async {
// Arrange
final jobData = JobData()..id = 4..title = 'Job 4';
// Stub
when(() => mockJobRepository.addJob(jobData))
.thenAnswer((_) async {});
// Act
await jobViewModel.addJob(jobData: jobData);
// Assert
verify(() => mockJobRepository.addJob(jobData)).called(1);
});
При таком подходе не определяется, корректно работает функция addJob или нет. Альтернативно можно написать код так:
test('should add job to jobMap', () async {
final jobData = JobData()..id = 4..title = 'Job 4';
// Stub
when(() => mockJobRepository.addJob(jobData))
.thenAnswer((_) async {});
when(() => mockJobRepository.getAllJobs()).thenAnswer((_) async {
return [
JobData()..id = 1..title = 'Job 1',
JobData()..id = 2..title = 'Job 2',
JobData()..id = 3..title = 'Job 3',
JobData()..id = 4..title = 'Job 4',
];
});
// Act
await jobViewModel.addJob(jobData: jobData);
await jobViewModel.getAllJobs();
// Assert
expect(jobViewModel.jobMap, {
1: JobData()..id = 1..title = 'Job 1',
2: JobData()..id = 2..title = 'Job 2',
3: JobData()..id = 3..title = 'Job 3',
4: JobData()..id = 4..title = 'Job 4',
});
});
Когда заменили на 4 JobData, то, определенно, результат в выражении expect будет тоже 4 JobData. Поэтому не нужно определять корректно работает функция addJob или нет.
Подводя итоги, использование Faking может быть более эффективно, чем Mocking, в случаях, похожих на этот.
Подписывайтесь на телеграм-канал Flutter. Много, чтобы не пропустить новый выпуск и еще много всего интересного о кроссплатформенной разработке.