Does flutter_test support testing 'leaf' widgets? - flutter

Suppose I create a simple widget, such as this:
class RightArrowButton extends StatelessWidget {
const RightArrowButton({VoidCallback onPressed})
: this._onPressed = onPressed;
final VoidCallback _onPressed;
#override
Widget build(BuildContext context) {
return IconButton(
visualDensity: VisualDensity.compact,
onPressed: _onPressed,
icon: _buildIcon(iconData()),
);
}
/// Overrride to set a different icon.
IconData iconData() => Icons.play_arrow;
Widget _buildIcon(IconData icon) => Icon(icon, color: Colors.blue);
}
Further suppose that I wish to unit test this widget. I know, this is an incredibly simple widget, but I might be attempting to use TDD to write the widget in the first place. In any case, I could apply the concept to a more complex widget.
In this case, the test would be very simple: Construct the widget, passing it an onPressed function whose side-effects can be checked to validate that the onPressed method is actually called when someone taps on the widget.
Sample test code:
void main() {
testWidgets('RightArrowButton', (WidgetTester tester) async {
int counter = 0;
await tester.pumpWidget(RightArrowButton(
onPressed: () => counter++,
));
expect(0, equals(counter));
var button = find.byType(IconButton);
await tester.tap(button);
expect(1, equals(counter));
});
}
However, when I do this, I get the following error:
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following assertion was thrown building IconButton(Icon, padding: EdgeInsets.all(8.0), dirty):
No Material widget found.
IconButton widgets require a Material widget ancestor.
I understand the error after reading this SO question:
No Material widget found
So -- Is there a way to unit test widgets in the way I want? Or am I forced to test a whole assembly of widgets in order to get a valid Material widget ancestor (which is more like a small integration test)?

I found a solution which works. Flutter experts, is the following approach the only or best way to achieve my goal of testing individual widgets in isolation?
void main() {
testWidgets('arrow test', (WidgetTester tester) async {
int counter = 0;
// must include Card to get Material;
// must include MaterialApp to get Directionality
await tester.pumpWidget(MaterialApp(
home: Card(
child: RightArrowButton(
onPressed: () => counter++,
),
),
));
await tester.tap(find.byType(RightArrowButton));
expect(1, equals(counter));
});
}

Related

Mocking StateNotifierProvider (Riverpod) with Mockito

I'm trying to write a test for a row of buttons. The buttons should be enabled / disabled depending on a flag provided by a controller class.
class UserSettingsButtonRow extends ConsumerWidget {
const UserSettingsButtonRow({Key? key}) : super(key: key);
#override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(userSettingsControllerProvider);
UserSettingsController controller =
ref.read(userSettingsControllerProvider.notifier);
...
When I test this manually in the context of the whole program everything works fine. However I'd like to automate this test.
This is my test:
#GenerateNiceMocks([MockSpec<UserSettingsController>()])
void main() {
testWidgets(
'UserSettingsButtonRow - buttons enabled',
(tester) async {
// check if two buttons are enabled
UserSettingsController settingsController = MockUserSettingsController();
when(settingsController.hasValueChanged()).thenAnswer((_) => true);
await tester.runAsync(
() async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userSettingsControllerProvider
.overrideWith((ref) => settingsController),
],
child: const MaterialApp(
home: Material(
child: UserSettingsButtonRow(),
),
),
),
);
},
);
await tester.pumpAndSettle();
expect(find.byType(ElevatedButton), findsNWidgets(2));
List<ElevatedButton> buttons = find
.byType(ElevatedButton)
.evaluate()
.map((e) => e.widget as ElevatedButton)
.toList();
for (ElevatedButton button in buttons) {
expect(button.enabled, isTrue);
}
},
);
<.......>
My problem is that a assertion in the build() method of my widget is not fulfilled.
When the automated test comes to
ref.watch(userSettingsControllerProvider);
I get the following exception:
Exception has occurred.
_AssertionError ('package:riverpod/src/framework/element.dart': Failed assertion: line 439 pos 9: 'getState() != null': Bad state, the provider did not initialize. Did "create" forget to set the state?)
I can't find many examples on testing / mocking with StateNotifierProviders. Can somebody help me out and tell me what's wrong here?
Thanks for your help!
I tried the solution from what is the correct approach to test riverpod with mockito but the atttribute state is not supported any more.
Riverpod Testing: How to mock state with StateNotifierProvider? is not a solution either for me as I want to override the hasValueChanged() method from the Controller.

Widget testing to assert Text widgets style

I am building a Task/To do item widget. The Task widget is implemented using both a ListTile and Checkbox widgets.
class _TaskListItemState extends State<TaskListItem> {
#override
Widget build(BuildContext context) {
return InkWell(
onTap: _goToTaskScreen,
child: ListTile(
leading: Checkbox(
value: widget.task.isComplete, onChanged: _toggleTaskComplete),
title: Text(
widget.task.title,
style: TextStyle(
decoration: widget.task.isComplete
? TextDecoration.lineThrough
: null,
color: widget.task.isComplete ? Colors.grey : null),
),
),
);
}
void _goToTaskScreen() {...}
void _toggleTaskComplete(bool? value) {...});
}
}
I am trying to test the widget, that if the task is completed then widget's text style decoration should be strike-through, else normal.
testWidgets(
'if the isCompleted is true, then title text should be strikethrough',
(WidgetTester tester) async {
// Arrange
const testKey = ValueKey('my-key-1');
const testTitle = 'Demo title';
const testProjectID = 555;
final DateTime testDueDate = DateTime.now();
const testIsComplete = true;
final Task taskComplete = Task(
title: testTitle,
projectID: testProjectID,
dueDate: testDueDate,
isComplete: testIsComplete);
await tester.pumpWidget(MaterialApp(
home: Material(
child: TaskListItem(key: testKey, task: taskComplete),
),
));
final textFinder = find.byType(Text);
final textWidget = tester.firstWidget(textFinder);
expect(textWidget.style.decoration, TextDecoration.lineThrough);
});
App runs fine when runnng flutter run.
But this throws an error, when running flutter test:
est/widgets/task_list_item_test.dart:57:25: Error: The getter 'style'
isn't defined for the class 'Widget'.
'Widget' is from 'package:flutter/src/widgets/framework.dart' ('/C:/flutter/packages/flutter/lib/src/widgets/framework.dart'). Try
correcting the name to the name of an existing getter, or defining a
getter or field named 'style'.
expect(textWidget.style.decoration, TextDecoration.lineThrough);
^^^^^
I know I am missing something because I have only been learning flutter for over a week. Could you help me how to perform Widget testing to assert a Text widgets style. Thank you!
After running into this issue myself and looking into resolution for testing/assert styles on a Text widget, I found the find.byWidgetPredicate method to the most effect at achieving this.
For you case, you can use byWidgetPredicate like so:
// Define your finder with specific conditions here:
final textWithStrikeThrough = find.byWidgetPredicate(
(widget) =>
widget is Text &&
widget.style.decoration == TextDecoration.lineThrough,
description: '`Text` widget with strike through',
);
// Now you can use this finder like other finders.
expect(
textWithStrikeThrough,
findsOneWidget,
);
You could try casting the firstWidget method as a Text like this:
final textFinder = find.byType(Text);
final textWidget = tester.firstWidget<Text>(textFinder);
To ensure the type is stored the textWidget object.

How to verify a widget is "offscreen"

bounty info: I'll accept your answer if:
isn't something along the line do this instead
the code sample is mostly unchanged
produce successful test, not just some quote from docs
doesn't need any extra package
[edit : 07/02/21] following Miyoyo#5957 on flutter community on
discord #iapicca Convert widget position to global, get width height, add both, and see if the resulting bottom right position is on screen? and using the following answers as reference:
test widget global position
test widget size
flutter_test dimensions issue
given the code sample below (also runnable on dartpad)
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
final _testKey = GlobalKey();
const _fabKey = ValueKey('fab');
final _onScreen = ValueNotifier<bool>(true);
void main() => runApp(_myApp);
const _myApp = MaterialApp(
home: Scaffold(
body: MyStage(),
floatingActionButton: MyFAB(),
),
);
class MyFAB extends StatelessWidget {
const MyFAB() : super(key: const ValueKey('MyFAB'));
#override
Widget build(BuildContext context) => FloatingActionButton(
key: _fabKey,
onPressed: () => _onScreen.value = !_onScreen.value,
);
}
class MyStage extends StatelessWidget {
const MyStage() : super(key: const ValueKey('MyStage'));
#override
Widget build(BuildContext context) => Stack(
children: [
ValueListenableBuilder(
child: FlutterLogo(
key: _testKey,
),
valueListenable: _onScreen,
builder: (context, isOnStage, child) => AnimatedPositioned(
top: MediaQuery.of(context).size.height *
(_onScreen.value ? .5 : -1),
child: child,
duration: const Duration(milliseconds: 100),
),
),
],
);
}
I want to test is the widget is off screen
here's the test code so far
void main() {
testWidgets('...', (tester) async {
await tester.pumpWidget(_myApp);
final rect = _testKey.currentContext.findRenderObject().paintBounds;
expect(tester.getSize(find.byKey(_testKey)), rect.size,
reason: 'size should match');
final lowestPointBefore = rect.bottomRight.dy;
print('lowest point **BEFORE** $lowestPointBefore ${DateTime.now()}');
expect(lowestPointBefore > .0, true, reason: 'should be on-screen');
await tester.tap(find.byKey(_fabKey));
await tester.pump(const Duration(milliseconds: 300));
final lowestPointAfter =
_testKey.currentContext.findRenderObject().paintBounds.bottomRight.dy;
print('lowest point **AFTER** $lowestPointAfter ${DateTime.now()}');
expect(lowestPointAfter > .0, false, reason: 'should be off-screen');
});
}
and the logs produced
00:03 +0: ...
lowest point **BEFORE** 24.0 2021-02-07 16:28:08.715558
lowest point **AFTER** 24.0 2021-02-07 16:28:08.850733
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure object was thrown running a test:
Expected: <false>
Actual: <true>
When the exception was thrown, this was the stack:
#4 main.<anonymous closure> (file:///home/francesco/projects/issue/test/widget_test.dart:83:5)
<asynchronous suspension>
<asynchronous suspension>
(elided one frame from package:stack_trace)
...
This was caught by the test expectation on the following line:
file:///home/francesco/projects/issue/test/widget_test.dart line 83
The test description was:
...
════════════════════════════════════════════════════════════════════════════════════════════════════
00:03 +0 -1: ... [E]
Test failed. See exception logs above.
The test description was: ...
00:03 +0 -1: Some tests failed.
I'm not sure if my approach is correct
and the time in the print suggest me that
lowest point **BEFORE** 24.0 2021-02-07 16:28:08.715558
lowest point **AFTER** 24.0 2021-02-07 16:28:08.850733
suggest me that
await tester.pumpAndSettle(Duration(milliseconds: 300));
doesn't do what I think it does
Problems are:
We were trying to find the rect of FlutterLogo but FlutterLogo rect will remain same the parent AnimatedPositioned widget's location are actually changing.
Even though we now start to check for AnimatedPositioned paintBounds it will still be the same as we are not changing width but the position it self.
Solution:
Get the screen rect by topWidget for me it's Scaffold. (if we have different widgets like HomeScreen which contains FAB button we just need to find that rect)
Before click I'm checking if fab button is on-screen or not
Tap and pump the widget and let it settle.
Search for widget rect and it will be out of the screen i.e. in our case -600
Added comments in the code it self
testWidgets('...', (tester) async {
await tester.pumpWidget(MyApp);
//check screen width height - here I'm checking for scaffold but you can put some other logic for screen size or parent widget type
Rect screenRect = tester.getRect(find.byType(Scaffold));
print("screenRect: $screenRect");
//checking previous position of the widget - on our case we are animating widget position via AnimatedPositioned
// which in itself is a statefulwidget and has Positioned widget inside
//also if we have multiple widgets of same type give them uniqueKey
AnimatedPositioned widget =
tester.firstWidget(find.byType(AnimatedPositioned));
double topPosition = widget.top;
print(widget);
print("AnimatedPositioned topPosition: $topPosition}");
expect(
screenRect.bottom > topPosition && screenRect.top < topPosition, true,
reason: 'should be on-screen');
//click button to animate the widget and wait
await tester.tap(find.byKey(fabKey));
//this will wait for animation to settle or call pump after duration
await tester.pumpAndSettle(const Duration(milliseconds: 300));
//check after position of the widget
AnimatedPositioned afterAnimationWidget =
tester.firstWidget(find.byType(AnimatedPositioned));
double afterAnimationTopPosition = afterAnimationWidget.top;
Rect animatedWidgetRect = tester.getRect(find.byType(AnimatedPositioned));
print("rect of widget : $animatedWidgetRect");
expect(
screenRect.bottom > afterAnimationTopPosition &&
screenRect.top < afterAnimationTopPosition,
false,
reason: 'should be off-screen');
});
Note: replaced _ from code as it was hiding the object from test file.
Output:
screenRect: Rect.fromLTRB(0.0, 0.0, 800.0, 600.0)
fab clicked
rect of widget : Rect.fromLTRB(0.0, -600.0, 24.0, -576.0)
I found this answer (in particular the code inside the onNotification), which kind of does what (I think) you want. It finds the RenderObject using the current context of the key. Afterwards it finds the RenderAbstractViewport using this RenderObject, and checks the offSetToReveal. Using this offset you can determine whether the current RenderObject is being displayed or not (using a simple comparison).
I'm not a 100% sure this will work / is what you want, but hopefully it can push you in the right direction.
Also (even though you stated you didn't want any external package), on the same question someone recommended this package, which can be useful for others having the same problem but who are open to using an external package.
I want to thank #parth-dave
for his answer, that I happily reward with the bounty
and Miyoyo referenced in the question
I want to offer my own implementation built on his approach
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
// !! uncomment tge line below to run as test app
// void main() => runApp(_myApp);
class Keys {
static final subject = UniqueKey();
static final parent = UniqueKey();
static final trigger = UniqueKey();
}
final _onScreen = ValueNotifier<bool>(true);
Widget get app => MaterialApp(
home: Scaffold(
key: Keys.parent,
body: MyStage(),
floatingActionButton: MyFAB(),
),
);
class MyFAB extends StatelessWidget {
const MyFAB() : super(key: const ValueKey('MyFAB'));
#override
Widget build(BuildContext context) => FloatingActionButton(
key: Keys.trigger,
onPressed: () => _onScreen.value = !_onScreen.value,
);
}
class MyStage extends StatelessWidget {
const MyStage() : super(key: const ValueKey('MyStage'));
#override
Widget build(BuildContext context) => Stack(
children: [
ValueListenableBuilder(
child: FlutterLogo(
key: Keys.subject,
),
valueListenable: _onScreen,
builder: (context, isOnStage, child) => AnimatedPositioned(
top: MediaQuery.of(context).size.height *
(_onScreen.value ? .5 : -1),
child: child,
duration: const Duration(milliseconds: 100),
),
),
],
);
}
void main() {
group('`AnimatedPositined` test', () {
testWidgets(
'WHEN no interaction with `trigger` THEN `subject` is ON SCREEN',
(tester) async {
await tester.pumpWidget(app);
final parent = tester.getRect(find.byKey(Keys.parent));
final subject = tester.getRect(find.byKey(Keys.subject));
expect(parent.overlaps(subject), true, reason: 'should be ON-screen');
});
testWidgets('WHEN `trigger` tapped THEN `subject` is OFF SCREEN`',
(tester) async {
await tester.pumpWidget(app);
await tester.tap(find.byKey(Keys.trigger));
await tester.pumpAndSettle(const Duration(milliseconds: 300));
final parent = tester.getRect(find.byKey(Keys.parent));
final subject = tester.getRect(find.byKey(Keys.subject));
expect(parent.overlaps(subject), false, reason: 'should be OFF-screen');
});
});
}

How to mock navigation arguments for testing flutter screen widgets?

I would like to write a mockito test for a screen widget in flutter. The problem is, that this widget uses a variable from the navigation argument and I'm not sure how to mock this variable.
This is the example screen:
class TestScreen extends StatefulWidget {
static final routeName = Strings.contact;
#override
_TestScreenState createState() => _TestScreenState();
}
class _TestScreenState extends State<TestScreen> {
Contact _contact;
#override
Widget build(BuildContext context) {
_contact = ModalRoute.of(context).settings.arguments;
return Scaffold(
appBar: AppBar(title: Text(Strings.contact)),
body: Text(_contact.name),
);
}
}
With this command I open the screen
Navigator.pushNamed(context, TestScreen.routeName, arguments: contact);
Normally I would mock some components, but I'm not sure how to mock the screen arguments. I hope it works something like this. However, I do not know what I can exactly mock.
when(screenArgument.fetchData(any))
.thenAnswer((_) async => expectedContact);
This is the current test, which of course is not working since _contact is null:
void main() {
testWidgets('contact fields should be filled with data from argument', (WidgetTester tester) async {
// GIVEN
final testScreen = TestApp(widget: TestScreen());
// WHEN
await tester.pumpWidget(testScreen);
// THEN
expect(find.text("test"), findsOneWidget);
});
}
An ugly way would be to use constructor parameters for the screen only for testing, but I want to avoid that.
Maybe someone of you knows how to best test such screen widgets.
The way that I've found is the same approach how flutter guys are testing it:
https://github.com/flutter/flutter/blob/d03aecab58f5f8b57a8cae4cf2fecba931f60673/packages/flutter/test/widgets/navigator_test.dart#L715
Basically they create a MaterialApp, put a button that after pressing will navigate to the tested page.
My modified solution:
Future<void> pumpArgumentWidget(
WidgetTester tester, {
#required Object args,
#required Widget child,
}) async {
final key = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: key,
home: FlatButton(
onPressed: () => key.currentState.push(
MaterialPageRoute<void>(
settings: RouteSettings(arguments: args),
builder: (_) => child,
),
),
child: const SizedBox(),
),
),
);
await tester.tap(find.byType(FlatButton));
await tester.pumpAndSettle(); // Might need to be removed when testing infinite animations
}
This approach works ok-ish, had some issues with testing progress indicators as it was not able to find those even when debugDumpApp displayed them.
If you are using a Dependency Injector such as I am, you may need to avoid pass contextual arguments to the constructor if your view is not built at the time the view class is instantiated. Otherwise, just use the view constructor as someone suggested.
So if you can't use constructor as I can't, you can solve this using Navigator directly in your tests. Navigator is a widget, so just use it to return your screen. Btw, it has no problem with Progress Indicator as pointed above.
import 'package:commons/core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MyCustomArgumentsMock extends Mock implements MyCustomArguments {}
void main() {
testWidgets('indicator is shown when screen is opened', (tester) async {
final MyCustomArguments mock = MyCustomArgumentsMock();
await tester.pumpWidget(MaterialApp(
home: Navigator(
onGenerateRoute: (_) {
return MaterialPageRoute<Widget>(
builder: (_) => TestScreen(),
settings: RouteSettings(arguments: mock),
);
},
),
));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
}

Where do I run initialisation code when starting a flutter app?

Where do I run initialisation code when starting a flutter app?
void main() {
return runApp(MaterialApp(
title: "My Flutter App",
theme: new ThemeData(
primaryColor: globals.AFI_COLOUR_PINK,
backgroundColor: Colors.white),
home: RouteSplash(),
));
}
If I want to run some initialisation code to, say fetch shared preferences, or (in my case) initialise a package (and I need to pass in the the BuildContext of the MaterialApp widget), what is the correct way to do this?
Should I wrap the MaterialApp in a FutureBuilder? Or is there a more 'correct' way?
------- EDIT ---------------------------------------------------
I have now placed the initialisation code in RouteSplash() widget. But since I required the BuildContext of the app root for the initialisation, I called the initialisation in the Widget build override and passed in context.ancestorInheritedElementForWidgetOfExactType(MaterialApp). As I don't need to wait for initialisation to complete before showing the splash screen, I haven't used a Future
One simple way of doing this will be calling the RouteSplash as your splash screen and inside it perform the initialization code as shown.
class RouteSplash extends StatefulWidget {
#override
_RouteSplashState createState() => _RouteSplashState();
}
class _RouteSplashState extends State<RouteSplash> {
bool shouldProceed = false;
_fetchPrefs() async {
await Future.delayed(Duration(seconds: 1));// dummy code showing the wait period while getting the preferences
setState(() {
shouldProceed = true;//got the prefs; set to some value if needed
});
}
#override
void initState() {
super.initState();
_fetchPrefs();//running initialisation code; getting prefs etc.
}
#override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: shouldProceed
? RaisedButton(
onPressed: () {
//move to next screen and pass the prefs if you want
},
child: Text("Continue"),
)
: CircularProgressIndicator(),//show splash screen here instead of progress indicator
),
);
}
}
and inside the main()
void main() {
runApp(MaterialApp(
home: RouteSplash(),
));
}
Note: It is just one way of doing it. You could use a FutureBuilder if you want.
To run code at startup, put it in your main.dart. Personally, that's the way I do it, to initialise lists etc.