Last active
December 9, 2023 16:42
-
-
Save Rex-Ferrer/c0d4bff4b0d3909daf8994410cd659ce to your computer and use it in GitHub Desktop.
16 Tips for Widget Testing in Flutter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// https://gist.github.com/Esgrima/c0d4bff4b0d3909daf8994410cd659ce | |
// https://dartpad.dev/c0d4bff4b0d3909daf8994410cd659ce | |
import 'package:flutter/material.dart'; | |
import 'package:flutter_test/flutter_test.dart'; | |
import 'package:boolean_selector/boolean_selector.dart'; | |
// (TODO: Tip # 1) Consider making frequently used variables/values constants | |
const _fooConst1 = ''; | |
const _fooConst2 = ''; | |
const _fooConst3 = ''; | |
const _deskTopSize = Size(1920, 1040); // physical pixels | |
// (TODO: Tip # 2) Control which platforms your tests run on | |
// This is why we need to import the boolean_selector package | |
@TestOn(android && ios && chrome) | |
void main() { | |
// Make good use of [setUpAll], [setUp], [tearDownAll], and [tearDown] | |
// to avoid repetitive code. D.ont R.epeat Y.ourself. | |
// (TODO: Tip # 3a) Consider pre-loading fonts if you're using custom fonts | |
// Remember this runs once before ALL tests or groups | |
setUpAll(() async {}); | |
// (TODO: Tip # 3b) | |
// Remember this runs before EACH test or group | |
setUp(() async { | |
final TestWidgetsFlutterBinding binding = | |
TestWidgetsFlutterBinding.ensureInitialized(); | |
// (TODO: Tip # 4) Consider configuring your default screen size here. | |
// You can reset it to something else within a test | |
binding.window.physicalSizeTestValue = _deskTopSize; | |
}); | |
// (TODO: Tip # 3c) | |
// Remember this runs once after ALL tests or groups | |
tearDownAll(() async {}); | |
// (TODO: Tip # 3d) | |
// Remember this runs after EACH test or group | |
tearDown(() async { | |
// Code that clears caches can go here | |
}); | |
group('Integration Tests', () { | |
// (TODO: Tip # 5a) What's this doing here? I didn't know this either, | |
// but you can call this within your groups and it'll run once before ALL | |
// your tests in the group | |
setUpAll(() async {}); | |
// (TODO: Tip # 5b) Same as above but before EACH test | |
// This may be a perfect place for you to pump a fresh widget instead | |
// of doing each time in your testWidgets() | |
setUp(() async { | |
// For example, each test could start at the FooHomePage | |
await tester.pumpWidget( | |
FooHomePage( | |
showDebugBanner: false, | |
), | |
); | |
}); | |
// (TODO: Tip # 5c) Same as above but after ALL tests | |
tearDownAll(() async {}); | |
// (TODO: Tip # 5d) Same as above but after EACH test | |
tearDown(() async {}); | |
// (TODO: Tip # 6) Attempt to write an integration widget test | |
// Share what you came up with in the comments | |
testWidgets('Foo Integration Test 1', (tester) async { | |
// This tests the Counter app in https://dartpad.dev/b6409e10de32b280b8938aa75364fa7b | |
await tester.pumpWidget(MyApp); | |
await tester.tap(find.byTooltip(‘Increment’)); | |
await tester.pumpAndSettle(); | |
// Assume this Text widget: | |
// Text( | |
// '$_counter', | |
// style: Theme.of(context).textTheme.headline4, | |
// semanticsLabel: ‘Counter’, | |
// ), | |
final Text text = find.BySemanticsLabel('Counter') | |
.last | |
.evaluate() | |
.single | |
.widget; | |
expect(text.data, ‘1’); | |
// Summary: You verified the behavior of the FloatingActionButton, Text, Scaffold, and | |
// MyHomePage widgets | |
}); | |
// Here's an integration test similar to what I did for my project | |
testWidgets('Navigation Integration Test', (tester) async { | |
// FooHomePage is a widget that consists of a MaterialApp | |
await tester.pumpWidget( | |
FooHomePage( | |
showDebugBanner: false, | |
), | |
); | |
// Any subset of the following two statements may be required | |
// if you want to make assertions, take screenshots along the way, | |
// or perform any gestures. Some animations require both and a provided | |
// duration. | |
// Resources to read: | |
// 1. https://medium.com/flutter/event-loop-in-widget-tester-50b3ca5e9fc5 | |
// 2. https://flutter.dev/docs/cookbook/testing/widget/introduction#4-build-the-widget-using-the-widgettester | |
// Pump triggers a frame sequence (build/layout/paint/etc), then flushes | |
// microtasks. | |
// A microtask is anything that is scheduled for execution in the event loop. | |
// Calling pump() repeatedly flushes microtasks and allows new ones to be | |
// created. | |
await tester.pump(); | |
// In widget tests, a new frame is only rendered when pump() is called. If you call | |
// pumpAndSettle(), frames will be requested until an animation has finished. | |
await tester.pumpAndSettle(duration: Duration(milliseconds: animationDuration)); | |
// Let's navigate to the ItemPage. I've made an assumption there's a button | |
// widget with the text 'ItemPage' | |
await tester.tap(find.text('ItemPage')); | |
await tester.pumpAndSettle(); | |
// Let's navigate to the Details page. Similar assumption as above | |
await tester.tap(find.text('Details')); | |
await tester.pumpAndSettle(); | |
// Let's navigate back to the home page | |
// Only use this function if you have a back button with a 'Back' tooltip or | |
// it'll throw an error | |
await tester.pageBack(); | |
await tester.pump(); | |
await tester.pumpAndSettle(); | |
await tester.pageBack(); | |
await tester.pump(); | |
await tester.pumpAndSettle(); | |
}); | |
}); | |
// (TODO: Tip # 7) Review the flutter_test documentation thoroughly, so you have an idea | |
// of what's at your disposal and what you need to implement. | |
group('ItemPage', () { | |
testWidgets('testFooBehavior1', (tester) async { | |
// (TODO: Tip # 8) Need a widget to be visible when it is offscreen in a | |
// ScrollView? | |
// Use ensureVisible and set skipOffStage to false if the widget | |
// could be offStage | |
await tester.ensureVisible(find.byType(Button, skipOffStage: false)); | |
await tester.pumpAndSettle(); | |
}); | |
testWidgets('testFooBehavior2', (tester) async { | |
// (TODO: Tip # 9) Learn how to take screenshots | |
// Here's an example screenshot comparison | |
await expectLater( | |
find.text('Save'), | |
matchesGoldenFile('save.png'), | |
); | |
// (TODO: Tip # 10) Use tags to limit which tests are run. | |
// If you're having text scaling bugs all of sudden, then pass this | |
// command line flag to only run tests related to 'text-scaling' | |
// --tags 'text-scaling' | |
// Pro-Tip: Create a constants folder and have a tags.dart holding all your tags | |
}, tags: ['contrast-ratio', 'text-scaling']); | |
}); | |
group('ItemDetailsPage', () { | |
// (TODO: Tip # 11a) Know your gestures | |
// These are the default gestures provided to you by flutter_test | |
// 1. drag, dragFrom | |
// 2. fling, flingFrom | |
// 3. tap, tapAt | |
// 4. longPress, longPressAt | |
// 5. press | |
// (TODO: Tip # 11b) Create custom gestures | |
// Custom scroll function | |
// | |
// final gesture = await createGesture(); | |
// | |
// // This is like placing your finger on the screen | |
// await gesture.down( | |
// getCenter( | |
// find.byType(CustomScrollView), | |
// ), | |
// ); | |
// | |
// // This is like moving your finger up or down the screen | |
// await gesture.moveBy( | |
// Offset(0, numberOfPixels), | |
// ); | |
// | |
// // This is like lifting your finger off the screen and | |
// // ends the gesture. This could be crucial because in some instances | |
// // future gestures won't be recognized and you may have errors in | |
// // your screenshots. | |
// await gesture.up(); | |
// | |
// await pumpAndSettle(); | |
testWidgets('FooBehavior1', (tester) async { | |
// (TODO: Tip # 12) When using a [Finder], are you running into the too many | |
// elements StateError? | |
// (TODO: Tip # 12a) General strategy is to identify probable unique | |
// identifiers like Keys or Tooltips | |
// For example: find.byKey(Key(‘Toggle’)) | |
// (TODO: Tip # 12b) You can also use a ChainedFinder to step through the | |
// matching process | |
final Key key1 = Key(); | |
await tester.pumpWidget( | |
// (TODO: Tip #13) Minimize boilerplate code with helpers | |
// Widget _directionalityBoilerplate(Widget child) { | |
// return new Directionality( | |
// textDirection: TextDirection.ltr, | |
// child: child, | |
// ); | |
// } | |
_directionalityBoilerplate(Column( | |
children: [ | |
Container( | |
key: key1, | |
child: const Text('1'), | |
), | |
Container( | |
child: const Text('2'), | |
) | |
], | |
)), | |
); | |
// ChainedFinder | |
final Text text = find | |
.descendant( | |
// This is the parent or first widget that gets found | |
of: find.byKey(key1), | |
// Text is searched for in the found widget | |
matching:find.byType(Text), | |
) | |
.last | |
.evaluate() | |
.single | |
.widget; | |
expect(text.data, '1'); | |
// (TODO: Tip # 14) Customize platform-specific configurations | |
// Give a pesky iPad Air extra wiggle-room to run before timing out | |
}, onPlatform: {'ios': const Timeout.factor(2)}); | |
testWidgets('FooBehavior2', (tester) async { | |
// (TODO: Tip # 15) Skip tests if an issue is causing it to fail | |
}, skip: 'Due to bug X. See <link>'); | |
}); | |
} | |
// (TODO: Tip # 16) Widget tests are great opportunities to use Dart | |
// extension methods, available since Dart 2.7. (https://dart.dev/guides/language/extension-methods) | |
extension SwitchDarkMode on WidgetTester { | |
// As an extension method, your code in testWidget() will look like this: | |
// | |
// await tester.switchToDarkMode(); | |
// | |
// Rather than: | |
// .await switchToDarkMode(tester); | |
// | |
// The only benefits are readability and consistency in your code, and you could | |
// write less code. | |
Future<void> switchToDarkMode() { | |
// Does things | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment