Testing
Overview
Testing is a crucial part of building robust and maintainable applications. The Flutter Kit is designed with testability in mind. This guide outlines our recommended approach, distinguishing between Unit and Widget tests based on a simple framework:
- Unit Tests:
input <-> output
- Widget Tests:
action <-> result
Unit Testing (input <-> output
)
Unit tests are best suited for testing isolated pieces of logic, particularly pure functions or methods with predictable inputs and outputs. They should be fast, self-contained, and have minimal dependencies. The goal is to verify that given a specific input, the function reliably produces the expected output.
When to Use:
- Utility or helper functions.
- Specific data transformation/validation logic that can be easily isolated.
Example:
Consider a simple utility function:
bool isEmailValid(String email) { if (email.isEmpty) return false; // Basic check for demonstration return email.contains('@') && email.contains('.');}
A unit test verifies its input/output behavior:
void main() { test('isEmailValid should return true for valid emails', () { expect(isEmailValid('test@example.com'), isTrue); });
test('isEmailValid should return false for invalid emails', () { expect(isEmailValid('testexample.com'), isFalse); expect(isEmailValid('test@examplecom'), isFalse); expect(isEmailValid(''), isFalse); });}
Complex classes with multiple dependencies (like full ViewModels or Services interacting with external systems) are generally better tested through Widget tests in this architecture, as isolating them for pure unit tests often requires excessive mocking.
For more context on the input <-> output
approach, refer to the Widget vs Unit Testing article.
Widget Testing (action <-> result
)
Widget tests are the primary way to test the functionality of your features. In the MVVM pattern used by the boilerplate, a View and its ViewModel are tightly coupled (1-to-1) and should be tested together as a single unit. The focus is on verifying that user actions lead to the expected results in the UI and state.
When to Use:
- Testing the behavior of a complete screen or component.
- Verifying UI rendering based on ViewModel state.
- Ensuring interactions (button taps, form submissions) trigger the correct logic in the ViewModel and update the UI accordingly.
Example:
Imagine a simple counter view/viewmodel:
class CounterViewModel { final ValueNotifier<int> counter = ValueNotifier(0);
void incrementCounter() { counter.value++; }
void dispose() { counter.dispose(); }}
class CounterView extends StatefulWidget { const CounterView({super.key});
@override State<CounterView> createState() => _CounterViewState();}
class _CounterViewState extends State<CounterView> { late final CounterViewModel _viewModel;
@override void initState() { super.initState(); _viewModel = CounterViewModel(); }
@override void dispose() { _viewModel.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return Scaffold( body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ ValueListenableBuilder<int>( valueListenable: _viewModel.counter, builder: (context, value, _) { return Text('Counter: $value'); }, ), ElevatedButton( onPressed: _viewModel.incrementCounter, child: const Text('Increment'), ), ], ), ); }}
A widget test verifies the action-result flow:
void main() { testWidgets('CounterView increments counter when button is pressed', (WidgetTester tester) async { // Arrange: Pump the widget await tester.pumpWidget(MaterialApp(home: CounterView()));
// Assert: Initial state (result) expect(find.text('Counter: 0'), findsOneWidget);
// Act: Simulate user action (tap the button) await tester.tap(find.byType(ElevatedButton)); await tester.pump(); // Rebuild the widget tree
// Assert: Final state (result after action) expect(find.text('Counter: 1'), findsOneWidget); });}
Notice how we test the CounterView
directly. The test verifies that the action (tapping the button) correctly interacts with the ViewModel logic (instantiated within the View’s state) and produces the expected result (the text updates to ‘Counter: 1’). This single test covers both the View’s rendering and the ViewModel’s core logic for this feature.
For more details on the action <-> result
approach and further examples, refer to the Widget vs Unit Testing article.
While Golden Tests and Integration Tests are also valuable, this guide focuses on the foundational unit (input <-> output
) and widget (action <-> result
) tests frequently used during development.