From 37d5dc45d121b93f227aad217b3cb2eb2478ad3a Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 8 Oct 2024 13:27:32 -0400 Subject: [PATCH] Add `bySemanticsIdentifier` finder for finding by identifier (#155571) ## Add `bySemanticsIdentifier` finder for finding by identifier ### Description This pull request introduces a new finder, `CommonFinders.bySemanticsIdentifier`, to the Flutter testing framework. This finder allows developers to locate `Semantics` widgets based on their `identifier` property, enhancing the precision and flexibility of widget tests. ### Motivation Establish a consistent and reliable method for locating elements in integration and end-to-end (e2e) tests. Unlike `label` or `key`, which may carry functional significance within the application, the `identifier` is purely declarative and does not impact functionality. Utilizing the `identifier` for finding semantics widgets ensures that tests can target specific elements without interfering with the app's behavior, thereby enhancing test reliability, maintainability, and reusability across testing frameworks. ### Changes - **semantics.dart** - Updated documentation to mention that `identifier` can be matched using `CommonFinders.bySemanticsIdentifier`. - **finders.dart** - Added the `bySemanticsIdentifier` method to `CommonFinders`. - Supports both exact string matches and regular expression patterns. - Includes error handling to ensure semantics are enabled during tests. - **finders_test.dart** - Added tests to verify that `bySemanticsIdentifier` correctly finds widgets by exact identifier and regex patterns. - Ensures that the finder behaves as expected when semantics are not enabled. ### Usage Developers can use the new finder in their tests as follows: ```dart // Exact match expect(find.bySemanticsIdentifier('Back'), findsOneWidget); // Regular expression match expect(find.bySemanticsIdentifier(RegExp(r'^item-')), findsNWidgets(2)); ``` --- .../flutter/lib/src/semantics/semantics.dart | 3 +- packages/flutter_test/lib/src/finders.dart | 60 +++++++++++++++---- packages/flutter_test/test/finders_test.dart | 49 +++++++++++++++ 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index e38c513c14f..00729829fba 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -1213,7 +1213,8 @@ class SemanticsProperties extends DiagnosticableTree { /// This value is not exposed to the users of the app. /// /// It's usually used for UI testing with tools that work by querying the - /// native accessibility, like UIAutomator, XCUITest, or Appium. + /// native accessibility, like UIAutomator, XCUITest, or Appium. It can be + /// matched with [CommonFinders.bySemanticsIdentifier]. /// /// On Android, this is used for `AccessibilityNodeInfo.setViewIdResourceName`. /// It'll be appear in accessibility hierarchy as `resource-id`. diff --git a/packages/flutter_test/lib/src/finders.dart b/packages/flutter_test/lib/src/finders.dart index d543571b0ec..f2d2f0861f2 100644 --- a/packages/flutter_test/lib/src/finders.dart +++ b/packages/flutter_test/lib/src/finders.dart @@ -470,8 +470,8 @@ class CommonFinders { /// Finds a standard "back" button. /// - /// A common element on many user interfaces is the "back" button. This is the - /// button which takes the user back to the previous page/screen/state. + /// A common element on many user interfaces is the "back" button. This is + /// the button which takes the user back to the previous page/screen/state. /// /// It is useful in tests to be able to find these buttons, both for tapping /// them or verifying their existence, but because different platforms and @@ -555,11 +555,49 @@ class CommonFinders { /// /// If the `skipOffstage` argument is true (the default), then this skips /// nodes that are [Offstage] or that are from inactive [Route]s. - Finder bySemanticsLabel(Pattern label, { bool skipOffstage = true }) { + Finder bySemanticsLabel(Pattern label, {bool skipOffstage = true}) { + return _bySemanticsProperty( + label, + (SemanticsNode? semantics) => semantics?.label, + skipOffstage: skipOffstage, + ); + } + + /// Finds [Semantics] widgets matching the given `identifier`, either by + /// [RegExp.hasMatch] or string equality. + /// + /// This allows matching against the identifier of a [Semantics] widget, which + /// is a unique identifier for the widget in the semantics tree. This is + /// exposed to offer a unified way widget tests and e2e tests can match + /// against a [Semantics] widget. + /// + /// ## Sample code + /// + /// ```dart + /// expect(find.bySemanticsIdentifier('Back'), findsOneWidget); + /// ``` + /// + /// If the `skipOffstage` argument is true (the default), then this skips + /// nodes that are [Offstage] or that are from inactive [Route]s. + Finder bySemanticsIdentifier(Pattern identifier, {bool skipOffstage = true}) { + return _bySemanticsProperty( + identifier, + (SemanticsNode? semantics) => semantics?.identifier, + skipOffstage: skipOffstage, + ); + } + + Finder _bySemanticsProperty( + Pattern pattern, + String? Function(SemanticsNode?) propertyGetter, + {bool skipOffstage = true} + ) { if (!SemanticsBinding.instance.semanticsEnabled) { - throw StateError('Semantics are not enabled. ' - 'Make sure to call tester.ensureSemantics() before using ' - 'this finder, and call dispose on its return value after.'); + throw StateError( + 'Semantics are not enabled. ' + 'Make sure to call tester.ensureSemantics() before using ' + 'this finder, and call dispose on its return value after.', + ); } return byElementPredicate( (Element element) { @@ -568,13 +606,13 @@ class CommonFinders { if (element is! RenderObjectElement) { return false; } - final String? semanticsLabel = element.renderObject.debugSemantics?.label; - if (semanticsLabel == null) { + final String? propertyValue = propertyGetter(element.renderObject.debugSemantics); + if (propertyValue == null) { return false; } - return label is RegExp - ? label.hasMatch(semanticsLabel) - : label == semanticsLabel; + return pattern is RegExp + ? pattern.hasMatch(propertyValue) + : pattern == propertyValue; }, skipOffstage: skipOffstage, ); diff --git a/packages/flutter_test/test/finders_test.dart b/packages/flutter_test/test/finders_test.dart index 49d3c2a5335..6d021d2bb1e 100644 --- a/packages/flutter_test/test/finders_test.dart +++ b/packages/flutter_test/test/finders_test.dart @@ -299,6 +299,55 @@ void main() { expect(find.bySemanticsLabel('Foo'), findsOneWidget); semanticsHandle.dispose(); }); + + testWidgets('Throws StateError if semantics are not enabled (bySemanticsIdentifier)', (WidgetTester tester) async { + expect( + () => find.bySemanticsIdentifier('Add'), + throwsA( + isA().having( + (StateError e) => e.message, + 'message', + contains('Semantics are not enabled'), + ), + ), + ); + }, semanticsEnabled: false); + + testWidgets('finds Semantically labeled widgets by identifier', (WidgetTester tester) async { + final SemanticsHandle semanticsHandle = tester.ensureSemantics(); + await tester.pumpWidget(_boilerplate( + Semantics( + identifier: 'Add', + button: true, + child: const TextButton( + onPressed: null, + child: Text('+'), + ), + ), + )); + expect(find.bySemanticsIdentifier('Add'), findsOneWidget); + semanticsHandle.dispose(); + }); + + testWidgets('finds Semantically labeled widgets by identifier RegExp', (WidgetTester tester) async { + final SemanticsHandle semanticsHandle = tester.ensureSemantics(); + // list of elements with a prefixed identifier + await tester.pumpWidget(_boilerplate( + Row(children: [ + Semantics( + identifier: 'item-1', + child: const Text('Item 1'), + ), + Semantics( + identifier: 'item-2', + child: const Text('Item 2'), + ), + ]), + )); + expect(find.bySemanticsIdentifier('item'), findsNothing); + expect(find.bySemanticsIdentifier(RegExp(r'^item-')), findsNWidgets(2)); + semanticsHandle.dispose(); + }); }); group('byTooltip', () {