Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga. Поздравляю вас, коллеги, мы это сделали — это последняя серия нашего многосерийного сериала про тестирование Flutter приложений. И напоследок разберем 9 лучших практик написания модульных тестов, которые помогут создавать более эффективные Unit-тесты. Оригинал оставлю тут, если вы вдруг знаете вьетнамский :–)

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

№1 Каждый тест должен проверять только один сценарий

Обычно, каждый тест должен иметь только одно утверждение (вызов функций expect или verify). Тест без утверждения точно не считается хорошим. Но хорошо ли, если слишком много утверждений? 

Много людей скажут, что нет, но для автора, множество утверждений — не проблема, так как это делает тестирование тщательным и помогает отлаживать тест намного легче. 

Однако, каждый тест должен соответствовать принципу Единственной Ответственности (Single Responsibility из SOLID). Это означает, что он должен проверять только один тест-кейс. Например, когда необходимо проверить функцию addJob, на этапе Act — не нужно вызывать функции updateJob и removeJob. Они могут исказить результат тестируемой функции. 

Например, в 6 части автор вызвал функцию expect дважды в одном тесте.

// GOOD
group('addJob', () {
  test('should add job to jobMap', () async {
    // before adding job
    await jobViewModel.getAllJobs();
    expect(jobViewModel.jobMap.length, 3);

    // Act
    await jobViewModel.addJob(
      jobData: JobData()..id = 4..title = 'Job 4',
    );

    // after adding job
    await jobViewModel.getAllJobs();
    expect(jobViewModel.jobMap.length, 4);
  });
});

// BAD
group('addJob', () {
  test('should add job to jobMap', () async {
    // before adding job
    await jobViewModel.getAllJobs();
    expect(jobViewModel.jobMap.length, 3);

    // Act
    await jobViewModel.addJob(
      jobData: JobData()..id = 4..title = 'Job 4',
    );
    await jobViewModel.updateJob(
      jobData: JobData()..id = 1..title = 'Job 1 updated',
    );
    await jobViewModel.deleteJob(2);

    // after adding job
    await jobViewModel.getAllJobs();
    expect(jobViewModel.jobMap.length, 3);
  });
});

№2 Нужно сравнивать объект полностью, а не каждое его поле по отдельности

Предположим, что данные из таблицы JobData требуется мигрировать в таблицу NewJobData.

@collection
class NewJobData {
  Id id = Isar.autoIncrement;

  late String title;
  late String desciption;

  @override
  bool operator ==(Object other) =>
    identical(this, other) ||
    other is NewJobData &&
    runtimeType == other.runtimeType &&
    title == other.title &&
    desciption == other.desciption;

  @override
  int get hashCode => title.hashCode ^ desciption.hashCode;
}

Тогда нужен код для этой миграции внутри JobData.

@collection
class JobData {
  Id id = Isar.autoIncrement;

  late String title;

  @override
  String toString() {
    return title;
  }

  @override
  bool operator ==(Object other) =>
    identical(this, other) ||
    other is JobData &&
    runtimeType == other.runtimeType &&
    title == other.title;

  @override
  int get hashCode => title.hashCode;

  NewJobData migrate() {
    return NewJobData()
              ..title = title
              ..desciption = '';
  }
}

Теперь необходимо написать тесты для функции migrate.

test('[v1] data should not be changed after migrating', () {
  final oldJobData = JobData()..title = 'IT';

  final newJobData = oldJobData.migrate();

  expect(newJobData.title, 'IT');
  expect(newJobData.desciption, '');
});

Конечно сравнивать каждое поле title и description тоже правильно, но лучше, сравнивать весь объект.

test('[v2] data should not be changed after migrating', () {
  final oldJobData = JobData()..title = 'IT';

  final newJobData = oldJobData.migrate();

  expect(
    newJobData,
    NewJobData()
      ..title = 'IT'
      ..desciption = '',
  );
});

Почему? Предположим, что в один день, было добавлено несколько новых полей типа postedAt в класс NewJobData, но не отредактирована функция миграции в классе JobData. Это приведет к багу, и совершить миграцию будет невозможно.

Однако, несмотря на то, что в коде есть баги, во время запуска теста [v1] снова, он пройдет. Это показывает, что тест [v1] не достаточен, чтобы показать этот баг. В тоже время, если запустить тест [v2], он упадет. Получается, что сравнение всего объекта вместо сравнения каждого поля по отдельности поможет находить баги при изменениях.

№3 Не использовать if else и циклы в тестах

Часто случается так, что разработчик видит дублирующийся код теста и рефакторит его. Рефакторинг — это хорошо, но делать это добавлением логики, булевых переменных, флагов, if else и циклов for для тестирования не лучшая идея. Не гарантируется, что логика, которую написали в тесте, без багов. Если тест упадет, не получится узнать, это случилось из-за плохого кода или плохого теста. И на самом деле, невозможно будет писать тесты на тестирование этой логики.

// BAD
test('...', () {
  ...
  for (int i = 0; i < list.length; i++) {
    if (someCondition) {
      expect(someVariable, someValueA);
    } else if (someOtherCondition) {
      expect(someVariable, someValueB);
    } else {
      expect(someVariable, someValueC);
    }
  }
  ...
});

№4 Писать тесты независимо от кода, не пытаться их модифицировать, чтобы они проходили

У многих разработчиков есть привычка, что когда тест проваливается, они думают, что это из-за того, что он плохо написан. Поэтому тест переписывается, пока он не пройдет. 

Если код верный и тест неправильный — тут все хорошо. Но если наоборот и мы пытаемся исправить тест, то оба наших кода получаются неправильными. Это идет вразрез с целью тестирования, потому что задача — проверить различные части кода, а не покрыть каждую его строку.

№5 Покрытие не важно, важно написать нужное количество тестов

Касательно тестирования функции isNewJob в части 8, то эта функция имеет всего одну строку кода, поэтому достаточно 1 теста для покрытия 100% кода.

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);
  });
});

Не настолько важно какое покрытие кода, как то, что протестированы все возможные кейсы, включая правильную работу, работу с ошибкой и краевые значения. Например, когда проверяется функцию isNewJob из примера выше, то нужно протестировать следующее:

  • Когда работа была опубликована на 1 день (happy case)

  • Когда работа была опубликована на 8 дней (happy case)

  • Когда работа была опубликована на 7 дней (краевое значение)

  • Когда работа была опубликована сегодня (краевое значение)

  • Когда postedAt больше текущего значения, потому что пользователь изменил время на устройстве раньше или дата из API не верна (скрытый случай)

№6 Использовать Faking, чтобы сделать фейковый репозиторий вместо использования Mock

// GOOD
class FakeJobRepository extends Fake implements JobRepository {
// Giả sử ban đầu trong DB đã có 3 job
  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;
  }
}

// BAD
class FakeJobRepository extends Mock implements JobRepository {}

when(() => mockJobRepository.addJob(jobData)).thenAnswer((_) async {});

Это было рассмотрено детально в части 7.

№7 Вызывать функцию registerFallbackValue в setUpAll

// GOOD
setUpAll(() {
  registerFallbackValue(Screen('login'));
});

// BAD
setUp(() {
  registerFallbackValue(Screen('login'));
});

Это было рассмотрено детально в части 5.

№8 Инициализировать тестовые объекты и Mock-объекты в функции setUp

// GOOD
late MockSharedPreferences mockSharedPreferences;
late LoginViewModel loginViewModel;

setUp(() {
  mockSharedPreferences = MockSharedPreferences();
  loginViewModel = LoginViewModel(sharedPreferences:  
    mockSharedPreferences);
});

// BAD
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: 
  mockSharedPreferences);

Это было рассмотрено детально в части 4.

№9 Naming convention

Тут есть всего 3 правила, которые нужно запомнить:

  1. Название тестового файла должно состоять из названия файла с кодом плюс суффикс _test.dart.

  2. Структура папки test должна повторять структуру папки lib:

  1. Описания тестов (не важно насколько оно длинное) должно быть понятно другим, чтобы мгновенно понимать, что проверяется без чтения кода Unit-теста. Можно это делать по формуле:

[unit name] ... [should] ... [expected output] ... [when] ... context

Это было рассмотрено детально в части 2.

Заключение

На этом всё! Теперь вы знаете всё о тестировании Flutter-приложений. Надеемся, этот материал принёс вам пользу и новые знания. Хорошего вам кода и легкого тестирования!

Остаемся с вами на связи в Flutter.Много.

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