Lesson 34 of 77 advanced

Widget Testing: UI Flows, Navigation & Forms

Testing Flutter UI in a simulated environment — finders, pumps, and interaction simulation

Open interactive version (quiz + challenge)

Real-world analogy

Widget testing is like a robot QA tester that reads your UI, taps buttons, types text, and checks what appeared on screen — all without a real phone. It is faster than manual testing and more realistic than unit tests, sitting squarely in the middle of the testing pyramid.

What is it?

Widget testing renders Flutter widgets in a headless test environment, allowing you to simulate user interactions (tap, type, scroll) and assert on the resulting UI state — without a real device. It sits between unit tests and integration tests in cost and coverage.

Real-world relevance

On a school management platform, widget tests for the student registration form caught that the 'Submit' button was not disabled during loading — a mock BLoC was injected emitting LoadingState, and the test found the button was still tappable, which would have caused duplicate submissions.

Key points

Code example

// test/presentation/widgets/login_form_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mocktail/mocktail.dart';

class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {}

void main() {
  late MockAuthBloc mockAuthBloc;

  setUp(() {
    mockAuthBloc = MockAuthBloc();
  });

  testWidgets('shows validation error when email is empty on submit',
      (WidgetTester tester) async {
    when(() => mockAuthBloc.state).thenReturn(const AuthInitialState());

    await tester.pumpWidget(
      MaterialApp(
        home: BlocProvider<AuthBloc>.value(
          value: mockAuthBloc,
          child: const LoginScreen(),
        ),
      ),
    );

    // Submit without entering any text
    await tester.tap(find.byKey(const Key('login-submit-btn')));
    await tester.pump();

    expect(find.text('Email is required'), findsOneWidget);
    expect(find.text('Password is required'), findsOneWidget);
  });

  testWidgets('shows loading indicator when AuthBloc emits LoadingState',
      (WidgetTester tester) async {
    when(() => mockAuthBloc.state).thenReturn(const AuthLoadingState());

    await tester.pumpWidget(
      MaterialApp(
        home: BlocProvider<AuthBloc>.value(
          value: mockAuthBloc,
          child: const LoginScreen(),
        ),
      ),
    );

    expect(find.byType(CircularProgressIndicator), findsOneWidget);
    expect(find.byKey(const Key('login-submit-btn')), findsNothing);
  });

  testWidgets('navigates to HomeScreen on AuthenticatedState',
      (WidgetTester tester) async {
    // Start with initial state
    whenListen(
      mockAuthBloc,
      Stream.fromIterable([
        const AuthLoadingState(),
        const AuthenticatedState(),
      ]),
      initialState: const AuthInitialState(),
    );

    await tester.pumpWidget(
      MaterialApp(
        routes: {
          '/home': (_) => const HomeScreen(),
        },
        home: BlocProvider<AuthBloc>.value(
          value: mockAuthBloc,
          child: const LoginScreen(),
        ),
      ),
    );

    await tester.pumpAndSettle();

    expect(find.byType(HomeScreen), findsOneWidget);
    expect(find.byType(LoginScreen), findsNothing);
  });
}

Line-by-line walkthrough

  1. 1. testWidgets('...', (WidgetTester tester) async {}) — the test runner injects WidgetTester as tester
  2. 2. when(() => mockAuthBloc.state).thenReturn(const AuthInitialState()) — stubs the BLoC's current state before the widget renders
  3. 3. tester.pumpWidget(MaterialApp(home: BlocProvider.value(...))) — renders the full widget tree with its required providers
  4. 4. tester.tap(find.byKey(const Key('login-submit-btn'))) — simulates a user tap; must await
  5. 5. tester.pump() — advances one frame so validators run and error widgets rebuild
  6. 6. expect(find.text('Email is required'), findsOneWidget) — asserts the error message appeared
  7. 7. whenListen(mockAuthBloc, Stream.fromIterable([...])) — simulates a stream of states for navigation test
  8. 8. tester.pumpAndSettle() — waits for the navigation animation to fully complete before asserting

Spot the bug

testWidgets('shows error on empty submit', (tester) async {
  when(() => mockAuthBloc.state).thenReturn(const AuthInitialState());

  await tester.pumpWidget(
    MaterialApp(
      home: LoginScreen(),
    ),
  );

  await tester.tap(find.byKey(const Key('submit-btn')));
  await tester.pump();

  expect(find.text('Email is required'), findsOneWidget);
});
Need a hint?
The test will throw a red screen error before even reaching the tap. Look at how LoginScreen gets its BLoC.
Show answer
Bug: LoginScreen uses BlocProvider to get AuthBloc but the test wraps it in a plain MaterialApp without providing the BLoC. Fix: wrap the widget with BlocProvider<AuthBloc>.value(value: mockAuthBloc, child: const LoginScreen()). This is one of the most common widget test setup errors.

Explain like I'm 5

Imagine you have a little robot that can look at your app's screen, press buttons, and type words — but it does not need a real phone. You tell it: 'Press the Login button without typing anything and tell me if you see an error message.' The robot checks and reports back in a fraction of a second. That is widget testing.

Fun fact

Flutter's widget test environment runs at 10x faster than real device speed because it skips GPU rendering entirely. The test harness uses a software rasteriser, which means 1000 widget tests typically run in under 60 seconds.

Hands-on challenge

Write a widget test for a form that has an email field, a password field, and a submit button. Test three scenarios: (1) empty submit shows validation errors, (2) valid input with loading state shows spinner and hides button, (3) successful auth navigates to HomeScreen.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery