leancodepl / patrol

Flutter-first UI testing framework. Ready for action!
https://patrol.leancode.co
Apache License 2.0
887 stars 133 forks source link

`tap()` throwing `WaitUntilVisibleTimeoutException` #405

Open burhankhanzada opened 2 years ago

burhankhanzada commented 2 years ago

I have list of widgtes showing with some translate animation. Before using Patrol I have used ensureVisible() which works:


void main() {
  testWidgets('some test'), (tester) async {
    await app.main();
    await tester.pumpAndSettle();

    final cards = find.byType(Card);

    expect(cards, findsNWidgets(4));

    final firstCard = cards.evaluate().first.widget;

    final card = find.byWidget(firstCard);

    await tester.ensureVisible(card);
    await tester.pumpAndSettle();
    await tester.tap(card);
  }
}

but in Patrol docs says it will handle the visibility issue itself but it is giving me WaitUntilVisibleTimeoutException when setting visible or settle duration

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  patrolTest('some test', ($) async {
    await app.main();

    await $.pumpAndSettle();

    final cards = $(Card);

    expect(cards, findsNWidgets(4));

    final firstCard = cards.evaluate().first.widget;

    final card = $(firstCard);

    await card.tap(settleTimeout: 5.seconds, visibleTimeout: 5.seconds);
  });
}
bartekpacia commented 2 years ago

Hi @burhankhanzada 👋

Could you share the code of your widget? So that I can reproduce the problem myself.

Also, try doing:

await card.scrollTo();

before tapping.

burhankhanzada commented 2 years ago
class NetworkCard extends ConsumerWidget {
  const NetworkCard({super.key, required this.networkIndex});

  final int networkIndex;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final logo = Expanded(
      flex: 2,
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Image.asset(
          getLogoPath(
            context: context,
            networkIndex: networkIndex,
          ),
        ),
      ),
    );

    return Theme(
      data: getNetworkTheme(
        context: context,
        networkIndex: networkIndex,
      ),
      child: Builder(
        builder: (context) {
          final primaryColorAlpha =
              Theme.of(context).primaryColor.withAlpha(50);

          final ripple = SizedBox.expand(
            child: Material(
              color: Colors.transparent,
              child: InkWell(
                highlightColor: primaryColorAlpha,
                splashColor: primaryColorAlpha,
                onTap: () async {
                  ref.read(networkNotifierProvider).networkIndex = networkIndex;

                  await Navigator.pushNamed(context, bundleOfferListPage);
                },
              ),
            ),
          );

          return Expanded(
            child: TweenAnimationBuilder<double>(
              tween: Tween(begin: -100, end: 0),
              duration: 500.ms,
              builder: (context, value, child) {
                return Transform.translate(
                  offset: Offset(value, 0),
                  child: child,
                );
              },
              child: CustomCard(
                child: Stack(
                  children: [
                    Row(
                      children: [logo, const TextIcon()],
                    ),
                    ripple,
                  ],
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}
burhankhanzada commented 2 years ago
await card.scrollTo();

it also don't work may be because cards are in the column not in list

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        titleSpacing: 0,
        title: const Text(appName),
      ),
      drawer: const MainDrawer(),
      body: SpacedColumn(
        padding: const EdgeInsets.all(16),
        children: List.generate(
          networkList.length,
          (index) => NetworkCard(
            key: networkCardKeyList[index],
            networkIndex: index,
          ),
        ),
      ),
    );
  }
}
bartekpacia commented 2 years ago

Thanks for providing the code @burhankhanzada. Unfortunately, I wasn't able to reproduce your issue because you didn't provide everything that's needed (e.g some classes like SpacedColumn are not needed, and you're using ConsumerWidget from riverpod). The best way to help me reproduce your problem would be to flutter create a new very simple, example app, which would showcase only the problem that you're reporting. Keep that in mind for the future :)

It would really help me if you showed the repo (or, if you can't, created a simple repo just for demo purposes).

That said, I think I know what's the cause of your problem. Let's go over it step by step.

First, please try running your tests with flutter run test/widget_test.dart instead of flutter test test/widget_test.dart (or just flutter test, which runs all tests). This will run the tests on a real device, letting you see what the app looks like during tests.

Second, please tell me if your first test (without Patrol) works if you remove the call to ensureVisible(). I expect that it is not necessary, but I may be wrong.

Third, please show me the output of running flutter test in the terminal (please paste text, not an image).

The difference between flutter_test's tap() and Patrol's tap()

What this does:

await tester.tap(find.byType(Card));

is it taps on the Card widget which is in the widget tree. It does not mean that the Card is visible!

On the other hand, this code:

await $(Card).tap();

will tap on the first Card widget if it is in the widget tree and is visible. If the Card is not visible, we wait for visibleTimeout, and if at least one card Card is still not visible after visibleTimeout passes, then a WaitUntilVisibleTimeoutException is thrown.

We made it work that way because, from the user perspective, it doesn't matter that a widget is in the tree. What matters is that a widget is visible – only then the user is able to interact with it. If I can see it, I can tap it. And if I can't see it, I can't tap it.

I created a simple example to help you understand what I mean:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';

void main() {
  Future<void> pumpTree(WidgetTester tester) async {
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: SingleChildScrollView(
            child: Column(
              children: List.generate(
                100,
                (index) => Text(index.toString()),
              ),
            ),
          ),
        ),
      ),
    );
  }

  testWidgets('taps with flutter_test', (tester) async {
    await pumpTree(tester);

    await tester.tap(find.text('99'));
  });

  patrolTest('taps with patrol', ($) async {
    await pumpTree($.tester);

    await $('99').tap();
  });
}

The first test passes, and the second one fails. This is a result of the behavior I described above. Even though that a widget with text '99' does exist in the tree, it is not visible. If we were a user, we couldn't tap on it.

The fix is simple – just use scrollTo().

  patrolTest('taps with patrol', ($) async {
    await pumpTree($.tester);

    await $('99').scrollTo();
    await $('99').tap();
  });

Or in a shorter way:

  patrolTest('taps with patrol', ($) async {
    await pumpTree($.tester);

    await $('99').scrollTo().tap();
  });

I hope I helped a bit. I'm looking forward to your response!

burhankhanzada commented 2 years ago

Thanks, @bartekpacia for this depth analysis on my issue also let me clear some points.

First of all my tests are integration_test so they by default are running on the emulator by the way I have also run flutter run integration_test/... but the same result.

Secondly, you are right about ensureVisible test with 'testWidgets' is passing without it but I was using it to wait for the animation complete and then tap to happen.

Third, in your example, you used a list whereas I just have a column of cards with expanded so there are visible to the user, but the only issue is with an animation may because of that patrol think widget is not visible.

Fourth, As u suggest I have created an example repo please look at it.

Here is the output using testWidgets

Running Gradle task 'assembleDebug'...                             25.2s
√  Built build\app\outputs\flutter-apk\app-debug.apk.
Installing build\app\outputs\flutter-apk\app.apk...                994ms

Warning: A call to tap() with finder "exactly one widget with key [<'Card0'>] (ignoring offstage widgets): AnimatedCard-[<'Card0'>]" derived an Offset (Offset(180.0, 144.0)) that would not hit test on the specified widget.
Maybe the widget is actually off-screen, or another widget is obscuring it, or the widget cannot receive pointer events.
The finder corresponds to this RenderBox: RenderTransform#a9f85 relayoutBoundary=up2
The hit test result at that offset is: HitTestResult(HitTestEntry<HitTestTarget>#05261(TextSpan(debugLabel: (englishLike bodyMedium 2014).merge(blackMountainView bodyMedium), inherit: false, color: Color(0xdd000000), family: Roboto, size: 14.0, weight: 400, baseline: alphabetic, decoration: TextDecoration.none, "Card")), RenderParagraph#9a611@Offset(15.0, 8.0), RenderPositionedBox#f5319@Offset(176.0, 60.0), RenderConstrainedBox#90731@Offset(176.0, 60.0), RenderSemanticsAnnotations#9c148@Offset(176.0, 60.0), _RenderInkFeatures#a7948@Offset(176.0, 60.0), RenderCustomPaint#0fdc0@Offset(176.0, 60.0), RenderPhysicalShape#006e1@Offset(176.0, 60.0), RenderPadding#e3107@Offset(180.0, 64.0), RenderSemanticsAnnotations#96c4e@Offset(180.0, 64.0), RenderPointerListener#c95b2@Offset(180.0, 64.0), RenderSemanticsAnnotations#5b2f3@Offset(180.0, 64.0), RenderMouseRegion#54363@Offset(180.0, 64.0), RenderSemanticsAnnotations#b95ee@Offset(180.0, 64.0), RenderFlex#7d185@Offset(180.0, 64.0), RenderCustomMultiChildLayoutBox#376f8@Offset(180.0, 144.0), _RenderInkFeatures#c7a88@Offset(180.0, 144.0), RenderPhysicalModel#daa0e@Offset(180.0, 144.0), RenderSemanticsAnnotations#bc76f@Offset(180.0, 144.0), RenderRepaintBoundary#99e34@Offset(180.0, 144.0), RenderIgnorePointer#4f369@Offset(180.0, 144.0), RenderAnimatedOpacity#836ad@Offset(180.0, 144.0), RenderAnimatedOpacity#3f601@Offset(180.0, 144.0), _RenderColoredBox#42e89@Offset(180.0, 144.0), RenderAnimatedOpacity#13649@Offset(180.0, 144.0), RenderAnimatedOpacity#adee3@Offset(180.0, 144.0), _RenderColoredBox#9eff5@Offset(180.0, 144.0), RenderRepaintBoundary#06800@Offset(180.0, 144.0), _RenderFocusTrap#148fd@Offset(180.0, 144.0), RenderSemanticsAnnotations#7740a@Offset(180.0, 144.0), RenderOffstage#aae0e@Offset(180.0, 144.0), RenderSemanticsAnnotations#d52b5@Offset(180.0, 144.0), _RenderTheatre#01c90@Offset(180.0, 144.0), RenderSemanticsAnnotations#ab620@Offset(180.0, 144.0), RenderAbsorbPointer#65201@Offset(180.0, 144.0), RenderPointerListener#39a65@Offset(180.0, 144.0), RenderCustomPaint#5c250@Offset(180.0, 144.0), RenderSemanticsAnnotations#8b267@Offset(180.0, 144.0), RenderSemanticsAnnotations#3c4b6@Offset(180.0, 144.0), RenderSemanticsAnnotations#331ad@Offset(180.0, 144.0), RenderSemanticsAnnotations#9e08b@Offset(180.0, 144.0), RenderSemanticsAnnotations#c9cf5@Offset(180.0, 144.0), RenderRepaintBoundary#4efff@Offset(180.0, 144.0), HitTestEntry<HitTestTarget>#b9401(_LiveTestRenderView#8aa51), HitTestEntry<HitTestTarget>#fd48c(<IntegrationTestWidgetsFlutterBinding>))
#0      WidgetController._getElementPoint (package:flutter_test/src/controller.dart:963:25)
#1      WidgetController.getCenter (package:flutter_test/src/controller.dart:846:12)
#2      WidgetController.tap (package:flutter_test/src/controller.dart:274:18)
#3      main.<anonymous closure> (file:///C:/Users/Burhan/Desktop/patrol_example/integration_test/flutter_test.dart:24:20)
<asynchronous suspension>
#4      StackZoneSpecification._registerUnaryCallback.<anonymous closure> (package:stack_trace/src/stack_zone_specification.dart:125:47)
<asynchronous suspension>
To silence this warning, pass "warnIfMissed: false" to "tap()".
To make this warning fatal, set WidgetController.hitTestWarningShouldBeFatal to true.

✓ Flutter test

Exited.

Here is the output using patrolTest


Running Gradle task 'assembleDebug'...                             23.3s
√  Built build\app\outputs\flutter-apk\app-debug.apk.
Installing build\app\outputs\flutter-apk\app.apk...                901ms

══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════ The following WaitUntilVisibleTimeoutException was thrown running a test: TimeoutException after 0:00:10.000000: Finder exactly one widget with key [<'Card0'>] (ignoring offstage widgets): AnimatedCard-[<'Card0'>] did not find any visible widgets

When the exception was thrown, this was the stack:

0 PatrolTester.waitUntilVisible. (package:patrol/src/custom_finders/patrol_tester.dart:279:11)

(elided one frame from package:stack_trace) The test description was: Patrol Test ════════════════════════════════════════════════════════════════════════════════════════════════════ Test failed. See exception logs above. The test description was: Patrol Test ✖ Patrol Test Exited (1). ```
burhankhanzada commented 2 years ago

Yeah, you are right but on my emulator, it tap, and snackbar shows up can you please try to run on emulator.

Also what the best to run to check if a widget is visible to the user with flutter_test like you guys are checking on patrol

bartekpacia commented 2 years ago

Thank you @burhankhanzada for creating an example repo.

I run your tests from the command line and that's what I got:

$ flutter test integration_test/flutter_test.dart
00:00 +0: loading /Users/bartek/dev/random/patrol_example/integration_test/flutter_test.dart                              R00:08 +0: loading /Users/bartek/dev/random/patrol_example/integration_test/flutter_test.dart                          8.3s
✓  Built build/app/outputs/flutter-apk/app-debug.apk.
00:09 +0: loading /Users/bartek/dev/random/patrol_example/integration_test/flutter_test.dart                         418ms
00:11 +0: Flutter test

Warning: A call to tap() with finder "exactly one widget with key [<'Card0'>] (ignoring offstage widgets): AnimatedCard-[<'Card0'>]" derived an Offset (Offset(196.4, 173.4)) that would not hit test on the specified widget.
Maybe the widget is actually off-screen, or another widget is obscuring it, or the widget cannot receive pointer events.
The finder corresponds to this RenderBox: RenderTransform#7bead relayoutBoundary=up2
The hit test result at that offset is: HitTestResult(HitTestEntry<HitTestTarget>#c0ad7(TextSpan(debugLabel: (englishLike bodyMedium 2014).merge(blackMountainView bodyMedium), inherit: false, color: Color(0xdd000000), family: Roboto, size: 14.0, weight: 400, baseline: alphabetic, decoration: TextDecoration.none, "Card")), RenderParagraph#a09d2@Offset(15.0, 8.0), RenderPositionedBox#bd78c@Offset(192.4, 89.4), RenderConstrainedBox#9d8f2@Offset(192.4, 89.4), RenderSemanticsAnnotations#2fc0a@Offset(192.4, 89.4), _RenderInkFeatures#1c681@Offset(192.4, 89.4), RenderCustomPaint#801b4@Offset(192.4, 89.4), RenderPhysicalShape#9ac75@Offset(192.4, 89.4), RenderPadding#8dc2b@Offset(196.4, 93.4), RenderSemanticsAnnotations#6abbe@Offset(196.4, 93.4), RenderPointerListener#1a69e@Offset(196.4, 93.4), RenderSemanticsAnnotations#b48f4@Offset(196.4, 93.4), RenderMouseRegion#3fb8a@Offset(196.4, 93.4), RenderSemanticsAnnotations#c66e2@Offset(196.4, 93.4), RenderFlex#2bc82@Offset(196.4, 93.4), RenderCustomMultiChildLayoutBox#717a2@Offset(196.4, 173.4), _RenderInkFeatures#1bfef@Offset(196.4, 173.4), RenderPhysicalModel#3547d@Offset(196.4, 173.4), RenderSemanticsAnnotations#a1db7@Offset(196.4, 173.4), RenderRepaintBoundary#f47f1@Offset(196.4, 173.4), RenderIgnorePointer#690e6@Offset(196.4, 173.4), RenderAnimatedOpacity#a006d@Offset(196.4, 173.4), RenderAnimatedOpacity#07a99@Offset(196.4, 173.4), _RenderColoredBox#5b728@Offset(196.4, 173.4), RenderAnimatedOpacity#aeac4@Offset(196.4, 173.4), RenderAnimatedOpacity#f9111@Offset(196.4, 173.4), _RenderColoredBox#84dd8@Offset(196.4, 173.4), RenderRepaintBoundary#6f2d5@Offset(196.4, 173.4), _RenderFocusTrap#eac3a@Offset(196.4, 173.4), RenderSemanticsAnnotations#588ff@Offset(196.4, 173.4), RenderOffstage#1a6fb@Offset(196.4, 173.4), RenderSemanticsAnnotations#c7140@Offset(196.4, 173.4), _RenderTheatre#8f1b8@Offset(196.4, 173.4), RenderSemanticsAnnotations#ea940@Offset(196.4, 173.4), RenderAbsorbPointer#78c8d@Offset(196.4, 173.4), RenderPointerListener#ddc72@Offset(196.4, 173.4), RenderCustomPaint#fae3c@Offset(196.4, 173.4), RenderSemanticsAnnotations#f1182@Offset(196.4, 173.4), RenderSemanticsAnnotations#a2af9@Offset(196.4, 173.4), RenderSemanticsAnnotations#86043@Offset(196.4, 173.4), RenderSemanticsAnnotations#c8d9a@Offset(196.4, 173.4), RenderSemanticsAnnotations#506e1@Offset(196.4, 173.4), RenderRepaintBoundary#ea3dc@Offset(196.4, 173.4), HitTestEntry<HitTestTarget>#e57c3(_LiveTestRenderView#edf2d), HitTestEntry<HitTestTarget>#6d83e(<IntegrationTestWidgetsFlutterBinding>))
#0      WidgetController._getElementPoint (package:flutter_test/src/controller.dart:963:25)
#1      WidgetController.getCenter (package:flutter_test/src/controller.dart:846:12)
#2      WidgetController.tap (package:flutter_test/src/controller.dart:274:18)
#3      main.<anonymous closure> (file:///Users/bartek/dev/random/patrol_example/integration_test/flutter_test.dart:20:20)
<asynchronous suspension>
#4      StackZoneSpecification._registerUnaryCallback.<anonymous closure> (package:stack_trace/src/stack_zone_specification.dart:125:47)
<asynchronous suspension>
To silence this warning, pass "warnIfMissed: false" to "tap()".
To make this warning fatal, set WidgetController.hitTestWarningShouldBeFatal to true.

00:11 +1: All tests passed!

You can clearly see a warning, but nevertheless, the tap() succeeds (as can be seen on the emulator).

But why flutter_test works while Patrol fails?

Patrol, before tapping, ensures that the widget to be tapped is visible. Patrol ensures that the widget is visible by checking if the widget is hit testable. Here's the code that does this.

But in your case, the widget never becomes hit testable. This is because of a Flutter bug – here. I added a comment there which demonstrates how that bugs breaks Patrol.

Now, there's not much we can do about it. As a workaround, for this specific tap you can default to the "normal tap" using WidgetTester from flutter_test:

Replace this line:

await cardFinder.tap();

with this:

await $.tester.tap(cardFinder);

You can still use the rest of Patrol, but when you need to, you can easily fallback to flutter_test.

Sorry for the problem. Let me know if the fix works for you and if you have any further questions.

burhankhanzada commented 2 years ago

Yeah your workaround works, also i never know that i can also call tester from $ this should be documented in docs very use full in cases like this when someone migrating from flutter_test

bartekpacia commented 2 years ago

Right, that's definitely worth documenting. Thanks for your valuable feedback :)

bartekpacia commented 2 years ago

Also what the best to run to check if a widget is visible to the user with flutter_test like you guys are checking on patrol

To check if the widget is visible, we use Finder.hitTestable() method. There's no way to check visibility, but checking hit-testability is basically the same. We also think that isVisible is easier to read and understand than isHitTestable 😛