Coder Social home page Coder Social logo

passsy / spot Goto Github PK

View Code? Open in Web Editor NEW
62.0 4.0 1.0 659 KB

Chainable powerful Flutter widget selector API, screenshots and assertions for awesome widget tests.

Home Page: https://pub.dev/packages/spot

License: Apache License 2.0

Dart 100.00%
hacktoberfest flutter widget-testing

spot's Introduction

Spot

pub

Fluent, chainable Widget finders and better assertions for Flutter widget tests

⛓️ Chainable widget selectors 💙 Prints helpful error messages

Usage

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

void main() {
  testWidgets('Widget test with spot', (tester) async {
    // Create widget selectors for elements in the widget tree
    final scaffold = spot<MaterialApp>().spot<Scaffold>();
    final appBar = scaffold.spot<AppBar>();
    
    // Assert for values of widgets
    appBar.spotText('Dash').hasFontSize(14).hasFontColor(Colors.black87);
    
    // Find widgets based on child widgets
    appBar
        .spot<IconButton>(children: [spotIcon(Icons.home)])
        .existsOnce()
        .hasTooltip('home');

    // Find widgets based on multiple parent widgets
    spot<Icon>(parents: [appBar, spot<IconButton>()])
        .existsExactlyNTimes(2)
        .all((icon) {
      icon.hasColorWhere((color) => color.equals(Colors.black));
    });

    // Interact with widgets using `act`
    final button = spot<FloatingActionButton>();
    await act.tap(button);
  });
}

Take screenshots of your entire screen or single widgets to see what's going on.

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

void main() {
  testWidgets('Take screenshots', (tester) async {
    tester.pumpWidget(MyApp());
    
    // Take a screenshot of the entire screen
    await takeScreenshot();
    // console:
    // Screenshot file:///var/folders/0j/p0s0zrv91tgd33zrxb88c0440000gn/T/spot/screenshot_test:10-s83dv.png
    //   taken at main.<fn> file:///Users/pascalwelsch/Projects/passsy/spot/test/spot/screenshot_test.dart:10:10
    
    // Take a screenshot of a single widget
    await spot<AppBar>().takeScreenshot();
    // console:
    // Screenshot file:///var/folders/0j/p0s0zrv91tgd33zrxb88c0440000gn/T/spot/screenshot_test:16-w8UPv.png
    //   taken at main.<fn> file:///Users/pascalwelsch/Projects/passsy/spot/test/spot/screenshot_test.dart:16:24
  });
}

Chain selectors

You know exactly where your widgets are. Like a button in the AppBar or a Text in a Dialog. Spot allows you to chain matchers, narrowing down the search space.

Chaining allows spot to create better error messages for you. Spot follows the chain of your selectors and can tell you exactly where the widget is missing. Like: Could not find "IconButton" in "AppBar", but found these widgets instead: <AppBar-widget-tree>.

spot<AppBar>().spot<IconButton>();
spot<IconButton>(parents: [spot<AppBar>()]);

Both syntax are identical. The first is shorter for when you only need a single parent. The second allows checking for multiple parents, which is only required for rare use cases.

Selectors

Spot has two features, creating selectors and asserting on them with matchers.

A selector is a query to find a set of widgets. Like a SQL query, or a CSS selector. It is only a description of what to search for, without actually doing the search.

Selectors can be rather complex, it is therefore recommended to reuse them. You can even save them top-level and reuse them across multiple tests.

spot<ElevatedButton>();

final WidgetSelector<TextField> textFields = 
    spot<LoginScreen>().spot<LoginForm>().spot<TextField>();

final WidgetSelector<TextField> usernameTextField =
    spot<TextField>(
      parents: [
        spot<TextWithLabel>(
          children: [
            spotText('Username'),
          ],
        ),
      ],
    );

A WidgetSelector may return 0, 1 or N widgets. Depending on how many widgets you expect to find, you should use the corresponding matchers.

Matchers

After creating a selector, you want to assert the widgets it found. The snapshot() method creates a WidgetSnapshot of the widget tree at that point in time and finds all widgets that match the selector.

Quantity matchers

The easiest matchers are the quantity matchers. They allow checking how many widgets were found.

  • existsOnce() asserts that exactly one widget was found
  • doesNotExist() asserts that no widget was found
  • existsExactlyNTimes(n) asserts that exactly n widgets were found
  • existsAtLeastOnce() asserts that at least one widget was found
  • existsAtMostOnce() asserts that at most one widget was found
final selector = spot<ElevatedButton>();

// calls snapshot() internally
final matchOne = selector.existsOnce(); 
final matchMultiple = selector.existsExactlyNTimes(5);

selector.doesNotExist(); // end, nothing to match on 

Property matchers

The property matchers allow asserting on the properties of the widgets. You don't have to use execpt(), instead you can use the has*/is* matchers directly.

spot<Tooltip>()
    .existsOnce() // takes snapshot and asserts quantity
    // start your chain of matchers
    .hasMessage('Favorite')
    .hasShowDurationWhere(
      (it) => it.isGreaterOrEqual(Duration(seconds: 1000)),
    )
    .hasTriggerMode(TooltipTriggerMode.longPress);

To match multiple widgets use all() or any()

spot<AppBar>().spot<Tooltip>().existsAtLeastOnce()
    .all((tooltip) => tooltip
      .hasShowDurationWhere((it) => it.isGreaterOrEqual(Duration(seconds: 1000)))
      .hasTriggerMode(TooltipTriggerMode.longPress)
    );

Selectors vs Matchers

It is recommended to use matchers instead of selectors once you have narrowed down the search space to the widget you want to assert on. This makes the error messages much clearer. Instead of widget not found you'll get Found ToolTip with message 'Settings' but expected 'Favorite' as error message.

// DON'T
spot<Tooltip>()
    .withMessage('Favorite') // selector
    .withTriggerMode(TooltipTriggerMode.longPress) // selector
    .existsOnce();

// DO
spot<Tooltip>()
    .existsOnce()
    .hasMessage('Favorite') // matcher
    .hasTriggerMode(TooltipTriggerMode.longPress); // matcher

Better errors

In case the settings icon doesn't exist you usually would get the following error using findsOneWidget

expect(find.byIcon(Icons.settings), findsOneWidget);

>>> Expected: exactly one matching node in the widget tree
>>>   Actual: _WidgetIconFinder:<zero widgets with icon "IconData(U+0E57F)" (ignoring offstage widgets)>
>>>    Which: means none were found but one was expected

The error message above is not really helpful, because the actual error is not that there's no icon, but the Icons.home instead of Icons.settings.

spot prints the entire widget tree and shows that there is an Icon, but the wrong one (IconData(U+0E318)). That's much more helpful!

In the future, spot will only print the widget tree from the last node found node (spot<AppBar>).

spot<AppBar>().spotIcon(Icons.settings).existsOnce();

Could not find 'icon "IconData(U+0E57F)"' as child of #2 type "IconButton"
There are 1 possible parents for 'icon "IconData(U+0E57F)"' matching #2 type "IconButton". But non matched. The widget trees starting at #2 type "IconButton" are:
Possible parent 0:
IconButton(Icon, padding: EdgeInsets.all(8.0), dependencies: [_InheritedTheme, IconTheme, _LocalizationsScope-[GlobalKey#bdafc]])
└Semantics(container: false, properties: SemanticsProperties, renderObject: RenderSemanticsAnnotations#9b22d relayoutBoundary=up13)
 └InkResponse
  └_InkResponseStateWidget(gestures: [tap], mouseCursor: SystemMouseCursor(click), BoxShape.circle, dependencies: [MediaQuery], state: _InkResponseState#181bf)
   └_ParentInkResponseProvider
    └Actions(dispatcher: null, actions: {ActivateIntent: CallbackAction<ActivateIntent>#fded7, ButtonActivateIntent: CallbackAction<ButtonActivateIntent>#5d1ad}, state: _ActionsState#f4947)
     └_ActionsMarker
      └Focus(dependencies: [_FocusMarker], state: _FocusState#3db93)
       └_FocusMarker
        └Semantics(container: false, properties: SemanticsProperties, renderObject: RenderSemanticsAnnotations#d907f relayoutBoundary=up14)
         └MouseRegion(listeners: [enter, exit], cursor: SystemMouseCursor(click), renderObject: RenderMouseRegion#49c96 relayoutBoundary=up15)
          └Semantics(container: false, properties: SemanticsProperties, renderObject: RenderSemanticsAnnotations#e83d5 relayoutBoundary=up16)
           └GestureDetector(startBehavior: start, dependencies: [MediaQuery])
            └RawGestureDetector(state: RawGestureDetectorState#2d012(gestures: [tap], excludeFromSemantics: true, behavior: opaque))
             └Listener(listeners: [down], behavior: opaque, renderObject: RenderPointerListener#cab1c relayoutBoundary=up17)
              └ConstrainedBox(BoxConstraints(48.0<=w<=Infinity, 48.0<=h<=Infinity), renderObject: RenderConstrainedBox#96a26 relayoutBoundary=up18)
               └Padding(padding: EdgeInsets.all(8.0), dependencies: [Directionality], renderObject: RenderPadding#c223d relayoutBoundary=up19)
                └SizedBox(width: 24.0, height: 24.0, renderObject: RenderConstrainedBox#d47d4 relayoutBoundary=up20)
                 └Align(alignment: Alignment.center, dependencies: [Directionality], renderObject: RenderPositionedBox#ac4b6)
                  └Builder(dependencies: [IconTheme])
                   └IconTheme(color: Color(0xffffffff), size: 24.0)
                    └Icon(IconData(U+0E318), dependencies: [Directionality, IconTheme])
                     └Semantics(container: false, properties: SemanticsProperties, renderObject: RenderSemanticsAnnotations#ec9ab relayoutBoundary=up1)
                      └ExcludeSemantics(excluding: true, renderObject: RenderExcludeSemantics#5b179 relayoutBoundary=up2)
                       └SizedBox(width: 24.0, height: 24.0, renderObject: RenderConstrainedBox#eefd6 relayoutBoundary=up3)
                        └Center(alignment: Alignment.center, dependencies: [Directionality], renderObject: RenderPositionedBox#4c194)
                         └RichText(textDirection: ltr, softWrap: wrapping at box width, overflow: visible, maxLines: unlimited, text: "", dependencies: [_LocalizationsScope-[GlobalKey#bdafc]], renderObject: RenderParagraph#35451 relayoutBoundary=up1)
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure was thrown running a test:
Could not find 'icon "IconData(U+0E57F)"' as child of [type "MaterialApp" > 'type "Scaffold"' >
'type "AppBar"' && type "IconButton"]

Roadmap

  • ✅ Make chainable WidgetSelectors
  • ✅ Print full widget tree when assertions fail
  • ✅ Allow defining WidgetSelector with children
  • ✅ Allow defining WidgetSelector with parents
  • ✅ Interop with Finder API
  • ✅ Match properties of widgets (via DiagnosticsNode)
  • ✅ Allow matching of nested properties (with checks API)
  • ✅ Generate code for custom properties for Flutter widgets
  • ✅ Allow generating code for properties of 3rd party widgets
  • ✅ Interact with widgets (act)
  • ✅ Allow manually printing a screenshot at certain points
  • ✅ Negate child matchers
  • ✅ Simplify WidgetSelector API
  • ⬜️ Become the de facto Widget selector API for patrol
  • ⬜️ Combine multiple WidgetSelectors with and
  • ⬜️ More act features
  • ⬜️ Print only widget tree of the parent scope when test fails
  • ⬜️ Create screenshot when test fails
  • ⬜️ Automatically create report with screenshots of all user interactions
  • ⬜️ Create interactive HTML page with all widgets and matchers when test fails

Project state

Spot is used in production by many apps already. The current plan is to merge spot somehow with patrol, the next big milestone.

The public spot<X>() API just received a rework in 0.10.0 which cleans up and simplifies the API. (Awaiting feedback) The act API is still experimental and has known issues. (WIP)

Even though things are still fluid, spot today already provides a lot of value over the traditional finder API.

License

Copyright 2022 Pascal Welsch

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

spot's People

Contributors

danielmolnar avatar giuspepe avatar nohli avatar passsy avatar rehlma avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

Forkers

danielmolnar

spot's Issues

Suggestion: add bool

It would be great to have an implementation that replaces something like this:

bool hasBurgerMenu() {
    return spot<Tooltip>().whereMessage((it) => it.equals('Open navigation menu')).finder.evaluate().isNotEmpty;
}

act.tap(spotText('textSpan')) should click the exact TextSpan

Currently, act.tap() taps the center of the RenderObject. But RenderParagraph can give the exact location of the text and there should be clicked, allowing TextSpans with gesture recognizers.

      Offset locationOfTextSpan(String text) {
        assert(text.length > 1);
        final paragraph =
            spotText(text).snapshotRenderObject() as RenderParagraph;
        final fullText = paragraph.text.toPlainText();
        final start = fullText.indexOf(text);
        final end = start + text.length;
        final barLocationStart =
            paragraph.getOffsetForCaret(TextPosition(offset: start), Rect.zero);
        final barLocationEnd =
            paragraph.getOffsetForCaret(TextPosition(offset: end), Rect.zero);
        if (barLocationStart.dy == barLocationEnd.dy) {
          // on same line, click the middle
          return Offset(
            (barLocationStart.dx + barLocationEnd.dx) / 2,
            (barLocationStart.dy + barLocationEnd.dy) / 2,
          );
        } else {
          // click one pixel inside the first character
          return barLocationStart + const Offset(1, 1);
        }
      }

      Future<void> tapTextSpan(String text) async {
        final spanLocation = locationOfTextSpan(text);

        final binding = TestWidgetsFlutterBinding.instance;
        final downEvent = PointerDownEvent(position: spanLocation);
        binding.handlePointerEvent(downEvent);

        final upEvent = PointerUpEvent(position: spanLocation);
        binding.handlePointerEvent(upEvent);
      }
      testWidgets('concatenates text spans', (tester) async {
        await tester.pumpWidget(
          _stage(
            children: [
              RichText(
                text: TextSpan(
                  children: [
                    TextSpan(text: 'foo'),
                    TextSpan(
                      text: 'bar',
                      recognizer: TapGestureRecognizer()
                        ..onTap = () => print('click'),
                    ),
                  ],
                ),
              ),
            ],
          ),
        );

        spotText('foo').existsOnce();
        spotText('foobar').existsOnce();

        await tapTextSpan('bar');
      });

This example should be the default

Enhance Error Handling of all(), any()

An issue has been identified in the and methods within the class, where the error handling and messaging could be improved to provide more detailed information about failed conditions when no widgets fulfill the provided matcher.

This enhancement request was raised during the review of Pull Request #43 (#43), specifically in the context of a discussion on improving the method's error messaging (#43 (comment)).

The goal is to make the error messages more informative and helpful for developers by including details about the failed conditions, thereby facilitating easier debugging and a better understanding of why a test might have failed.

Requested by @passsy.

Refactor Method for Clarity and Readability

In the context of PR #41 (#41), a review comment (#41 (comment)) suggested improvements for the method within the class. The feedback highlighted the need for clearer, more actionable error messages and a refactor to reduce the method's complexity and improve readability. This issue aims to track the suggested enhancements to ensure they are addressed.

spotWidgetAtPosition

I'd love a function to find widgets on a position on screen.
For example

spot().spotWidgetsAtPosition(Offset offset)
returns a full list of all widgets found at that position.

spot().spotWidgetAtPosition<T>(Offset offset)
returns the first widget of that Type on that position.

spot() vs act API

Not reading any documentation of course and just wanting to kickstart using spot it feels counter intuitive to have spot(), creating an instance of spot and an act, which does not have an constructor.
Does it make sense to you to make act() also valid?

I also would love to have an act() method on selectors so this would be interchangeable:
await act.tap(spotSingle<ElevatedButton>());
await spotSingle<ElevatedButton>().tap();

Where the latter makes it way more discoverable and , for me at least, more intuitive.

This has probably already been thought of and the act API is about to , since overall the API feels nice and smooth.

API to assert List<WidgetSelector>

Sometimes I want to assert multiple widgets with the same condition. e.g. when building a form.
I could write it multi-line, but it might be useful to assert all at the same time

final WidgetSelector<Checkbox> boxA = spotCheckboxA();
final WidgetSelector<Checkbox> boxB = spotCheckboxB();
final WidgetSelector<Checkbox> boxC = spotCheckboxC();

// assert that those widgets are checked
// [boxA, boxB, boxC].areChecked();

hasProp(widgetSelector: ) can't check for properties

I want to check if TronButton.onTap is set or not.

Old API (0.6.0)

extension EffectiveTextMatcher on WidgetMatcher<TronButton> {
  // ignore: avoid_positional_boolean_parameters
  WidgetMatcher<TronButton> isTappable(bool value) {
    return hasProp(
      selector: (subject) => subject.context.nest<bool>(
        () => ['is clickable"'],
        (Element element) {
          final widget = element.widget as TronButton;
          return Extracted.value(widget.onTap != null);
        },
      ),
      match: (it) => it.equals(value),
    );
  }
}

Still not great with hasProp(widgetSelector: ) (spot 0.7.0)

extension EffectiveTextMatcher on WidgetMatcher<TronButton> {
  // ignore: avoid_positional_boolean_parameters
  WidgetMatcher<TronButton> isTappable(bool value) {
    return hasProp(
      widgetSelector: (w) => w.has((it) => it.onTap != null, 'isTappable'),
      match: (it) => it.equals(value),
    );
  }
}

Can we improve this?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.