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

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

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

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

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

Read in English

Попередня публікація охоплювала значну частину перевірок, які можна виконати в тестах Flutter і Dart, і докладно пояснювала більше половини цієї шпаргалки. Тут ми зосередимось на нижній половині, починаючи з асинхронних матчерів.

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

Асинхронний Expect

Функція expectLater() подібна до expect(), але повертає Future, що завершиться, коли матчер завершить виконання.

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

І expect() і expectLater() не дадуть тесту завершитися, поки матчер не завершить виконання, але expectLater() дає можливість використати await і додати ще код після її виклику.

Хоча expectLater() може приймати будь-який матчер, логічно передавати їй саме нащадків класу AsyncMatcher, який виконує асинхронні обчислення.

Future матчери

Є кілька матчерів для перевірки результатів виконання Future.

Матчер completes завершується успішно, коли Future завершується успішно з будь-яким значенням.

test('expectLater: completes ✅', () async {
  final result = Future.value(0);
  await expectLater(result, completes);
});

Матчер completion приймає інший матчер для перевірки результату Future:

test('expectLater: completion ✅', () async {
  final result = Future.value(0);
  await expectLater(result, completion(isZero));
});

А матчер throwsA має вже бути вам знайомим:

test('expectLater: throwsA ✅', () async {
  final result = Future.error(Exception());
  await expectLater(result, throwsA(isException));
});

Stream матчери

З початку протестуємо стріми із захаркодженими значеннями, щоб познайомитися із різноманіттям відповідних матчерів. А потім поговоримо про тестування стрімів у випадках, коли використовувати функцію expect() занадто пізно.

emits / neverEmits

Матчер emits перевіряє чи Stream видав значення, яке задовольняє іншому матчеру, який emits прийняв як параметр. Він може приймати очікуване значення, інший матчер, який характеризує очікуване значення, або функцію-предикат:

test('expect: emits ✅', () {
  final stream = Stream.fromIterable([0]);
  expect(stream, emits(0));
  expect(stream, emits(isZero));
  expect(stream, emits((value) => value == 0));
  expect(stream, emits(predicate<int>((value) => value == 0)));
});

Матчер neverEmits виконує протилежну перевірку:

test('expect: neverEmits ✅', () {
  final stream = Stream.fromIterable([1]);
  expect(stream, neverEmits(0));
  expect(stream, neverEmits(isZero));
  expect(stream, neverEmits((value) => value == 0));
  expect(stream, neverEmits(predicate<int>((value) => value == 0)));
});

emitsInOrder / emitsInAnyOrder

Ці матчери гарантують, що Stream видав декілька значень.

У певному порядку:

test('expect: emitsInOrder ✅', () {
  final stream = Stream.fromIterable([0, 1]);
  expect(stream, emitsInOrder([isZero, 1]));
});

Або у будь-якому порядку:

test('expect: emitsInAnyOrder ✅', () {
  final stream = Stream.fromIterable([Result(0), Result(1)]);
  expect(stream, emitsInAnyOrder([hasValue(1), Result(0)]));
});

Як бачите, обидва матчери приймають масив, що містить очікувані значення або матчери.

emitsDone

Матчер emitsDone допомагає гарантувати, що Stream більше не видасть неочікуваних значень:

test('expect: emitsDone ✅', () {
  final stream = Stream.empty();
  expect(stream, emitsDone);
});
test('expect: emitsDone ✅', () {
  final stream = Stream.value(0);
  expect(stream, emitsInOrder([0, emitsDone]));
});

emitsError

Матчер emitsError допомагає переконатися, що Stream видав помилку, і приймає інший матчер для перевірки помилки:

test('expect: emitsError ✅', () {
  final stream = Stream.error(UnimplementedError());
  expect(stream, emitsError(isUnimplementedError));
});

Тестування закритих / вже оброблених стрімів

Поки що ми тестували тільки стріми, що містили захардкоджені значення, які оброблялися прямо всередині функції expect(). Але уявіть, що ми маємо протестувати стрім, який уже був закритий, або стрім, який уже видав значення, які нас цікавили.

Розглянемо цей клас:

class StreamExample {
  final _streamController = StreamController<int>.broadcast();

  void doWork() {
    _streamController.add(0);
    _streamController.add(1);
  }

  Stream<int> get stream => _streamController.stream;
}

Коли викликається метод doWork, стрім має видати два значення: 0 і 1. Ось тест, який спадає на думку для такої поведінки:

test('expect: drained stream ❌', () async {
  final streamExample = StreamExample();
  streamExample.doWork();
  expect(streamExample.stream, emitsInOrder([0, 1]));
});

На жаль, функція expect() викликається надто пізно, значення вже видано, і цей тест ніколи не завершується. Замість цього, expect() або expectLater() слід використати перед викликом doWork().

На відміну від використання функції expectLater() із Future матчерами (де вона розміщується в кінці тесту та ми чекаємо на результат її виконання з await), для тестування StreamMatcher її слід розміщувати перед виконанням викликів, які впливають на значення стріма. Саме таким чином ми можемо ловити значення, що видає стрім. У цьому випадку не слід чекати завершення виклику expectLater() з await, інакше тест також не завершиться.

test('expectLater: drained stream ✅', () async {
  final streamExample = StreamExample();
  expectLater(streamExample.stream, emitsInOrder([0, 1]));
  streamExample.doWork();
});

Матчери Flutter віджетів

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

Матчер для 0 / 1 / N віджетів

Ця група матчерів використовується у віджет-тестах найчастіше.

findsNothing гарантує, що жоден віджет у дереві віджетів не відповідає першому параметру функції expect():

testWidgets('widget test: findsNothing ✅', (tester) async {
  final content = Text('1');

  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));

  expect(find.text('0'), findsNothing);
});

Матчери findsWidgets / findsOneWidget перевіряють присутність принаймні одного / точно одного віджета в дереві:

testWidgets('widget test: findsWidgets ✅', (tester) async {
  final content = Column(children: [Text('1'), Text('1')]);

  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));

  expect(find.text('1'), findsWidgets);
});
testWidgets('widget test: findsOneWidget ❌', (tester) async {
  final content = Column(children: [Text('1'), Text('1')]);

  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));

  expect(find.text('1'), findsOneWidget);
});

У наведеному вище прикладі матчер findsOneWidget зафейлив тест, оскільки дерево віджетів містить два віджети з текстом «1». Опис результату тесту:

Expected: exactly one matching node in the widget tree
  Actual: _TextFinder:<2 widgets with text "1" (ignoring offstage widgets): [
		Text("1", dependencies:[DefaultSelectionStyle, DefaultTextStyle, MediaQuery]), 
		Text("1", dependencies:[DefaultSelectionStyle, DefaultTextStyle, MediaQuery])]>
   Which: is too many

Матчери findsAtLeastNWidgets / findsNWidgets перевіряють присутність принаймні N / рівно N віджетів в дереві:

testWidgets('widget test: findsAtLeastNWidgets ✅', (tester) async {
  final content = Column(children: [Text('1'), Text('1')]);

  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));

  expect(find.text('1'), findsAtLeastNWidgets(2));
});
testWidgets('widget test: findsNWidgets ✅', (tester) async {
  final content = Column(children: [Text('1'), Text('1')]);

  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));

  expect(find.text('1'), findsNWidgets(2));
});

Матчер кольору

Матчер isSameColorAs допомагає перевірити значення властивостей будь-якого віджета з типом Color:

testWidgets('widget test: isSameColorAs ✅', (tester) async {
  final content = Container(color: Colors.green);

  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));

  expect(
    tester.widget<Container>(find.byType(Container)).color,
    isSameColorAs(Colors.green),
  );
});
testWidgets('widget test: isSameColorAs ✅', (tester) async {
  final content = Text('1', style: TextStyle(color: Colors.green));

  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));

  expect(
    tester.widget<Text>(find.text('1')).style!.color,
    isSameColorAs(Colors.green),
  );
});

Матчери контейнерів

Матчери isInCard / isNotInCard допомагають перевірити, чи знаходиться віджет у Card або ні:

testWidgets('widget test: isInCard ✅', (tester) async {
  final content = Card(child: Text('1'));

  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));

  expect(find.text('1'), isInCard);
});
testWidgets('widget test: isNotInCard ✅', (tester) async {
  final content = Text('1');

  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));

  expect(find.text('1'), isNotInCard);
});

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

Ми всі знаємо, що віджет Container не може одночасно приймати параметри color та decoration через такий assert в його конструкторі:

assert(color == null || decoration == null,
   'Cannot provide both a color and a decoration\n'
   'To provide both, use "decoration: BoxDecoration(color: color)".',
 ),

Якщо ви використовуєте подібний підхід під час реалізації своїх віджетів, матчер throwsAssertionError може допомогти з тестуванням таких assert:

testWidgets('widget test: throwsAssertionError ✅', (tester) async {
  final builder = () => Container(color: Colors.red, decoration: BoxDecoration(color: Colors.red));

  expect(() => builder(), throwsAssertionError);
});

Оскільки throwsAssertionError під капотом використовує матчер throwsA, той самий принцип застосовується і тут: функція, яка має викинути ексепшн, має бути викликана всередині функції expect().

Матчер throwsFlutterError перевіряє, що функція викидає FlutterError. Ось приклад з тестів самого Flutter фреймворку:

testWidgets('widget test: throwsFlutterError ✅', (tester) async {
  final testKey = GlobalKey<NavigatorState>();

  await tester.pumpWidget(SizedBox(key: testKey));

  expect(() => Navigator.of(testKey.currentContext!), throwsFlutterError);
});

Аксесабіліті матчери

Існує лише одна пара аксесабіліті матчерів: meetsGuideline / doesNotMeetGuideline. Ці матчери є асинхронними, тому їх слід використовувати з функцією expectLater, згаданою вище.

Вони приймають об’єкт AccessibilityGuideline, який представляє тип аксесабіліті перевірки. Фреймворк вже пропонує кілька таких обʼєктів для типових перевірок:

  • androidTapTargetGuideline перевіряє, що частини UI, які мають реагувати на дотик, мають мінімальний розмір 48 на 48 пікселів;
  • iOSTapTargetGuideline перевіряє, що частини UI, які мають реагувати на дотик, мають мінімальний розмір 44 на 44 пікселів;
  • textContrastGuideline містить вказівки щодо вимог до контрастності тексту, визначених WCAG;
  • labeledTapTargetGuideline перевіряє, що частини UI, які мають реагувати на дотик або тривале натискання, мають аксесабіліті опис.

Можна створити свої власні аксесабіліті матчери наслідуючи AccessibilityGuideline клас або створюючи власні екземпляри MinimumTapTargetGuideline, MinimumTextContrastGuideline, LabeledTapTargetGuideline.

Погляньмо на цей приклад:

testWidgets('meetsGuideline: iOSTapTargetGuideline ✅', (tester) async {
  final handle = tester.ensureSemantics();
  final content = SizedBox.square(
    dimension: 46.0,
    child: GestureDetector(onTap: () {}),
  );
  await tester.pumpWidget(MaterialApp(home: Center(child: content)));
  await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
  handle.dispose();
});

Віджет GestureDetector матиме розмір 46×46. Цього достатньо, щоб задовольнити вимоги iOSTapTargetGuideline, який вимагає розмір зони дотику 44×44. Аналогічний тест, що використовує androidTapTargetGuideline, який вимагає область дотику розміром 48×48, фейлиться:

testWidgets('meetsGuideline: androidTapTargetGuideline ❌', (tester) async {
  final handle = tester.ensureSemantics();
  final content = SizedBox.square(
    dimension: 46.0,
    child: GestureDetector(onTap: () {}),
  );
  await tester.pumpWidget(MaterialApp(home: Center(child: content)));
  await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
  handle.dispose();
});

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

Expected: Tappable objects should be at least Size(48.0, 48.0)
  Actual: <Instance of 'WidgetTester'>
   Which: SemanticsNode(Rect.fromLTRB(377.0, 277.0, 423.0, 323.0), actions: [tap]): 
     expected tap target size of at least Size(48.0, 48.0), but found Size(46.0, 46.0)

Подивімось на наступний приклад з textContrastGuideline:

testWidgets('meetsGuideline: textContrastGuideline ✅', (tester) async {
  final handle = tester.ensureSemantics();
  final content = Container(
    color: Colors.white,
    child: Text(
      'Text contrast test', 
      style: TextStyle(color: Colors.black),
    ),
  );
  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));
  await expectLater(tester, meetsGuideline(textContrastGuideline));
  handle.dispose();
});

Чорний текст на білому тлі має достатній коефіцієнт контрастності, тому цей тест успішно проходить. Однак дрібний помаранчевий текст на білому фоні важко прочитати, і цей тест вже не проходить:

testWidgets('meetsGuideline: textContrastGuideline ❌', (tester) async {
  final handle = tester.ensureSemantics();
  final content = Container(
    color: Colors.white,
    child: Text(
      'Text contrast test', 
      style: TextStyle(color: Colors.deepOrange),
    ),
  );
  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));
  await expectLater(tester, meetsGuideline(textContrastGuideline));
  handle.dispose();
});

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

Expected: Text contrast should follow WCAG guidelines
  Actual: <Instance of 'WidgetTester'>
   Which: SemanticsNode(Rect.fromLTRB(0.0, 0.0, 14.0, 14.0), label: "Text contrast test", textDirection: ltr):
     Expected contrast ratio of at least 4.5 but found 3.03 for a font size of 14.0.

Як бачите, в описі зазначено, що очікуваний коефіцієнт контрастності залежить від розміру шрифту. У наведеному вище прикладі, коли стиль тексту не було надано ані у Text віджеті, ані в MaterialApp, було застосовано стандартний розмір тексту 14. Цікаво, що якщо шрифт тексту збільшити, той самий тест проходить, оскільки більші тексти легше читати, навіть якщо коефіцієнт контрастності не ідеальний:

testWidgets('meetsGuideline: textContrastGuideline ✅', (tester) async {
  final handle = tester.ensureSemantics();
  final content = Container(
    color: Colors.white,
    child: Text(
      'Text contrast test', 
      style: TextStyle(
        fontSize: 20, 
        color: Colors.deepOrange,
      ),
    ),
  );
  await tester.pumpWidget(MaterialApp(home: Scaffold(body: content)));
  await expectLater(tester, meetsGuideline(textContrastGuideline));
  handle.dispose();
});

Матчери зображень

Матчер для файлу зображення

Матчер matchesGoldenFile дозволяє підтвердити відповідність Finder, Future<ui.Image>, або ui.Image еталонному (golden) файлу зображення:

testWidgets('expectLater: matchesGoldenFile ✅', (tester) async {
  final widget = Container(
    width: 200, height: 200,
    padding: EdgeInsets.all(20),
    color: Colors.white,
    child: ColoredBox(color: Colors.blue),
  );
  await tester.pumpWidget(widget);
  await expectLater(find.byType(Container), matchesGoldenFile('golden_test.png'));
});

Матчер для обʼєкту зображення

Матчер matchesReferenceImage дозволяє підтвердити, що Finder, Future<ui.Image>, або ui.Image відповідає еталонному об’єкту ui.Image:

import 'dart:ui' as ui;

testWidgets('expectLater: matchesReferenceImage ✅', (tester) async {
  final key = UniqueKey();
  final widget = Container(
      width: 200, height: 200,
      padding: EdgeInsets.all(20),
      child: ColoredBox(color: Colors.green),
    );
  final image = await createTestImage();
  await tester.pumpWidget(
    Center(
      child: RepaintBoundary(
        key: key,
        child: widget,
      ),
    ),
  );
  await expectLater(find.byKey(key), matchesReferenceImage(image));
});

Future<ui.Image> createTestImage() {
  final paint = ui.Paint()
    ..style = ui.PaintingStyle.fill
    ..color = Colors.green;
  final recorder = ui.PictureRecorder();
  final pictureCanvas = ui.Canvas(recorder);
  pictureCanvas.drawRect(Rect.fromLTWH(20, 20, 160, 160), paint);
  final picture = recorder.endRecording();
  return picture.toImage(200, 200);
}

Для тестування Finder, як у тесті вище, варто враховувати, що:

  • має знайтися точно один віджет, що відповідатиме Finder;
  • перший RepaintBoundary предок цього віджета стане джерелом для зображення, яке буде порівнюватися з еталонним зображенням.

Тестування викликів та параметрів

Досі ми використовували функцію expect() для порівняння результатів деяких операцій із очікуваними значеннями. Існує ще одна категорія перевірок, які постійно виконують розробники: переконатися, що викликано якийсь побічний ефект.

Тестування викликів

Для цього використовується функція verify():

test('verify ❌', () {
  final mock = MockService();
  verify(() => mock.sideEffect(0));
});

Ця функція походить із пакета mocktail. Пакет mockito надає схожий функціонал.

Ось визначення класу Service та моку MockService:

abstract class Service { 
  void sideEffect(int value, {Result? result});
}

class MockService extends Mock implements Service {}

Функція verify() гарантує, що вказаний метод був викликаний принаймні один раз. Наведений вище тест не проходить, тому що метод sideEffect() ніколи не викликався, і має наступний опис:

No matching calls (actually, no calls at all).

Для того, щоб пересвідчитися, що певний метод був викликаний рівно N разів, у результату функції verify() існує метод called():

test('verify ❌', () {
  final mock = MockService();
  verify(() => mock.sideEffect(0)).called(1);
});

Щоб пересвідчитися, що певний метод ніколи не був викликаний, існує функція verifyNever():

test('verifyNever ✅', () {
  final mock = MockService();
  verifyNever(() => mock.sideEffect(0));
});

Тестування параметрів

У наведеному вище прикладі метод sideEffect() приймає два параметри. Якщо їх значення не важливі, можна використати виклик any(), який може представляти буквально будь-яке значення:

test('verify: any ✅', () {
  final mock = MockService()..sideEffect(0, result: Result(0));
  verify(() => mock.sideEffect(any(), result: any(named: 'result')));
});

Якщо параметри виклику важливі, то замість any() можна вказати реальні значення:

test('verify: any ✅', () {
  final mock = MockService()..sideEffect(0, result: Result(0));
  verify(() => mock.sideEffect(0, result: Result(0)));
});

Подібно до матчера equals, verify порівнює параметри за допомогою оператора рівності. Таким чином, щоб вищенаведений тест пройшов, клас Result повинен мати перевизначений operator ==, як було показано раніше. В протилежному випадку тест завершується невдачею з таким описом:

No matching calls. 
All calls: MockService.sideEffect(0, {result: Result{value: 0}})

Якщо важливо лише перевірити, чи відповідають ці параметри певній умові, виклик any() може прийняти один із матчерів, які ми так уважно розглядали раніше:

test('verify: any that ✅', () {
  final mock = MockService()..sideEffect(0, result: Result(0));
  verify(() => mock.sideEffect(
      any(that: isZero),
      result: any(named: 'result', that: allOf(isResult, hasValue(0))),
    ));
});

Ця публікація завершує тему перевірок, доступних у Dart і Flutter тестах.

Підписуйтесь на мене у Twitter, YouTube, Medium!

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному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

Бачив цей cheatsheet ще в твітері, гарна робота!

Дякую за відгук, Радіоне!

Щойно помітила свою помилку 🤦🏼‍♀️ Вибачте, Родіоне 🙂

Хмм, не вмію у Dart, але ви молодець ;) зайшов чисто глянути що воно таке :)

Дякую 🙂 Заходьте частіше!

Дякую Вам дуже!!!

Дякую за зворотній зв’язок, Вадиме!

Дякую, чудова коллекція

Дякую за відгук, Дмитро!

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