Перевірки у Dart та Flutter тестах: (майже) найповніша шпаргалка

Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!

Усім привіт! Я — Анна, експертка з мобільної розробки, GDE з Dart та Flutter, досвідчена розробниця мобільних додатків на Flutter.

Тести є суттєвими для забезпечення якості програмного забезпечення. Незалежно від того, чи створюєте ви юніт, віджет або інтеграційні тести для тестування Flutter-додатків, кінцевою метою будь-якого тесту є підтвердження того, що реальність збігається з очікуваннями.

У цьому матеріалі ви знайдете (майже) найповнішу шпаргалку з перевірок, доступних у Dart та Flutter тестах з детальними коментарями!

Read in English

Нижче кожен із пунктів цієї шпаргалки розглядається більш детально. Із загальним підходом до тестування Flutter додатків можна ознайомитися на офіційному сайті.

Всі наведені приклади можна знайти у цьому Flutter проєкті на GitHub.

Expect

expect() це основна функція перевірки якогось твердження в тестах. Подивімося на цей тест:

test('expect: value ✅', () {
  final result = 0;
  expect(result, 0);
});

де result це значення, яке зазвичай надходить із коду, який ми тестуємо.

Тут expect() допомагає нам переконатися, що значення result є 0. Якщо значення буде іншим, функція викине TestFailure ексепшн і тест впаде.

Крім того, expect() виведе опис проблеми. Наприклад, для цього тесту:

test('expect: value ❌', () {
  final result = 1;
  expect(result, 0);
});

побачимо наступний опис:

Expected: <0>
  Actual: <1>

Ось повна сігнатура методу expect():

void expect(
  dynamic actual,       // значення, що перевіряємо
  dynamic matcher, {    // характеризує очікуваний результат
  String? reason,       // додається до опису проблеми
  dynamic skip,         // true або String із причиною пропуску
}) {...}

expect() приймає опціональний параметр reason, який буде додано до опису проблеми. Для цього тесту:

test('expect: reason ❌', () {
  final result = 1;
  expect(result, 0, reason: 'Result should be 0!');
});

опис буде:

Expected: <0>
  Actual: <1>
Result should be 0!

expect() також приймає опціональний параметр skip, що може бути або true або String:

test('expect: skip ✅', () {
  final result = 1;
  expect(result, 0, skip: true);
  expect(result, 0, skip: 'for a reason');
});

Цей тест завершиться успішно, і виведе такий опис:

Skip expect (<0>).
Skip expect: for a reason

Увага! Використання параметра skip не пропускає весь тест, а лише виклик expect(), до якого він застосовується.

Далі ми зосередимося на параметрі matcher методу expect(), і дослідимо, які значення він може приймати.

Матчер

Матчер — це сутність, яка підтверджує, що значення відповідає деяким очікуванням, в залежності від типу матчера. Це може бути або нащадок класу Matcher, або само очікуване значення. Матчер також відповідає за опис проблеми в разі, якщо тест впаде.

Надалі поговоримо про доступні різновиди матчерів.

Матчер equals

У наведеному нижче прикладі ми передаємо 0 як параметр matcher:

test('expect: value ✅', () {
  final result = 0;
  expect(result, 0);
});

У такому випадку, коли передається саме значення, неявно використовується матчер equals. Це еквівалентно:

test('matcher: equals ✅', () {
  final result = 0;
  expect(result, equals(0));
});

Це, мабуть, найбільш часто використовуваний матчер, у явному чи неявному вигляді.

Матчер equals використовує operator == для повірняння обʼєктів. За замовчуванням класи в Dart порівнюються «за посиланням», а не «за значенням». Тобто, якщо equals застосувати до нестандартних об’єктів, як-от цей клас Result:

class Result {
  Result(this.value);

  final int value;
}

то матчер equals зфейлить цей тест:

test('expect: equals ❌', () {
  final result = Result(0);
  expect(result, equals(Result(0)));
});

з наступним описом:

Expected: <Instance of 'Result'>
  Actual: <Instance of 'Result'>

Гарною ідеєю є перевизначення методу .toString(), що зробить опис інформативнішим. Після вдосконалення класу Result:

class Result {
  Result(this.value);

  final int value;

  @override
  String toString() => 'Result{value: $value}';
}

опис проблеми зміниться на:

Expected: Result:<Result{value: 0}>
  Actual: Result:<Result{value: 0}>

Щоби тест пройшовся, клас Result має перевизначити operator ==, наприклад так:

class Result {
  Result(this.value);

  final int value;

  @override
  String toString() => 'Result{value: $value}';

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

  @override
  int get hashCode => value.hashCode;
}

Матчери рівності

Крім матчера equals, який порівнює об’єкти за допомогою operator == і використовується неявно, коли замість матчера передається очікуване значення, існує ще декілька матчерів рівності.

same

Матчер same пересвідчується, що очікуваний та фактичний результат є одним і тим самим екземпляром. Цей тест:

test('expect: same ❌', () {
  final result = Result(1);
  expect(result, same(Result(1)));
});

впаде з наступним описом:

Expected: same instance as Result:<Result{result: 1}>
  Actual: Result:<Result{result: 1}>

Але цей тест пройде успішно:

test('expect: same ✅', () {
  final result = Result(1);
  expect(result, same(result));
});

Цікаве спостереження щодо const. Цей тест також проходить:

test('expect: same ✅', () {
  final result = 1;
  expect(result, same(1));
});

оскільки 1 є константою, і лише один його екземпляр існує в пам’яті. Те саме стосується випадків, коли користувацькі класи мають const конструктори, а екземпляри створюються з використанням модифікатора const. Якщо у конструктор класу Result додати const:

class Result {
  const Result(this.value);

  final int value;
}

цей тест також виконається успішно:

test('expect: same ✅', () {
  final result = const Result(1);
  expect(result, same(const Result(1)));
});

Але цей тест все ще впаде:

test('expect: same ❌', () {
  final result = Result(1);
  expect(result, same(Result(1)));
});

оскільки без використання const створюються два різних екземпляри Result.

null-матчери

Наступна пара матчерів досить проста: isNull і isNotNull перевіряють результат на null. Тест:

test('expect: isNull ❌', () {
  final result = 0;
  expect(result, isNull);
});

впаде з описом:

Expected: null
  Actual: <0>

А цей тест завершиться успішно:

test('expect: isNotNull ✅', () {
  final result = 0;
  expect(result, isNotNull);
});

bool-матчери

Наступна пара матчерів говорить сама за себе: isTrue та isFalse. Ці тести виконуються успішно:

test('expect: isTrue ✅', () {
  final result = 0;
  expect(result < 1, isTrue);
});
test('expect: isFalse ✅', () {
  final result = 0;
  expect(result > 1, isFalse);
});

anything

Матчер anything відповідає буквально будь-якому значенню. Його використано в імплементації any з пакета mockito або any<T>() з mocktail, які ми, можливо, обговоримо згодом. Однак цей матчер не часто використовується у тестах додатків.

Матчери типів

isA

Матчер isA<T> допомагає перевірити тип змінної:

test('expect: isA ❌', () {
  final result = 0;
  expect(result, isA<Result>());
});

Цей тест впаде з наступним описом:

Expected: <Instance of 'Result'>
  Actual: <0>

Матчери відомих типів

Є кілька більш сфокусованих матчерів для верифікації типів: isList і isMap. Ці тести виконуються успішно:

test('expect: isList ✅', () {
  final result = [0];
  expect(result, isList);
});
test('expect: isMap ✅', () {
  final result = {0: Result(0)};
  expect(result, isMap);
});

Власні матчери типів

За допомогою TypeMatcher можна дуже просто створити власний матчер типу:

const isResult = TypeMatcher<Result>();

Оце і все, тепер його можна використовувати в тестах:

test('expect: isResult ✅', () {
  final result = Result(0);
  expect(result, isResult);
});

Матчери помилок

Матчери типів помилок

Матчери типів помилок базуються на тому ж TypeMatcher з прикладу вище, оскільки вони перевіряють тип помилки: isArgumentError, isException, isNoSuchMethodError, isUnimplementedError тощо.

test('expect: isUnimplementedError ✅', () {
  final result = UnimplementedError();
  expect(result, isUnimplementedError);
});

throwsA

Матчер throwsA гарантує, що виклик методу призвів до помилки. Якщо виклик методу має викинути ексепшн, то його небезпечно викликати в тілі тесту. Замість цього його слід викликати всередині виклику expect().

Матчер throwsA приймає інший матчер, який перевіряє помилку, наприклад, один із наведених вище матчерів типів помилок:

test('expect: throwsA ✅', () {
  final result = (int value) => (value as dynamic).length;
  expect(() => result(0), throwsA(isNoSuchMethodError));
});

Матчери колекцій

Під «колекцією» мається на увазі String, Iterable та Map.

Матчери розміру

Пара матчерів isEmpty та isNotEmpty викликає відповідні гетери .isEmpty або .isNotEmpty в обʼєкту result та очікує, що вони повернуть true:

test('expect: isEmpty ✅', () {
  final result = [];
  expect(result, isEmpty);
});
test('expect: isEmpty ❌', () {
  final result = '0';
  expect(result, isEmpty);
});
test('expect: isNotEmpty ✅', () {
  final result = {0: '0'};
  expect(result, isNotEmpty);
});

При використанні цього матчеру з типом, який не має геттерів .isEmpty або .isNotEmpty:

test('expect: isEmpty ❌', () {
  final result = 0;
  expect(result, isNotEmpty);
});

матчери isEmpty та isNotEmpty зфейлять тест з наступним описом:

Expected: non-empty
  Actual: <0>
NoSuchMethodError: Class 'int' has no instance getter 'isNotEmpty'.
Receiver: 0
Tried calling: isNotEmpty

Матчер hasLength діє за тим же принципом і викликає .length геттер на переданому значенні:

test('expect: hasLength ✅', () {
  final result = '0';
  expect(result, hasLength(1));
});

Якщо значення не має геттеру .length:

test('expect: hasLength ❌', () {
  final result = 0;
  expect(result, hasLength(1));
});

тест впаде з наступним описом:

Expected: an object with length of <1>
  Actual: <0>
   Which: has no length property

Матчери змісту

Матчер contains має різну логіку залежно від значення, до якого він застосований.

Для String це означає пошук підрядка:

test('expect: contains ✅', () {
  final result = 'result';
  expect(result, contains('res'));
});

Для Map це означає пошук за ключем:

test('expect: contains ✅', () {
  final result = {0: Result(0)};
  expect(result, contains(0));
});

А для Iterable це означає пошук елемента, що задовольняє матчеру, який було передано у матчер contains. В цьому тесті наведено приклад використання матчеру predicate, а також неявного equals:

test('expect: contains ✅', () {
  final result = [Result(0), Result(1)];
  expect(result, contains(predicate<Result>((r) => r.value == 0)));
  expect(result, contains(Result(1)));
});

Матчер isIn є протилежним до матчеру contains:

test('expect: isIn ✅', () {
  final result = 'res';
  expect(result, isIn('result'));
});
test('expect: isIn ✅', () {
  final result = Result(0);
  expect(result, isIn([Result(0), Result(1)]));
});

String матчери

На додаток до наведених вище матчерів колекцій, які працюють для String, існує ще пара матчерів.

Матчери змісту

Матчери startsWith, endsWith перевіряють початок або кінець рядка:

test('expect: startsWith ✅', () {
  final result = 'result';
  expect(result, startsWith('res'));
});
test('expect: endsWith ✅', () {
  final result = 'result';
  expect(result, endsWith('ult'));
});

matches

Матчер matches може приймати інший рядок:

test('expect: matches ✅', () {
  final result = 'result';
  expect(result, matches('esul'));
});

або регулярку:

test('expect: matches ✅', () {
  final result = 'result';
  expect(result, matches(RegExp('r[a-z]{4}t')));
});

Iterable матчери

На додаток до наведених вище матчерів колекцій, які працюють для List та Set, існує ще пара матчерів.

Кожен і будь-який

Матчери everyElement та anyElement перевіряють, що всі або деякі елементи списку задовольняють матчеру або дорівнюють значенню, яке вони прийняли як параметр:

test('expect: everyElement ✅', () {
  final result = [Result(0), Result(1)];
  expect(result, everyElement(isResult));
});
test('expect: anyElement ✅', () {
  final result = {0, 1};
  expect(result, anyElement(0));
}); 

Матчери змісту

Матчери containsAll та containsAllInOrder перевіряють, що Iterable, переданий як параметр, є підмножиною фактичного Iterable, опціонально перевіряючи порядок елементів:

test('expect: containsAll ✅', () {
  final result = [Result(0), Result(1), Result(2)];
  expect(result, containsAll([Result(1), Result(0)]));
});
test('expect: containsAllInOrder ✅', () {
  final result = {0, 1, 2};
  expect(result, containsAllInOrder({0, 1}));
});

Фактичний Iterable може мати додаткові елементи.

Матчери orderedEquals та unorderedEquals перевіряють, чи фактичний Iterable має ту саму довжину та містить ті самі елементи, що й переданий Iterable, опціонально перевіряючи порядок елементів:

test('expect: orderedEquals ✅', () {
  final result = [Result(0), Result(1)];
  expect(result, orderedEquals([Result(0), Result(1)]));
});
test('expect: unorderedEquals ✅', () {
  final result = {0, 1};
  expect(result, unorderedEquals({1, 0}));
});

Map матчери

На додаток до наведених вище матчерів колекцій, які працюють для Map, існує ще пара матчерів.

Матчер containsValue перевіряє, що метод .containsValue повертає true:

test('expect: containsValue ✅', () {
  final result = {0: Result(0)};
  expect(result, containsValue(Result(0)));
});

Матчер containsPair пересвідчується, що мапа містить такий ключ і таке значення, де значення також може бути іншим матчером:

test('expect: containsPair ✅', () {
  final result = {0: Result(0)};
  expect(result, containsPair(0, isResult));
  expect(result, containsPair(0, Result(0)));
});

Числові матчери

Матчери, орієнтовані на 0

isZero, isNonZero, isPositive, isNonPositive, isNegative, isNonNegative матчери перевіряють відношення значення до 0:

test('expect: isZero ✅', () {
  final result = 0;
  expect(result, isZero);
});
test('expect: isZero ✅', () {
  final result = 1;
  expect(result, isPositive);
});

Матчери діапазону

Матчери inInclusiveRange, inExclusiveRange перевіряють, що num значення входить у переданий діапазон:

test('expect: inInclusiveRange ✅', () {
  final result = 1;
  expect(result, inInclusiveRange(0, 1));
});
test('expect: inExclusiveRange ❌', () {
  final result = 1;
  expect(result, inExclusiveRange(0, 1));
});

Матчери порівняння

Матчери greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo використовують operator ==, operator <, та operator > для порівння очікуваного і фактичного значень:

test('expect: greaterThan ✅', () {
  final result = 1;
  expect(result, greaterThan(0));
});
test('expect: lessThanOrEqualTo ✅', () {
  final result = 1;
  expect(result, lessThanOrEqualTo(1));
});

Їх можна застосовувати не тільки до числових значень, а й до власних класів. Щоб використовувати їх із класом Result, його потрібно доповнити реалізаціями операторів operator < та operator >:

class Result {
  const Result(this.value);

  final int value;

  ...

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

  bool operator >(Object other) =>
      other is Result &&
          value > other.value;

  bool operator <(Object other) =>
      other is Result &&
          value < other.value;
}

Як бачите, об’єкти Result порівнюються за внутрішнім полем value. Тепер ці тести також проходять:

test('expect: greaterThan ✅', () {
  final result = Result(1);
  expect(result, greaterThan(Result(0)));
});
test('expect: lessThanOrEqualTo ✅', () {
  final result = Result(1);
  expect(result, lessThanOrEqualTo(Result(1)));
});

Універсальний матчер

Взагалі, більшість типів перевірок, які розробнику коли-небудь може знадобитися виконати в методах expect(), можна виразити одним матчером predicate. Він приймає предикат — функцію з одним параметром, яка повертає bool, де можна пересвідчитись, що параметр відповідає очікуванням. Наприклад:

test('expect: predicate ✅', () {
  final result = Result(0);
  expect(result, predicate((e) => e is Result && e.value == 0));
  expect(result, predicate<Result>((result) => result.value == 0));
});

Залежно від типу необхідної перевірки, predicate може бути саме тим матчером, який вам потрібен. Але є купа більш вузько спеціалізованих матчерів, які забезпечують більш читабельний код і опис. Давайте порівняємо.

Тест з матчером predicate:

test('expect: predicate ❌', () {
  final result = 1;
  expect(result, predicate((e) => e == 0));
});

виводить наступний опис:

Expected: satisfies function
  Actual: <1>

Його можна покращити за допомогою параметра description. Цей тест:

test('expect: predicate ❌', () {
  final result = 1;
  expect(result, predicate((e) => e == 0, 'Result should be 0!'));
});

виводить:

Expected: Result should be 0!
  Actual: <1>

В той же час матчер equals:

test('expect: equals ❌', () {
  final result = 1;
  expect(result, equals(0));
});

надає більше інформації про очікуваний результат із меншим обʼємом коду:

Expected: <0>
  Actual: <1>

Завжди віддавайте перевагу використанню сфокусованих матчерів, якщо такі доступні.

Власні матчери

Якщо ви не знайшли матчер, який би задовольнив ваші вимоги, ви можете створити свій власний матчер.

Для прикладу, створімо матчер, який перевіряє значення поля value. Для цього нам потрібен нащадок класу CustomMatcher:

class HasValue extends CustomMatcher {
  HasValue(Object? valueOrMatcher)
      : super(
          'an object with value field of',
          'value field',
          valueOrMatcher,
        );

  @override
  Object? featureValueOf(dynamic actual) => actual.value;
}

Клас HasValue наслідує CustomMatcher і приймає один параметр, який може бути власне значенням або іншим матчером. Він викликає батьківський конструктор із назвою фічі та описом, який буде використано, якщо тест не пройде.

Він також перевизначає метод featureValueOf, який намагається отримати значення поля value фактичного об’єкта, переданого в expect(). Передбачається, що він працює з будь-яким типом, який має поле value, як-от клас Result. У випадку, якщо actual не має такого поля, ця реалізація featureValueOf викине ексепшн, але базовий клас CustomMatcher викликає його всередині блоку try / catch, тому зможе коректно зафейлити тест.

Слідуючи загально узгодженій практиці оголошення матчерів, додамо метод для створення матчеру hasValue:

Matcher hasValue(Object? valueOrMatcher) => HasValue(valueOrMatcher);

Тепер його можна використовувати будь-яким із цих способів:

test('expect: hasValue ✅', () {
  final result = Result(0);
  expect(result, hasValue(0));
  expect(result, HasValue(0));
  expect(result, hasValue(equals(0)));
});

Зауважте, що матчер hasValue може приймати як 0, так і equals(0). Фактично, він може прийняти будь-який інший матчер:

test('expect: hasValue ✅', () {
  final result = Result(0);
  expect(result, hasValue(isZero));
  expect(result, hasValue(lessThan(1)));
});

У разі зафейленого тесту:

test('expect: hasValue ❌', () {
  final result = Result(1);
  expect(result, hasValue(0));
});

опис міститиме назву фічі та опис, передані до конструктору CustomMatcher:

Expected: an object with value property of <0>
  Actual: Result:<Result{result: 1}>
   Which: has value property with value <1>

Операції над матчерами

allOf

Матчер allOf дозволяє об’єднати кілька матчерів і перевіряє, що всі вони задоволені. Його можна використовувати з масивом матчерів, або передавши до семи окремих матчерів:

test('expect: allOf ✅', () {
  final result = Result(0);
  expect(result, allOf(hasValue(0), isResult));
  expect(result, allOf([hasValue(0), isResult]));
});

В разі невідповідності:

test('expect: allOf ❌', () {
  final result = Result(0);
  expect(result, allOf([hasLength(1), hasValue(1)]));
});

опис міститиме помилку з першого незадоволеного матчеру:

Expected: (an object with length of <1> and an object with value property of <1>)
  Actual: Result:<Result{result: 0}>
   Which: has no length property

anyOf

Матчер anyOf також приймає масив матчерів або до семи окремих матчерів і перевіряє, що принаймні один із них задоволений:

test('expect: anyOf ✅', () {
  final result = Result(0);
  expect(result, anyOf(hasLength(1), hasValue(0)));
  expect(result, anyOf([hasLength(1), hasValue(0)]));
});

Не дивлячись на те, що матчер hasLength не задоволений, тест пройдеться успішно.

isNot

Матчер isNot викликає матчер, що передано в параметрі, і інвертує його результат:

test('expect: isNot ✅', () {
  final result = 0;
  expect(result, isNot(1));
  expect(result, isNot(isResult));
  expect(result, isNot(allOf(isResult, hasValue(0))));
});

Запамʼятати все

Усі згадані матчери можна знайти в пакеті matcher.

Маючи таку кількість матчерів, може бути важко їх усі запам’ятати. Окрім шпаргалки, яку ви можете завжди мати під рукою, було б набагато простіше, якби всі матчери належали до одного класу, наприклад Matcher. У цьому випадку ми могли б просто написати Matcher., викликати підказку коду та вибрати потрібний матчер зі списку. Це навряд чи коли-небудь буде реалізовано, але є вихід, який дає майже аналогічний результат.

Підключення import 'package:flutter_test/flutter_test.dart'; або import 'package:test/test.dart'; неявно надає доступ до всіх матчерів з пакета matcher. Однак, якщо підключити його явно та надати йому значущої назви, наприклад match, можна користуватись доповненням коду після введення match.:

Післямова

У цій темі ще є багато чого розглянути, включаючи асинхронні матчери, матчери Flutter-віджетів тощо. Слідкуйте за оновленнями та підписуйтесь на мене у Twitter, YouTube, Medium!

👍ПодобаєтьсяСподобалось15
До обраногоВ обраному10
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Доволі корисна шпаргалка)

Дякую!

Дуже дякую за статтю!
Гарне оформлення!

Дякую за відгук, Дмитре! Такі коменти надихають.

Підписатись на коментарі