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));
```
This commit is contained in:
Adam 2024-10-08 13:27:32 -04:00 committed by GitHub
parent 138144bb2f
commit 37d5dc45d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 100 additions and 12 deletions

View File

@ -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`.

View File

@ -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,
);

View File

@ -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<StateError>().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: <Widget>[
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', () {