Dart Tutorial

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.

KindTestsSpeedRuns on
UnitOne function or classMillisecondsYour computer
WidgetOne widget or screenFastYour computer, with no device
IntegrationThe whole appSlowA 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.

CallDoes
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 #

FinderFinds
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.

Practise in the playground Updated by Santosh Adhikari