Полный гайд по тестированию на Flutter. Часть 6: Тестовые двойники: Faking vs Mocking

Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga. Мы с вами разобрали уже больше половины гайда о тестировании в Flutter! Сегодня статья перевод посвящена технике Faking. А в следующих частях рассмотрим часто встречаемые ошибки и лучшие практики в написании Unit-тестов. Так что не переключайтесь!

Полный гайд по тестированию на Flutter. Часть 6: Тестовые двойники: Faking vs Mocking

Новые выпуски, полезные плагины и библиотеки, кейсы и личный опыт в нашем авторском телеграм-канале Flutter. Много. В нашем сообществе уже 2645 мобильных разработчиков, присоединяйтесь!

Полный гайд по тестированию на Flutter. Часть 6: Тестовые двойники: Faking vs Mocking

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 для имитации реальных данных в базе данных 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. Много, чтобы не пропустить новый выпуск и еще много всего интересного о кроссплатформенной разработке.

22
1 комментарий

Название техники забавное) Всего одна буква и название становится описанием тестирования, когда в нем что-то идет не так 😂