expectAccessibleButton function

Future<void> expectAccessibleButton(
  1. WidgetTester tester,
  2. Finder finder, {
  3. int pressCount()?,
  4. bool checkContrast = true,
})

Checks that the button finder finds can be used by everyone, and fails the test with an explanation if not.

It checks that:

If pressCount is given, it must return how many times the button's action has run. The helper then also checks that a quick double tap and a held Enter key each run the action only once. For a button with a plain onPressed, that needs a cooldown. Use it on buttons whose action keeps the button on screen, rather than opening a page or dialog.

testWidgets('the pay button is accessible', (tester) async {
  var payments = 0;
  await tester.pumpWidget(MyCheckout(onPay: () async => payments++));
  await expectAccessibleButton(
    tester,
    find.widgetWithText(BouncyButton, 'Pay'),
    pressCount: () => payments,
  );
});

The guidelines look at the whole screen, not only this button, so a problem elsewhere on the screen also fails the test.

Implementation

Future<void> expectAccessibleButton(
  WidgetTester tester,
  Finder finder, {
  int Function()? pressCount,
  bool checkContrast = true,
}) async {
  final handle = tester.ensureSemantics();
  try {
    await tester.pump();
    expect(finder, findsOneWidget, reason: 'The finder must find one button.');

    final node = _buttonNode(tester, finder);
    expect(
      node,
      isNotNull,
      reason: 'Screen readers should announce it as a button, but found no '
          'button semantics in or around it.',
    );
    final data = node!.getSemanticsData();
    expect(
      data.label.trim().isNotEmpty || data.tooltip.trim().isNotEmpty,
      isTrue,
      reason: 'Screen readers have no label to read for this button. Give it '
          'text, a semanticLabel, or a tooltip.',
    );

    await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
    await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
    await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
    if (checkContrast) {
      await expectLater(tester, meetsGuideline(textContrastGuideline));
    }

    if (pressCount == null) {
      return;
    }

    // A quick double tap, as people often do out of habit.
    var before = pressCount();
    await tester.tap(finder, warnIfMissed: false);
    await tester.pump(const Duration(milliseconds: 50));
    await tester.tap(finder, warnIfMissed: false);
    await tester.pumpAndSettle();
    final afterTaps = pressCount() - before;
    expect(
      afterTaps,
      1,
      reason: 'A quick double tap ran the action $afterTaps times. Add a '
          'cooldown, or use onPressedAsync, which ignores taps while it runs.',
    );

    // A held Enter key, which sends repeat events.
    final focusNode = _focusNodeOf(tester, finder);
    focusNode.requestFocus();
    await tester.pump();
    before = pressCount();
    await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
    for (var i = 0; i < 3; i++) {
      await tester.sendKeyRepeatEvent(LogicalKeyboardKey.enter);
    }
    await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
    await tester.pumpAndSettle();
    final afterKey = pressCount() - before;
    focusNode.unfocus();
    await tester.pumpAndSettle();
    expect(
      afterKey,
      1,
      reason: 'Holding Enter on the focused button ran the action $afterKey '
          'times, but it should run once.',
    );
  } finally {
    handle.dispose();
  }
}