Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga. Мы с вами разобрали уже больше половины гайда о тестировании в Flutter! Сегодня статья перевод посвящена технике Faking. А в следующих частях рассмотрим часто встречаемые ошибки и лучшие практики в написании Unit-тестов. Так что не переключайтесь!
Новые выпуски, полезные плагины и библиотеки, кейсы и личный опыт в нашем авторском телеграм-канале Flutter. Много. В нашем сообществе уже 2645 мобильных разработчиков, присоединяйтесь!
Faking
Что произойдет в примере из прошлой статьи, если добавить параметр с типом 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) {}
}
Тогда, нужно обновить тест таким образом:
void main() {
...
setUpAll(() {
registerFallbackValue(BuildContext());
});
...
group('login', () {
test('navigator.push should be called once when the email is not empty', () {
...
loginViewModel.login(BuildContext(), email);
verify(() => mockNavigator.push(any(), any())).called(1);
});
});
}
Но BuildContext
— абстрактный класс, тогда как можно его инициализировать?
На этом этапе необходимо создать новый фейковый тип, расширив класс Fake
.
class FakeBuildContext extends Fake implements BuildContext {}
Вместо создания реального объекта BuildContext()
, нужно создать только фейковый объект FakeBuildContext()
.
registerFallbackValue(FakeBuildContext());
...
loginViewModel.login(FakeBuildContext(), email);
Однако, если наследоваться от класса Mock
вместо Fake
, то тест все равно пройдет. Так в чем же разница между Fake
и Mock
?
Faking vs Mocking
Термины 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<bool> 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<bool> 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<bool> 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<void> addJob(JobData jobData) async {
await isar.writeTxn(() async {
isar.jobDatas.put(jobData);
});
}
Future<void> updateJob(JobData jobData) async {
await isar.writeTxn(() async {
isar.jobDatas.put(jobData);
});
}
Future<void> deleteJob(int id) async {
await isar.writeTxn(() async {
isar.jobDatas.delete(id);
});
}
Future<List<JobData>> getAllJobs() async {
return await isar.jobDatas.where().findAll();
}
}
Это класс JobViewModel
.
class JobViewModel {
JobRepository jobRepository;
JobViewModel({required this.jobRepository});
final Map<int, JobData> jobMap = {};
Future<void> addJob({
required JobData jobData,
}) async {
await jobRepository.addJob(jobData);
}
Future<void> updateJob({
required JobData jobData,
}) async {
await jobRepository.updateJob(jobData);
}
Future<void> deleteJob(int id) async {
await jobRepository.deleteJob(id);
}
Future<void> getAllJobs() async {
final jobs = await jobRepository.getAllJobs();
jobMap.clear();
for (var post in jobs) {
jobMap[post.id] = post;
}
}
}
Теперь напишем тест для него.
Сначала необходимо создать класс FakeJobRepository
. Создадим переменную jobDataInDb
с типом List<JobData>
для имитации реальных данных в базе данных 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<void> addJob(JobData jobData) async {
jobDataInDb.add(jobData);
}
@override
Future<void> updateJob(JobData jobData) async {
jobDataInDb
.firstWhere((element) => element.id == jobData.id)
.title = jobData.title;
}
@override
Future<void> deleteJob(int id) async {
jobDataInDb.removeWhere((element) => element.id == id);
}
@override
Future<List<JobData>> 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);
});
При таком подходе не определяется, корректно работает функция 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. Много, чтобы не пропустить новый выпуск и еще много всего интересного о кроссплатформенной разработке.