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