Files
Musify/musify_flutter/.agents/skills/flutter-testing/references/widget-testing.md
T

14 KiB

Widget Testing Guide

Overview

Widget tests (component tests) verify that widgets render correctly and interact as expected. They run in a test environment that simulates widget lifecycle without a full UI system.

When to Write Widget Tests

  • Verifying widget rendering
  • Testing user interactions (taps, drags, scrolling)
  • Validating widget state changes
  • Testing different orientations and screen sizes
  • Testing form inputs and validation

Test Structure

Basic Widget Test

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('widget description', (tester) async {
    // Build widget
    await tester.pumpWidget(const MyWidget());
    
    // Find and verify
    expect(find.text('Hello'), findsOneWidget);
  });
}

Using MaterialApp

testWidgets('widget with MaterialApp', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: MyWidget(),
      ),
    ),
  );
  
  expect(find.byType(MyWidget), findsOneWidget);
});

Finding Widgets

Common Finders

// By text
find.text('Hello')
find.textContaining('ello')
find.textRegExp(RegExp(r'\d+'))

// By widget type
find.byType(Text)
find.byType(ElevatedButton)
find.byType(MyWidget)

// By key
find.byKey(const Key('my-button'))
find.byKey(const ValueKey('counter'))

// By icon
find.byIcon(Icons.add)

// By widget instance
find.byWidget(myWidgetInstance)

// By specific widget ancestor
find.ancestor(
  of: find.text('Child'),
  matching: find.byType(Column),
)

Finder Matchers

// Find exactly one
expect(find.text('Hello'), findsOneWidget);

// Find multiple
expect(find.text('Hello'), findsWidgets);

// Find nothing
expect(find.text('Missing'), findsNothing);

// Find at least N
expect(find.text('Item'), findsNWidgets(3));

User Interactions

Tapping

// Tap a widget
await tester.tap(find.byType(ElevatedButton));

// Trigger a frame
await tester.pump();

// Wait for all animations
await tester.pumpAndSettle();

Dragging

// Drag a widget
await tester.drag(
  find.byType(MyWidget),
  const Offset(0, -300),
);
await tester.pumpAndSettle();

Entering Text

// Enter text into TextField
await tester.enterText(
  find.byType(TextField),
  'Hello World',
);

// Tap to focus, then enter
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'Text');

// Enter by key
await tester.enterText(
  find.byKey(const Key('email-field')),
  'test@example.com',
);

Scrolling

// Scroll a ListView
await tester.fling(
  find.byType(ListView),
  const Offset(0, -500),
  10000,
);
await tester.pumpAndSettle();

// Scroll until widget is visible
await tester.scrollUntilVisible(
  find.text('Target Item'),
  500.0,
);

Long Press

await tester.longPress(find.byType(IconButton));
await tester.pumpAndSettle();

Testing Widget Properties

Text Content

testWidgets('displays correct text', (tester) async {
  await tester.pumpWidget(const MyWidget(text: 'Hello'));
  
  expect(find.text('Hello'), findsOneWidget);
  expect(find.text('Goodbye'), findsNothing);
});

Widget State

testWidgets('updates state on button press', (tester) async {
  await tester.pumpWidget(const MyApp());
  
  // Initial state
  expect(find.text('Count: 0'), findsOneWidget);
  
  // Tap increment button
  await tester.tap(find.byKey(const Key('increment')));
  await tester.pumpAndSettle();
  
  // Updated state
  expect(find.text('Count: 1'), findsOneWidget);
});

Conditional Rendering

testWidgets('shows loading indicator when loading', (tester) async {
  await tester.pumpWidget(MyWidget(isLoading: true));
  
  expect(find.byType(CircularProgressIndicator), findsOneWidget);
  expect(find.byType(ElevatedButton), findsNothing);
});

testWidgets('shows button when not loading', (tester) async {
  await tester.pumpWidget(MyWidget(isLoading: false));
  
  expect(find.byType(CircularProgressIndicator), findsNothing);
  expect(find.byType(ElevatedButton), findsOneWidget);
});

Testing with Arguments

testWidgets('receives and displays title', (tester) async {
  const title = 'My Title';
  
  await tester.pumpWidget(
    MaterialApp(
      home: MyWidget(title: title),
    ),
  );
  
  expect(find.text(title), findsOneWidget);
});

testWidgets('receives and uses callback', (tester) async {
  bool callbackCalled = false;
  
  await tester.pumpWidget(
    MaterialApp(
      home: MyWidget(
        onPressed: () => callbackCalled = true,
      ),
    ),
  );
  
  await tester.tap(find.byType(ElevatedButton));
  expect(callbackCalled, true);
});

Testing Orientation

testWidgets('widget in portrait mode', (tester) async {
  await tester.binding.setSurfaceSize(const Size(400, 800));
  await tester.pumpWidget(const MyApp());
  
  // Verify portrait layout
  expect(find.byType(Column), findsOneWidget);
  
  // Clean up
  addTearDown(tester.binding.setSurfaceSize(null));
});

testWidgets('widget in landscape mode', (tester) async {
  await tester.binding.setSurfaceSize(const Size(800, 400));
  await tester.pumpWidget(const MyApp());
  
  // Verify landscape layout
  expect(find.byType(Row), findsOneWidget);
  
  // Clean up
  addTearDown(tester.binding.setSurfaceSize(null));
});

Testing Scrollable Widgets

ListView Scrolling

testWidgets('scrolling in ListView', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: ListView.builder(
        itemCount: 100,
        itemBuilder: (context, index) => ListTile(
          title: Text('Item $index'),
        ),
      ),
    ),
  );
  
  // Initial item visible
  expect(find.text('Item 0'), findsOneWidget);
  expect(find.text('Item 50'), findsNothing);
  
  // Scroll down
  await tester.fling(
    find.byType(ListView),
    const Offset(0, -5000),
    10000,
  );
  await tester.pumpAndSettle();
  
  // Later item now visible
  expect(find.text('Item 50'), findsOneWidget);
});

Scroll Until Visible

testWidgets('scroll until specific item is visible', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: ListView.builder(
        itemCount: 100,
        itemBuilder: (context, index) => ListTile(
          key: ValueKey('item-$index'),
          title: Text('Item $index'),
        ),
      ),
    ),
  );
  
  // Scroll until item 75 is visible
  await tester.scrollUntilVisible(
    find.byKey(const ValueKey('item-75')),
    500.0,
  );
  
  expect(find.text('Item 75'), findsOneWidget);
});

Testing Forms

TextField Validation

testWidgets('validates email field', (tester) async {
  final formKey = GlobalKey<FormState>();

  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: Form(
          key: formKey,
          child: Column(
            children: [
              TextFormField(
                key: const Key('email'),
                validator: (value) {
                  if (value == null || !value.contains('@')) {
                    return 'Invalid email';
                  }
                  return null;
                },
              ),
              ElevatedButton(
                key: const Key('submit'),
                onPressed: () => formKey.currentState!.validate(),
                child: const Text('Submit'),
              ),
            ],
          ),
        ),
      ),
    ),
  );
  
  // Enter invalid email
  await tester.enterText(find.byKey(const Key('email')), 'invalid');
  await tester.tap(find.byKey(const Key('submit')));
  await tester.pump();
  
  // Should show error
  expect(find.text('Invalid email'), findsOneWidget);
  
  // Enter valid email
  await tester.enterText(find.byKey(const Key('email')), 'test@example.com');
  await tester.tap(find.byKey(const Key('submit')));
  await tester.pump();
  
  // Should hide error
  expect(find.text('Invalid email'), findsNothing);
});

Multiple Form Fields

testWidgets('form with multiple fields', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: Form(
          child: Column(
            children: [
              TextFormField(
                key: const Key('name'),
                decoration: const InputDecoration(labelText: 'Name'),
              ),
              TextFormField(
                key: const Key('email'),
                decoration: const InputDecoration(labelText: 'Email'),
              ),
              ElevatedButton(
                key: const Key('submit'),
                onPressed: () {},
                child: const Text('Submit'),
              ),
            ],
          ),
        ),
      ),
    ),
  );
  
  // Fill form
  await tester.enterText(find.byKey(const Key('name')), 'John Doe');
  await tester.enterText(find.byKey(const Key('email')), 'john@example.com');
  
  // Submit
  await tester.tap(find.byKey(const Key('submit')));
  await tester.pumpAndSettle();
});

Testing Animations

testWidgets('animation plays correctly', (tester) async {
  await tester.pumpWidget(const AnimatedWidget());
  
  // Initial state
  expect(find.byType(Opacity), findsOneWidget);
  
  // Trigger animation
  await tester.tap(find.byType(ElevatedButton));
  
  // Pump animation frames
  await tester.pump();
  await tester.pump(const Duration(milliseconds: 100));
  await tester.pump(const Duration(milliseconds: 100));
  
  // Animation complete
  await tester.pumpAndSettle();
  
  // Verify final state
  final opacity = tester.widget<Opacity>(find.byType(Opacity));
  expect(opacity.opacity, 0.0);
});

Testing Custom Widgets

Testing InheritedWidgets

class MyInheritedWidget extends InheritedWidget {
  final String data;
  
  const MyInheritedWidget({
    super.key,
    required this.data,
    required Widget child,
  }) : super(child: child);
  
  static MyInheritedWidget of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<MyInheritedWidget>()!;
  }
  
  @override
  bool updateShouldNotify(MyInheritedWidget oldWidget) =>
      data != oldWidget.data;
}

testWidgets('inherited widget provides data', (tester) async {
  await tester.pumpWidget(
    MyInheritedWidget(
      data: 'Test Data',
      child: Builder(
        builder: (context) {
          return Text(MyInheritedWidget.of(context).data);
        },
      ),
    ),
  );
  
  expect(find.text('Test Data'), findsOneWidget);
});

Testing StatefulWidget

class CounterWidget extends StatefulWidget {
  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _count = 0;
  
  void _increment() => setState(() => _count++);
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Count: $_count'),
        ElevatedButton(
          key: const Key('increment'),
          onPressed: _increment,
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

testWidgets('CounterWidget increments correctly', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: CounterWidget(),
      ),
    ),
  );
  
  // Initial count
  expect(find.text('Count: 0'), findsOneWidget);
  
  // Increment
  await tester.tap(find.byKey(const Key('increment')));
  await tester.pump();
  
  expect(find.text('Count: 1'), findsOneWidget);
});

Testing with Mock Data

class ProductList extends StatelessWidget {
  final List<Product> products;
  
  const ProductList({super.key, required this.products});
  
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: products.length,
      itemBuilder: (context, index) => ListTile(
        title: Text(products[index].name),
        subtitle: Text('\$${products[index].price}'),
      ),
    );
  }
}

testWidgets('displays list of products', (tester) async {
  final products = [
    Product(name: 'Product 1', price: 10.99),
    Product(name: 'Product 2', price: 20.99),
  ];
  
  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: ProductList(products: products),
      ),
    ),
  );
  
  expect(find.text('Product 1'), findsOneWidget);
  expect(find.text('\$10.99'), findsOneWidget);
  expect(find.text('Product 2'), findsOneWidget);
  expect(find.text('\$20.99'), findsOneWidget);
});

Testing Accessibility

testWidgets('widget has proper semantics', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: Semantics(
        label: 'Submit button',
        button: true,
        child: ElevatedButton(
          onPressed: () {},
          child: const Text('Submit'),
        ),
      ),
    ),
  );
  
  final semantics = tester.getSemantics(find.byType(ElevatedButton));
  expect(semantics.label, 'Submit button');
  expect(semantics.hasAction(SemanticsAction.tap), true);
});

Best Practices

  1. Use descriptive test names - Explain what and why
  2. Keep tests independent - Each test should be standalone
  3. Use pumpAndSettle() - Wait for all animations
  4. Test user behavior, not implementation - Focus on what users see and do
  5. Group related tests - Use group() for organization
  6. Test edge cases - Empty lists, null values, errors
  7. Use keys for widgets - Makes finding easier and more reliable
  8. Test accessibility - Verify semantic labels and actions

Common Patterns

Testing Navigation

testWidgets('navigates to detail screen', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      initialRoute: '/',
      routes: {
        '/': (context) => const HomeScreen(),
        '/detail': (context) => const DetailScreen(),
      },
    ),
  );
  
  // Tap navigation button
  await tester.tap(find.text('Go to Detail'));
  await tester.pumpAndSettle();
  
  // Verify navigation
  expect(find.text('Detail Screen'), findsOneWidget);
  expect(find.text('Home Screen'), findsNothing);
});

Testing Loading States

testWidgets('shows loading then content', (tester) async {
  bool isLoading = true;
  
  await tester.pumpWidget(
    MaterialApp(
      home: StatefulBuilder(
        builder: (context, setState) {
          return isLoading
              ? const CircularProgressIndicator()
              : const Text('Content Loaded');
        },
      ),
    ),
  );
  
  expect(find.byType(CircularProgressIndicator), findsOneWidget);
  
  // Simulate data load
  isLoading = false;
  await tester.pump();
  
  expect(find.byType(CircularProgressIndicator), findsNothing);
  expect(find.text('Content Loaded'), findsOneWidget);
});