Полный гайд по тестированию на Flutter. Части 7-8: Ошибки, которые усложняют написание тестов
Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga. После изучения техник написания Unit-тестов в прошлых частях пришло время перейти к изучению моментов, когда мы не сможем написать тесты. Это означает, что где-то допущены ошибки при написании кода, что усложняет автоматическое тестирование.
Мы объединили 2 статьи (1, 2), чтобы сразу рассказать о всех часто встречаемым ошибкам при написании кода. Поехали!
Hidden text
Пс: новые выпуски в нашем телеграм-канале Flutter. Много. Подписывайся, чтобы не пропустить.
Ошибка 1: Не использовать Dependency Injection (DI)
Без использования DI нельзя использовать Mocking и Stubbing для тестирования разнообразных сценариев.
Для понимания напишем 2 примера: один с DI, а другой без него.
Например, есть класс Storage
, как зависимость класса Repository
.
class Storage {
String getAccessToken() {
return 'token';
}
}
Автор оригинала под DI подразумевает не сам DI, а правильное написание, чтобы его возможно было использовать.
Теперь напишем код класса Repository
с использованием DI.
class Repository {
final Storage storage;
Repository({required this.storage});
bool get isLoggedIn => storage.getAccessToken().isNotEmpty;
}
Без DI класс Repository
будет выглядеть так:
class Repository {
final storage = Storage();
bool get isLoggedIn => storage.getAccessToken().isNotEmpty;
}
Теперь напишем тесты к 2 классам Repository
.
Когда используем DI можно создать класс MockStorage
.
class MockStorage extends Mock implements Storage {}
void main() {
late MockStorage mockStorage;
late Repository repository;
setUp(() {
mockStorage = MockStorage();
repository = Repository(storage: mockStorage);
});
}
Далее можно использовать Stubbing для имитации функции getAccessToken
, чтобы она возвращала empty или non-empty. Таким образом, получается 2 различных тестовых сценария:
test('should return true when the access token is not empty', () {
// Arrange
when(() => mockStorage.getAccessToken()).thenReturn('access_token');
// Act
bool isLoggedIn = repository.isLoggedIn;
// Assert
expect(isLoggedIn, true);
});
test('should return false when the access token is empty', () {
// Arrange
when(() => mockStorage.getAccessToken()).thenReturn('');
// Act
bool isLoggedIn = repository.isLoggedIn;
// Assert
expect(isLoggedIn, false);
});
Если не использовать DI, нельзя подменить класс Storage
и имитировать функцию getAccessToken
, поэтому будет только один тестовый сценарий.
void main() {
late Repository repository;
setUp(() {
repository = Repository();
});
test('should return true when the access token is not empty', () {
// Act
bool isLoggedIn = repository.isLoggedIn;
// Assert
expect(isLoggedIn, true);
});
}
Подытоживая, использование DI помогает проверять больше тестовых сценариев.
Ошибка 2: Использовать верхнеуровневые функции и переменные внутри метода, который тестируется
Предположим, в приложении вызываются API из 3 разных серверов: Firebase, Facebook и приватный сервер. Зачастую создаются глобальные переменные для использования в классах Repository. Примерно вот так:
final firebaseApiClient = Dio(BaseOptions(baseUrl: 'https://firebase.google.com'));
final appServerApiClient = Dio(BaseOptions(baseUrl: 'https://nals.vn'));
Эти переменные переиспользуются в множестве функций класса Repository
.
class Repository {
Future<String> getMyJob() async {
final response = await appServerApiClient.request('/me/job');
return response.toString();
}
Future<String> getAllJobs() async {
final response = await appServerApiClient.request('/jobs');
return response.toString();
}
}
Если код был написан таким образом, то невозможно протестировать функции getMyJob
и getAllJobs
.
test('getMyJob should return what the API returns', () async {
final repository = Repository();
final jobs = await repository.getMyJob();
expect(jobs, 'IT'); // Откуда я знаю, что API вернет «IT»?
});
Repository
зависит от глобальной переменной appServerApiClient
, и глобальные переменные невозможно подменить и имитировать результат, который возвращает API. Поэтому, нельзя узнать, что за API вернет, чтобы передать это в функцию expect
.
Более того, когда не заменяется appServerApiClient
при запуске теста, он будет делать реальный запрос к API, что приведет к риску падения теста из-за ошибок сервера с кодами 4xx и 5xx.
Теперь отрефакторим этот код, чтобы стало возможным написать тесты.
Вместо создания 3 глобальных переменных, нужно создать 3 класса.
class AppServerApiClient {
final Dio dio;
AppServerApiClient() : dio = Dio(BaseOptions(baseUrl:
'https://nals.vn'));
Future<Response> request(String path) async {
return dio.request(path);
}
}
class FirebaseApiClient {
final Dio dio;
FirebaseApiClient() : dio = Dio(BaseOptions(baseUrl:
'https://firebase.google.com'));
Future<Response> request(String path) async {
return dio.request(path);
}
}
class FacebookApiClient {
...
}
Чтобы избежать дублирования кода, создаем класс BaseApiClient
.
class BaseApiClient {
final String baseUrl;
final Dio dio;
BaseApiClient(this.baseUrl) : dio
= Dio(BaseOptions(baseUrl: baseUrl));
Future<Response> request(String path) async {
return dio.request(path);
}
}
class AppServerApiClient extends BaseApiClient {
AppServerApiClient() : super('https://nals.vn');
}
class FirebaseApiClient extends BaseApiClient {
FirebaseApiClient() : super('https://firebase.google.com');
}
class FacebookApiClient extends BaseApiClient {
FacebookApiClient() : super('https://facebook.com');
}
Далее внедряем их в Repository
.
class Repository {
final AppServerApiClient appServerApiClient;
final FirebaseApiClient firebaseApiClient;
final FacebookApiClient facebookApiClient;
Repository({
required this.appServerApiClient,
required this.firebaseApiClient,
required this.facebookApiClient,
});
...
}
Теперь можно создать Mock-объект для API и использовать технику Stubbing.
class MockAppServerApiClient extends Mock
implements AppServerApiClient {}
class MockFirebaseApiClient extends Mock
implements FirebaseApiClient {}
class MockFacebookApiClient extends Mock
implements FacebookApiClient {}
...
test('getMyJob should return ', () async {
// Stub
when(() => mockAppServerApiClient.request('/me/job')).thenAnswer(
(_) async => Response(
requestOptions: RequestOptions(path: '/me/job'),
data: 'IT',
),
);
// Act
final jobs = await repository.getMyJob();
// Assert
expect(jobs, 'IT');
});
Ошибка 3: Вызывать функцию плагина, которая использует нативный код, внутри тестируемой функции
Часто используются функции напрямую из таких плагинов, как FirebaseAnalytics
, FirebaseCrashlytics
, FirebaseFirestore
и других, внутри функций классов Repository
:
class Repository {
Future<String> getMyJob() async {
final response = await
FirebaseFirestore.instance.collection('job').doc('me').get();
return response.data()?['data'] ?? '';
}
}
Такой код также нарушает и ошибку 2, так как невозможно использовать Mocking для класса FirebaseFirestore, что приводит к вызову реальной функции и падению теста. Также, если функция плагина, которая использует нативный код, была запущена в тестовом окружении, получится ошибка:
MissingPluginException(No implementation found for method someMethodName on channel some_channel_name)
Для этого случая понадобится создать класс, в который нужно обернуть вызов функции плагина. В это же время, будет очень тяжело написать тесты для этого класса, поэтому попробуем сделать его функции максимально простыми и логичными.
class FirebaseFirestoreService {
Future<Map<String, dynamic>?> getMyJob() async {
final response = await
FirebaseFirestore.instance.collection('job').doc('me').get();
return response.data();
}
}
Остальная логика будет написана в классе Repository
.
class Repository {
final FirebaseFirestoreService firebaseFirestoreService;
Repository({required this.firebaseFirestoreService});
Future<String> getMyJob() async {
final response = await firebaseFirestoreService.getMyJob();
return response?['data'] ?? '';
}
}
Теперь можно написать тесты для класса Repository
.
Обратите внимание, что плагины, которые используют только код на Dart, могут работать нормально в Unit-тестах. Помимо вышеуказанного исправления, можно обратиться к другим способам здесь.
Ошибка 4: Не отделять логику от UI
Добавление логики в виджеты, которая не может быть протестирована как UI, сделает эту логику сложной для тестирования. Например:
class LoginButton extends StatelessWidget {
const LoginButton({
super.key,
required this.email,
required this.password,
});
final String email;
final String password;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
login(context, email, password);
},
child: const Text('Login'),
);
}
void login(BuildContext context, String email, String password) {
if (email.isEmpty || password.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Email and password are required'),
),
);
} else {
Navigator.of(context).pushNamed('home');
}
}
}
Если код такой, то невозможно написать тесты на функцию login
, так как класс LoginButton
— это виджет и не может быть проинициализирован в тестовом окружении.
Нужно создать еще один класс для логики и отделить ее от UI.
class LoginViewModel {
bool login(String email, String password) {
if (email.isEmpty || password.isEmpty) {
return false;
} else {
return true;
}
}
}
В коде выше нельзя проверить был ли показан SnackBar
или было ли переключение на Home screen. Для тестирования строк кода Flutter плагинов, таких как Navigator
и ScaffoldMessenger
, нужно создать класс-оболочку для таких функций:
class AppNavigator {
final BuildContext context;
AppNavigator(this.context);
void showSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
),
);
}
void pushNamed(String name) {
Navigator.of(context).pushNamed(name);
}
}
Теперь, нужно изменить функцию login
.
void login(AppNavigator navigator, String email, String password) {
if (email.isEmpty || password.isEmpty) {
navigator.showSnackBar('Email and password are required');
} else {
navigator.pushNamed('home');
}
}
Сейчас можно написать тесты.
class MockAppNavigator extends Mock implements AppNavigator {}
...
test('navigator.push should be called once when the email and password are not empty', () {
// Arrange
String email = 'ntminh@gmail.com';
String password = '123';
// Act
loginViewModel.login(mockAppNavigator, email, password);
// Assert
verifyNever(() => mockAppNavigator.showSnackBar(any()));
verify(() => mockAppNavigator.pushNamed('home')).called(1);
});
В этом коде не использовались никакие популярные решения для управления состоянием, такие как Riverpod, BLoC и т. п. Также не использовались шаблоны проектирования MVC, MVP или MVVM, поэтому код получился не очень чистым и с анти-паттернами.
На самом деле, если используются пакеты для управления состоянием Riverpod, BLoC или шаблоны типа MVC, MVP, MVVM, то это поможет отделить логику от UI и повысить качество кода, чтобы написать на него тесты.
Правильное использование архитектурных подходов облегчает тестирование. Поэтому используйте SOLID и будет вам счастье!
Ошибка 5: Использовать DateTime.now()
Предположим, что нужно протестировать функцию isNewJob
.
class Job {
final DateTime postedAt;
Job({required this.postedAt});
bool get isNewJob => DateTime.now().difference(postedAt).inDays <= 7;
}
28 января 2024 года — день, когда автор писал статью, поэтому тест написан со сценарием ровно на неделю раньше — 21 января 2024 года.
Даты, используемые автором оригинала, сохранены.
test('isNewJob returns true if job is posted within 7 days', () {
final job = Job(postedAt: DateTime(2024, 1, 21));
expect(job.isNewJob, true);
});
Однако, если запустить этот тест завтра, то он упадет, так как пройдет уже 8 дней с postedAt
.
Для того, чтобы это исправить, нужно добавить пакет clock и заменить DateTime.now()
на clock.now()
.
bool get isNewJob => clock.now().difference(postedAt).inDays <= 7;
В это же время необходимо использовать функцию withClock
, чтобы имитировать текущее время.
test('isNewJob returns true if job is posted within 7 days', () {
final job = Job(postedAt: DateTime(2023, 1, 10));
withClock(Clock.fixed(DateTime(2023, 1, 17)), () {
expect(job.isNewJob, true);
});
});
Как можно увидеть, даже если изменить postedAt
на 2023 год, то результат останется верным. Это происходит из-за того, что изменилась имитация текущего времени на использование 2023 года.
Ошибка 6: Написать слишком большую функцию или поделить ее на много слишком маленьких
До этого была написана функция, которая совершала много действий на Splash screen, включая:
Получение Remote Config из Firebase.
Проверка версии приложения для принудительного обновления.
Проверка, что пользователь впервые запустил приложение.
Проверка, что необходимо показать пользователю рекомендацию обновиться и важные диалоговые окна.
Вот этот код:
class UseCaseOutput {
final Config remoteConfig; // remote config from Firebase
final bool needForceUpdate; // need force update or not
final bool isFirstLogin; // is this the first time login?
final bool recommendUpdateApp; // need to show dialog to recommend update app
final bool isShowImportantNotice; // need show dialog with important notice
const UseCaseOutput({
required this.remoteConfig,
required this.needForceUpdate,
required this.isFirstLogin,
required this.recommendUpdateApp,
required this.isShowImportantNotice,
});
}
class FetchRemoteConfigUseCase {
const FetchRemoteConfigUseCase(this.repository);
final Repository repository;
Future<UseCaseOutput> execute() async {
final remoteConfig = await repository.fetchRemoteConfig();
final currentAppVersion = _getCurrentAppVersion();
var matchedVersion = _checkForceUpdate(
remoteConfig.versionList,
currentAppVersion,
);
final lastRecommendTime =
DateTime.tryParse(repository.showRecommendUpdateVersionTime);
final lastShowImportantNotice =
DateTime.tryParse(repository.showImportantNoticeTime);
return UseCaseOutput(
remoteConfig: matchedVersion?.config
?? remoteConfig.defaultConfig,
needForceUpdate: matchedVersion == null,
isFirstLogin: repository.isFirstLogin,
recommendUpdateApp: matchedVersion?.config != null
&& matchedVersion!.config.recommendUpdateVersion
.isRecommendUpdate(lastRecommendTime),
isShowImportantNotice: matchedVersion?.config != null
&& matchedVersion!.config.importantNotice
.isShowNotice(lastShowImportantNotice),
);
}
Version _getCurrentAppVersion() {
final versionName = RegExp(r'\d+')
.allMatches(repository.currentAppVersion)
.map((e) => int.tryParse(e.group(0) ?? '0'));
return Version(
major: versionName.elementAtOrNull(0) ?? 0,
minor: versionName.elementAtOrNull(1) ?? 0,
revision: versionName.elementAtOrNull(2) ?? 0,
availableFrom: DateTime.now(),
availableTo: DateTime.now(),
);
}
Version? _checkForceUpdate(
List<Version> remoteConfigVersions,
Version currentAppVersion,
) {
Version? currentConfig;
for (final version in remoteConfigVersions.sortedDescending()) {
if (version.isEqualWith(currentAppVersion)) {
if (version.isAvailable) {
currentConfig = version;
}
break;
}
if (currentAppVersion.isGreaterThan(version)
&& version.isAvailable) {
if (currentConfig == null
|| version.isGreaterThan(currentConfig)) {
currentConfig = version;
}
}
}
return currentConfig;
}
}
class Repository {
Future<RemoteConfig> fetchRemoteConfig() async
=> const RemoteConfig();
String get lastRecommendTime => '';
String get lastShowImportantNotice => '';
bool get isFirstLogin => false;
String get currentAppVersion => '1.1.0';
}
Написать метод с таким большим количеством функционала приведет к тому, что в тестах будет излишнее дублирование кода. Например, нужно проверить только функцию проверки для принудительного обновления, но требуется использовать Mocking и Stubbing для других функций, которые с этим не связаны.
when(() => _appRepository.fetchRemoteConfig()).thenAnswer((_)
=> Future.value(remoteConfig));
when(() => _appRepository.currentAppVersion).thenReturn('1.1.0');
// không liên quan đến chức năng force update
when(() => _appRepository.lastRecommendTime).thenReturn('');
when(() => _appRepository.lastShowImportantNotice).thenReturn('');
when(() => _userRepository.isFirstLogin).thenReturn(true);
Если необходимо протестировать всего один сценарий, то нужно повторить минимум 3 строчки кода. Тогда как при проверке множества кейсов, количество ненужного кода будет очень большим.
Более того, когда изменится код в функции lastRecommendTime
и нужно будет переписать тест, то тест кейсы для принудительного обновления тоже будут затронуты. Тогда придется фиксить множество тестов, не связанных с функцией lastRecommendTime
.
С другой стороны, если поделить функцию на слишком много мелких, это тоже плохо. Потому что тогда придется также писать множество тест кейсов и что более важно — множество маленьких тестов, что не поможет в обеспечении качества.
Как уже было упомянуто в первой части, Unit-тесты фокусируются только на тестировании отдельной функции. И можно удостовериться только, что каждая функция запускается корректно, но без гарантии, что вместе они будут работать как надо.
В общем, если написать большую функцию, которая объединяет много функционала или разбить функцию на слишком мелкие, то это не скажется хорошо на написании тестов в дальнейшем.
Заключение
Выше были представлены часто встречаемые ошибки, которые обнаруживаются во время написания кода и делают написание тестов сложнее. Надеемся, что эта статья даст вам больше знаний и опыта, чтобы лучше проектировать код для упрощения написания тестов и покрытия большего количества сценариев.
В следующей статье поговорим про best practices при написании тестов.
Подписывайся на телеграм-канал Flutter. Много, чтобы не пропустить новый выпуск!