Перевірки у Dart та Flutter тестах: (майже) найповніша шпаргалка
Усім привіт! Я — Анна, експертка з мобільної розробки, GDE з Dart та Flutter, досвідчена розробниця мобільних додатків на Flutter.
Тести є суттєвими для забезпечення якості програмного забезпечення. Незалежно від того, чи створюєте ви юніт, віджет або інтеграційні тести для тестування Flutter-додатків, кінцевою метою будь-якого тесту є підтвердження того, що реальність збігається з очікуваннями.
У цьому матеріалі ви знайдете (майже) найповнішу шпаргалку з перевірок, доступних у Dart та Flutter тестах з детальними коментарями!
Нижче кожен із пунктів цієї шпаргалки розглядається більш детально. Із загальним підходом до тестування 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!
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів