Widget Test Composer
Made by Surf 🏄♂️🏄♂️🏄♂️
Overview
Widget Test Composer is a utility package designed to simplify widget and golden testing processes using golden_toolkit package for Flutter applications. Developed by Surf :surfer: Flutter team :cow2:, it offers comprehensive features to facilitate efficient testing workflows.
Installation
Add surf_widget_test_composer
to your pubspec.yaml
:
dependencies:
surf_widget_test_composer: $currentVersion$
Example
Getting started
You need to create file test/flutter_test_config.dart
. There you will specify:
- localizations of your app;
- themes of your app you need to test;
- list of devices you want to test on;
- tolerance for golden tests (the resulting diffPercent must be less than the
tolerance
settings property).
E.g. you have two themes: light and dark. You need to test the app on two devices: iPhone 11, Google Pixel 4a and iPhone SE 1. You need to test the app in two languages: English and Russian. You need to test your app on two locales: US and RU. You also have DI scope that you need to wrap your widget with.
Then your file test/flutter_test_config.dart
will look like this:
import 'package:surf_widget_test_composer/surf_widget_test_composer.dart'
as helper;
/// Localization and locales from auto-generated AppLocalizations.
const _localizations = AppLocalizations.localizationsDelegates;
const _locales = AppLocalizations.supportedLocales;
Future<void> testExecutable(FutureOr<void> Function() testMain) {
/// You can specify your own themes.
/// Stringified is used for naming screenshots.
final themes = [
helper.TestingTheme(
data: ThemeData.dark(),
stringified: 'dark',
type: helper.ThemeType.dark,
),
helper.TestingTheme(
data: ThemeData.light(),
stringified: 'light',
type: helper.ThemeType.light,
),
];
/// You can specify your own devices.
final devices = [
helper.TestDevice(
name: 'iphone11',
size: const Size(414, 896),
safeArea: const EdgeInsets.only(top: 44, bottom: 34),
),
helper.TestDevice(
name: 'pixel 4a',
size: const Size(393, 851),
),
helper.TestDevice(
name: 'iphone_se_1',
size: const Size(640 / 2, 1136 / 2),
),
];
return helper.testExecutable(
testMain: testMain,
themes: themes,
localizations: _localizations,
locales: _locales,
wrapper: (child, mode, theme, localizations, locales) =>
helper.BaseWidgetTestWrapper(
childBuilder: child,
mode: mode,
themeData: theme,
localizations: localizations,
localeOverrides: locales,
// You can specify dependencies here.
dependencies: (child) => child,
),
/// You can specify background color of golden test based on current theme.
backgroundColor: (theme) => theme.colorScheme.background,
devicesForTest: devices,
/// You can specify tolerance for golden tests.
tolerance: 0.5,
);
}
According to the config, 12 goldens will be generated for each test: 2 locales x 2 themes x 3 devises.
For example goldens for SampleItemListView.
Usage
Now we can prepare tests.
If in addition to golden tests you also need widget tests, then you can make something like this:
class MockSettingsService extends Mock implements SettingsService {}
void main() {
final mockSettingsService = MockSettingsService();
const widget = SettingsScreen();
/// Generate golden.
testWidget<SettingsScreen>(
desc: 'SettingsScreen',
widgetBuilder: (context, theme) => ProviderScope(
overrides: [
settingsServiceProvider.overrideWithValue(mockSettingsService),
],
child: Consumer(
builder: (context, ref, _) => widget.build(context, ref),
),
),
setup: (context, mode) {
registerFallbackValue(ThemeMode.light);
when(() => mockSettingsService.themeMode()).thenAnswer(
(_) => Future.value(ThemeMode.dark),
);
when(() => mockSettingsService.updateThemeMode(any()))
.thenAnswer((_) => Future.value());
},
/// Widget tests.
test: (tester, context) async {
final button = find.byType(DropdownButton<ThemeMode>);
expect(button, findsOneWidget);
final floatingActionButton = find.byIcon(Icons.light_mode);
expect(floatingActionButton, findsOneWidget);
verifyNever(() => mockSettingsService.updateThemeMode(any()));
await tester.tap(floatingActionButton);
verify(() => mockSettingsService.updateThemeMode(any())).called(1);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.mode_night), findsOneWidget);
},
);
}
If you just need goldens, then the test might look like this:
void main() {
const widget = SampleItemListView();
/// Nothing to test, just want to generate the golden.
testWidget<SampleItemListView>(
desc: 'SampleItemListView - result',
widgetBuilder: (context, _) => widget.build(context),
// If we need to indicate that we are testing a specific widget/screen state,
// we can fill in the [screenState] field.
screenState: 'result',
);
}
Warning
Always specify the generic type of the widget you are testing (e.g.,testWidget<TestableScreen>
), as the golden's name generation is based on the widget class name.
Example for Elementary
class MockElementaryCounterWM extends Mock implements IElementaryCounterWM {}
void main() {
const int testValue = 5;
const widget = ElementaryCounterScreen();
final wm = MockElementaryCounterWM();
/// Generate golden.
testWidget<ElementaryCounterScreen>(
desc: 'ElementaryCounterScreen',
widgetBuilder: (context, theme) => widget.build(wm),
setup: (context, mode) {
when(() => wm.title).thenReturn('Elementary Counter');
when(() => wm.value).thenReturn(StateNotifier<int>(initValue: testValue));
when(() => wm.increment()).thenReturn(null);
},
/// Widget tests.
test: (tester, context) async {
expect(find.widgetWithText(Center, testValue.toString()), findsOneWidget);
final floatingActionButton = find.byIcon(Icons.add);
expect(floatingActionButton, findsOneWidget);
await tester.tap(floatingActionButton);
verify(wm.increment);
},
);
}
Example for Riverpod
class MockRiverpodCounterScreenController extends AutoDisposeNotifier<int>
with Mock
implements RiverpodCounterScreenController {}
void main() {
const int testValue = 5;
const widget = RiverpodCounterScreen();
final mockController = MockRiverpodCounterScreenController();
final container = ProviderContainer(
overrides: [
riverpodCounterScreenControllerProvider
.overrideWith(() => mockController),
],
);
/// Generate golden.
testWidget<RiverpodCounterScreen>(
desc: 'RiverpodCounterScreen',
widgetBuilder: (context, theme) => UncontrolledProviderScope(
container: container,
child: Consumer(
builder: (context, ref, _) => widget.build(context, ref),
),
),
setup: (context, mode) {
when(() => mockController.build()).thenReturn(testValue);
when(() => mockController.increment()).thenReturn(null);
},
/// Widget tests.
test: (tester, context) async {
expect(find.widgetWithText(Center, testValue.toString()), findsOneWidget);
final floatingActionButton = find.byIcon(Icons.add);
expect(floatingActionButton, findsOneWidget);
await tester.tap(floatingActionButton);
verify(() => mockController.increment()).called(1);
},
);
}
Example for BLoC
class MockBlocCounterBloc extends Mock implements BlocCounterBloc {}
void main() {
const int testValue = 5;
final mockBloc = MockBlocCounterBloc();
const widget = BlocCounterView();
/// Generate golden.
testWidget<BlocCounterView>(
desc: 'BlocCounterView',
widgetBuilder: (context, theme) => MultiBlocProvider(
providers: [
BlocProvider<BlocCounterBloc>(create: (_) => mockBloc),
],
child: widget,
),
setup: (context, mode) {
when(() => mockBloc.state).thenReturn(testValue);
when(() => mockBloc.stream).thenAnswer(
(_) => Stream<int>.fromIterable([testValue]),
);
when(() => mockBloc.add(Increment())).thenAnswer((_) => Future.value());
when(() => mockBloc.close()).thenAnswer((_) => Future.value());
},
/// Widget tests.
test: (tester, context) async {
expect(find.widgetWithText(Center, testValue.toString()), findsOneWidget);
final floatingActionButton = find.byIcon(Icons.add);
expect(floatingActionButton, findsOneWidget);
await tester.tap(floatingActionButton);
verify(() => mockBloc.add(Increment())).called(1);
},
);
}
Generating goldens
Don't forget to generate goldens before use:
flutter test --update-goldens --tags=golden
Additional Information
While testing, you can face the following errors:
00:05 +0: WHEN tasks are not completedTHEN shows `CircularProgressIndicator`
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown while running async test code:
pumpAndSettle timed out
This error means that the widget you are testing has an infinite loop. Usually this happens when you use looped animations. In order to fix this you can:
- define your custom pump function. E.g.:
/// Nothing to test, just want to generate the golden.
testWidget<TestableScreen>(
'Test screen - loading',
widgetBuilder: (_) => widget.build(wm),
/// Since we are testing a specific widget state, we fill in the [screenState] property.
screenState: 'loading',
/// We define our custom pump function - golden will be generated after 100 milliseconds no matter animation is finished or not.
customPump: (tester) => tester.pump(const Duration(milliseconds: 100)),
setup: (context, data) {
when(() => wm.data).thenReturn(EntityValueNotifier.loading());
when(() => wm.theme).thenReturn(Theme.of(context));
}
);
NOTE: This may lead to a mismatch between same goldens - every time you run the test, the golden may be different.
-
you also can use
TestEnvDetector.isTestEnvironment
in your widget. E.g.:CircularProgressIndicator( value: TestEnvDetector.isTestEnvironment ? 0.5 : value, color: Colors.blue, )
Changelog
All notable changes to this project will be documented in this file.
Issues
To report your issues, submit them directly in the Issues section.
Contribute
If you would like to contribute to the package (e.g. by improving the documentation, fixing a bug or adding a cool new feature), please read our contribution guide first and send us your pull request.
Your PRs are always welcome.
How to reach us
Please feel free to ask any questions about this package. Join our community chat on Telegram. We speak English and Russian.