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

Комментарии (0)