Flutter Lesson 67 of 83 5 min read
Unit and Widget Testing in Flutter
Write unit tests and widget tests in Flutter with flutter_test: pump widgets, find them, tap, enter text and verify the result.
On this page
Tests let you change code without fear. Flutter supports three kinds.
| Kind | Tests | Speed | Runs on |
|---|---|---|---|
| Unit | One function or class | Milliseconds | Your computer |
| Widget | One widget or screen | Fast | Your computer, with no device |
| Integration | The whole app | Slow | A device or emulator |
Write many unit tests, a good number of widget tests, and a few integration tests for the most important journeys.
New projects already include flutter_test. Tests live in test/ in files ending _test.dart, and you run them with:
flutter test
Unit tests #
Logic that lives outside widgets is tested as plain Dart. The Dart lesson on testing covers this in depth.
// lib/cart.dart
class Cart {
final _items = <String, int>{};
void add(String name, int price) => _items[name] = price;
void remove(String name) => _items.remove(name);
int get count => _items.length;
int get total => _items.values.fold(0, (sum, p) => sum + p);
}
// test/cart_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/cart.dart';
void main() {
group('Cart', () {
test('starts empty', () {
expect(Cart().count, 0);
});
test('total is the sum of the prices', () {
final cart = Cart()
..add('pen', 20)
..add('book', 350);
expect(cart.total, 370);
});
});
}
The more logic you keep out of widgets, the more of your app can be tested this cheaply.
A widget to test #
// lib/counter_page.dart
import 'package:flutter/material.dart';
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(child: Text('$_count', key: const Key('count'))),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _count++),
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
The widget test #
// test/counter_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/counter_page.dart';
void main() {
testWidgets('tapping the button increments the counter', (tester) async {
// 1. Build the widget.
await tester.pumpWidget(const MaterialApp(home: CounterPage()));
// 2. Check the starting state.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// 3. Interact.
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); // rebuild after setState
// 4. Check the result.
expect(find.text('1'), findsOneWidget);
});
}
Wrap the widget in a MaterialApp, since most widgets need a theme, a navigator and text direction.
pump #
The test does not redraw on its own. You tell it when.
| Call | Does |
|---|---|
pumpWidget(widget) | Builds the widget for the first time |
pump() | Draws one frame. Use after setState |
pump(duration) | Moves the clock forward and draws a frame |
pumpAndSettle() | Keeps drawing until nothing is animating. Use after navigation or an animation |
Forgetting to pump after an action is the most common reason a widget test fails. Do not use pumpAndSettle with something that animates for ever, such as a spinner: it will time out.
Finders #
| Finder | Finds |
|---|---|
find.text('Save') | A Text with exactly that string |
find.textContaining('order') | Text containing a string |
find.byIcon(Icons.add) | An icon |
find.byType(ListTile) | Widgets of a type |
find.byKey(const Key('count')) | A widget with that key |
find.byTooltip('Increment') | A widget with that tooltip |
find.widgetWithText(FilledButton, 'Save') | A button containing that text |
find.descendant(of: a, matching: b) | b inside a |
Prefer finders that match what the user sees, such as text and tooltips. Use keys when nothing visible identifies the widget.
Matchers #
expect(find.text('Hello'), findsOneWidget);
expect(find.byType(ListTile), findsNWidgets(3));
expect(find.text('Error'), findsNothing);
expect(find.byType(Card), findsWidgets); // at least one
Actions #
await tester.tap(find.text('Save'));
await tester.longPress(find.byType(ListTile).first);
await tester.enterText(find.byType(TextField), 'asha@example.com');
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.scrollUntilVisible(find.text('Item 40'), 200);
Testing a form #
testWidgets('shows an error for an invalid email', (tester) async {
await tester.pumpWidget(const MaterialApp(home: SignUpPage()));
await tester.enterText(find.byType(TextFormField).first, 'not-an-email');
await tester.tap(find.widgetWithText(FilledButton, 'Create account'));
await tester.pump();
expect(find.text('Enter a valid email'), findsOneWidget);
});
Testing with fake data #
A widget test must not call a real server. Pass the dependency in, and give the test a fake.
abstract interface class ProductRepository {
Future<List<String>> fetchAll();
}
class FakeProductRepository implements ProductRepository {
FakeProductRepository(this.result);
final List<String> result;
@override
Future<List<String>> fetchAll() async => result;
}
testWidgets('shows the products that were loaded', (tester) async {
await tester.pumpWidget(
MaterialApp(home: ProductsPage(repository: FakeProductRepository(['Pen', 'Bag']))),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget); // loading first
await tester.pump(); // let the future complete
expect(find.text('Pen'), findsOneWidget);
expect(find.text('Bag'), findsOneWidget);
});
testWidgets('shows a message when there are no products', (tester) async {
await tester.pumpWidget(
MaterialApp(home: ProductsPage(repository: FakeProductRepository([]))),
);
await tester.pump();
expect(find.text('No products yet'), findsOneWidget);
});
With Provider, Riverpod or Bloc, wrap the widget in the provider and supply a fake, using their override mechanisms. The mocktail package creates mocks for you when hand-written fakes become tedious.
Testing different screen sizes #
testWidgets('uses a rail on wide screens', (tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(const MaterialApp(home: AppShell()));
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
});
Golden tests #
A golden test compares a rendering of your widget with a saved image, pixel by pixel, to catch unintended visual changes.
await expectLater(find.byType(ProfileCard), matchesGoldenFile('goldens/profile_card.png'));
Create or update the images with flutter test --update-goldens.
What to test #
- Each state of a screen: loading, data, empty and error.
- Validation messages.
- That tapping a button calls the right thing.
- Layout switches at breakpoints.
- Logic in models, notifiers and cubits, with unit tests.
Do not test Flutter itself, such as whether a Text shows its string. Test your behaviour.
Try it yourself #
Write widget tests for a to-do screen: one that checks the empty message, one that adds a task by entering text and tapping Add, and one that ticks a task and checks that the “1 of 1 done” summary appears.