Or, what I wish I knew before retroactively writing tests for my entire app
Flutter is awesome. Testing is important. But man, does the documentation for testing suck. What follows is a living document of all the things I’ve found to be useful while writing tests.
Speaking of which, a few words about my situation: I’ve spent the last 16 months writing an app, but only writing very few tests. I’d like to change that.
If it’s not already immediately evident, I am absolutely not a testing whiz. I’ve written unit tests in other languages before, but mostly against my will. However, my app is only getting larger and more unwieldy, and unforeseen secondary and tertiary consequences are beginning to show up, and will continue to show up as I add in further complexity. Thus, I’d love it if the four-eyes principle was at least a little automated.
To wit, my guiding principle (so far): if everything works as expected under the hood, the widgets will show that visually. And thus, I should be able to write a test specific enough to prove it. Right?
As such, I’m writing what amounts to integration tests, but using the widget testing framework. Put another way, I’m testing individual widgets, that sometimes have myriad widgets beneath them. I reserve the right to revise this approach, but it means that:
- on the whole, I am not writing unit tests
- nor am I writing integration tests
- or relying on emulation
- my tests get run in the build pipeline
- However, as widget testing is a bit like flying blind, I run them first in the emulator to make sure everything is where it ought to be.
Okay, that’s enough of that. I’m sure there are other ways to go about this, and I look forward to hearing all about them. Maybe even from you?
Basics
It wouldn’t be a medium dot com tutorial if I didn’t first assume that you’re an idiot and mansplain what testing in flutter entails.
import 'package:flutter/material.dart'; class TestWidget extends StatelessWidget { const TestWidget({super.key, required this.title, required this.message}); final String title; final String message; @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', home: Scaffold( appBar: AppBar(title: Text(title)), body: Center(child: Text(message)), ), ); } } Here’s a super simple app, pulled straight from the Flutter docs.
import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('AppBar – ', () { testWidgets('has the right title', (tester) async { tester.binding.setSurfaceSize(Size(200, 200)); await tester.pumpWidget(const TestWidget(title: 'T', message: 'M')); expect(find.text('T'), findsOneWidget); }); }); group('Body – ', () { testWidgets('has the right message', (tester) async { tester.binding.setSurfaceSize(Size(200, 200)); await tester.pumpWidget(const TestWidget(title: 'T', message: 'M')); expect(find.text('M'), findsOneWidget); }); }); } And here’s the according test. I’ve modified it a bit, so that we can quickly cover these basics.
First up, I’ve grouped the tests. I’ll let you figure out how to structure your tests, but things that belong together should be in the same group. Then we’ll perform the test itself with testWidget(). Note that it’s callback is an async function, and that everything that has anything to do with time in said callback should be await-ed.
Here, I’m setting the size of the test field with the following code. You don’t have to do it, but I think it’s kinda nice to be able to control the playing field:
tester.binding.setSurfaceSize(Size(height, width));
Then we spawn the widget with tester.pumpWidget(). And then finally, we conduct the test with expect(), find() and a condition constant. We’ll go into all of these in greater detail, but for now keep this structure in mind.
Pumping
I don’t know why the flutter testing suite refers to populating the test field and other time-based interaction as pumping. But alas, here we are.
tester.pumpWidget()
sets up your widget. if you have any providers, or anything that needs to happen before your widget gets deployed, make sure to do instantiate / do it before this.
tester.pump()
advance things by 1 (one) frame. good when you’re expecting a state change / refresh. pass a duration as an argument to advance by however many frames will fit within said duration.
tester.pumpAndSettle()
this waits for there to be no more queued animation frames. a fire-and-forget tester.pump() with a duration; although you can also pass a duration here if you want to make sure that at least that much time has passed before continuing.
there are other pump functions, but I haven’t had to use them.
Find
Here are some of the most common finders I’ve run into so far. they are a part of the CommonFinder class, which is somehow magically encapsulated in the find keyword.
find.byType()
finds your element by type. it also works with your custom widgets, not just those from flutter.
find.text(‘’)
have some text in a given widget? this’ll find it.
find.byKey(Key())
hey, we’ve all been there. sometimes widgets are hard to pick out of a crowd. assign it a key, and you’ll be in business.
find.byIcon(Icon())
like it says on the tin, you can pass any material icon (Icons.your_icon_here) and it’ll get found. great for buttons.
find.descendant(of: find.byWhatever(), matching: find.byWhatever())
this one is awesome. drill down into your widgets with of; then get what you came for with matching.
find.widgetWithIcon(AncestorWidget, Icons.yourIconHere),
this one is like descendant, but with the caveat that the widget you’re trying to match is an icon. slick!
Expect
expect(find.byWhatever(), foundConstant)
I’ve debated even putting this in here, but for the sake of completion I will. put in your CommonFinder as a first argument, and the found constant as a second. they are self-explanatory, but here are the constants:
- findsOne
- findsNWidgets(X)
- findsNothing
Interaction
Simulate interaction with your widget(s) like a person would.
await tester.tap(find.byWhatever());
tap your widget of choice.
await tester.enterText(find.byWhatever(), ‘hello world!’);
contrary to what this functions’ name might have you think, this will replace whatever text is in your given TextField. if you haven’t specified a TextField, it will keep looking within your widget until one is found.
await tester.testTextInput.receiveAction(TextInputAction.done);
hey, you know the enter key? here’s the most obtuse way to access it. I haven’t worked out how to specify which TextField, so this should only be used in tests that only have one TextField to be found.
tester.widget(find.byType(TextField)).focusNode!.unfocus();
you would think that tapping on another, neighboring widget, and then pumping for good measure, would be enough to unfocus your TextField. you would be wrong. you have to directly access the TextField itself by way of type checking; then tell its focusNode to unfocus. any attached focusNode listeners will also be notified.
&c.
Widget Properties
expect( ( ( ( tester.firstWidget( find.byType(Container), ) as Container ).decoration as BoxDecoration ).border as Border ).top.color, Colors.red );
because I have too much time on my hands, I wrote my own version of a TextField, whose border changes color depending on the input. like, when an email is invalid, the border should be red. to test for that, we need to employ some super fancy footwork. typecasting is extremely important here: otherwise it’ll think that the container is just a widget, which does not have BoxDecoration by default. from there, I get the border, and select one of the BorderSides to get its color. as the border encircles the entire widget, any will do.
another example –I wanted to find out which keyboard type said TextField has been assigned:
expect( ( tester.firstWidget( find.byType(TextField), ) as TextField ).keyboardType, TextInputType.emailAddress ); Overflow Helper testWidgets( 'a funny name for your test', (WidgetTester tester) async { final originalOnError = FlutterError.onError!; FlutterError.onError = ignoreOverflowErrors; await tester.pumpWidget(YourWidgetHere); // other various code preparation for the test FlutterError.onError = (FlutterErrorDetails details) { originalOnError(details); }; expect(find.byType(OperationMode), findsOne); } ) this has been documented a few times on stackoverflow, but I’ll put it here. when you’re testing, regardless of how large your test field is or is not, text will overflow, causing your test to fail. it’s super annoying. so let’s politely ask the testing framework to kindly keep quiet about any such overflows. we’ll save the original onError into its own variable, then override it with our fancy helper. we’ll then set up the conditions for the test — pumping, settling, interactions, &c. — and then reset the onError to its original state. this will let the test complete like normal; otherwise it will hang. Tuck the following code away in its own helper file.
import 'package:flutter/material.dart'; void ignoreOverflowErrors( FlutterErrorDetails details, { bool forceReport = false, }) { bool ifIsOverflowError = false; bool isUnableToLoadAsset = false; var exception = details.exception; if (exception is FlutterError) { ifIsOverflowError = !exception.diagnostics.any( (e) => e.value.toString().startsWith("A RenderFlex overflowed by"), ); isUnableToLoadAsset = !exception.diagnostics.any( (e) => e.value.toString().startsWith("Unable to load asset"), ); } if (!ifIsOverflowError && !isUnableToLoadAsset) { FlutterError.dumpErrorToConsole(details, forceReport: forceReport); } } FireBase
Firebase could honestly be its own writeup. Without revealing too much about my app, I can tell you that there are plenty of mocks out there. I use the following code to make sure that the base Firebase environment is mocked, and it appears to work so far.
import 'package:firebase_core/firebase_core.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:firebase_core_platform_interface/firebase_core_platform_interface.dart'; import 'package:one/firebase_options.dart'; FuturesetupFirebaseMocks() async { TestWidgetsFlutterBinding.ensureInitialized(); FirebasePlatform.instance = MockFirebasePlatform(); await Firebase.initializeApp( options: YourFireBaseLoginStuffHere, ); } class MockFirebasePlatform extends FirebasePlatform { MockFirebasePlatform() : super(); @override Future initializeApp({ String? name, FirebaseOptions? options, }) async { return MockFirebaseApp(name: name, options: options); } @override FirebaseAppPlatform app([String name = defaultFirebaseAppName]) { return MockFirebaseApp( name: name, options: YourFireBaseLoginStuffHere, ); } Future resetApp(String name) async { return; } } class MockFirebaseApp extends FirebaseAppPlatform { MockFirebaseApp({ String? name, FirebaseOptions? options, }) : super( name ?? defaultFirebaseAppName, options ?? YourFireBaseLoginStuffHere, ); }
Okay, I think that’s all I want to share for now. If you found it helpful, do a clap. Or write a comment. Or do neither of those things.