Optimizing Widget Dependencies
In Flutter, widgets are the cornerstone of any application. However, while they're vital, they can often become tangled with dependencies, particularly as your project scales. This guide seeks to address those complexities, focusing on how to make your widgets more maintainable, especially within the context of Widgetbook.
Optimizing for Widgetbook#
This guide explores how to optimize widget dependencies in Flutter for both Widgetbook and easier unit testing. From basic examples to complex scenarios, we'll delve into the process of decoupling widget logic for better maintainability and scalability.
1. The Challenge with Direct Dependencies#
While tools like Widgetbook facilitate widget cataloging and testing, a direct dependency on data sources or business logic can pose challenges. When widgets directly rely on data sources or logic, we encounter two primary issues:
- Widgetbook Cataloging: Dependencies make widget cataloging a more tedious task.
- Testing Difficulties: Directly linked logic makes unit tests less precise, as they often test more than one 'unit' at once. Also, it becomes harder to test.
1.1 Basic Direct Dependency Example#
Here's a widget dependent on a Bloc:
class ExampleWidget extends StatelessWidget {
const ExampleWidget({super.key});
@override
Widget build(BuildContext context) {
final name = context.read<ValueBloc>().name;
return Text(name);
}
}
@UseCase(name: 'default', type: ExampleWidget)
Widget exampleUseCase(BuildContext context) {
return BlocProvider(
create: (context) => ValueBloc(name: 'This is the name'),
child: ExampleWidget(),
);
}
In this widget, ExampleWidget
reads data from ValueBloc
. This hard-coded
dependency is what makes testing and cataloging problematic.
2. Separation of Concerns#
To address the issues above, we can separate our concerns:
let the widget handle UI and presentation while an external entity deals with data and logic.
2.1 Refactoring the Basic Example#
Here’s how ExampleWidget
can be redesigned to receive its data externally:
class ExampleWidgetRefactored extends StatelessWidget {
const ExampleWidgetRefactored({super.key, required this.name});
final String name;
@override
Widget build(BuildContext context) {
return Text(name);
}
}
@UseCase(name: 'refactored', type: ExampleWidgetRefactored)
Widget exampleUseCase(BuildContext context) {
return ExampleWidgetRefactored(name: 'This is the name');
}
This refactoring ensures the widget doesn't know where the data comes from—it just presents what it receives. You can fetch data from a Bloc, an API, or even hard-coded values. The widget remains untouched in all cases.
3. Mocking for Testability and Development#
Separating concerns facilitates mocking. Mocking is the process of simulating parts of the system to isolate what we're testing. For widgets, this means we can check their behavior under various conditions without connecting to actual data sources.
Mocking offers:
- Speed: Tests run faster as they bypass the actual logic or data fetching.
- Reliability: Tests aren't affected by external failures (e.g., an API being down).
- Precision: We test exactly what we intend to, no more and no less.
3.1 Mocking the Basic Example#
Given the refactored widget, you can easily mock the name to test the widget:
void main() {
testWidgets('displays the passed name', (WidgetTester tester) async {
await tester.pumpWidget(ExampleWidgetRefactored(name: 'Test Name'));
expect(find.text('Test Name'), findsOneWidget);
});
}
This test checks if ExampleWidgetRefactored
correctly displays a passed name.
4. Addressing Complex Scenarios#
When developing applications, it's common to encounter widgets that are deeply nested and have multiple dependencies. Such scenarios can complicate code maintenance, scaling, and especially testing. In this section, we'll tackle a complex scenario, dissect the challenges it presents, and refactor it to a more maintainable and testable structure.
4.1 Initial Nested Widget Scenario#
Consider the following widget structure, which depicts a typical complex scenario:
class ParentWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
ChildWidgetA(),
ChildWidgetB(),
],
);
}
}
class ChildWidgetA extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dataA = context.read<DataBloc>().itemA;
return Text(dataA);
}
}
class ChildWidgetB extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dataB = context.read<DataBloc>().itemB;
return Text(dataB);
}
}
Here's what's happening:
ParentWidget
is a simple widget holding two children in a column.ChildWidgetA
fetchesitemA
fromDataBloc
to display it.ChildWidgetB
fetchesitemB
fromDataBloc
to display it.
Challenges#
- Tightly coupled with
DataBloc
: BothChildWidgetA
andChildWidgetB
are directly dependent onDataBloc
. This makes it hard to reuse these widgets in different contexts or with different data sources. - Reduced testability: Testing these widgets individually becomes tricky since they're tightly coupled to a specific Bloc.
This interlinked structure is tough to manage and test.
4.2 Refactoring Approach#
To refactor, our goals are:
- Decoupling from DataBloc: By passing data directly to widgets.
- Enhancing Testability: By reducing direct dependencies, widgets become more unit-test-friendly.
- Increasing Reusability: Decoupled widgets can be reused in various parts of the application with different data.
4.3 Refactored Code#
Here's the refactored structure:
class ParentWidget extends StatelessWidget {
final String itemA;
final String itemB;
ParentWidget({
required this.itemA,
required this.itemB,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
ChildWidgetA(data: itemA),
ChildWidgetB(data: itemB),
],
);
}
}
class ChildWidgetA extends StatelessWidget {
final String data;
ChildWidgetA({required this.data});
@override
Widget build(BuildContext context) {
return Text(data);
}
}
class ChildWidgetB extends StatelessWidget {
final String data;
ChildWidgetB({required this.data});
@override
Widget build(BuildContext context) {
return Text(data);
}
}
Let's see what is happening:
-
Decoupling from DataBloc:
ChildWidgetA
andChildWidgetB
no longer fetch data directly fromDataBloc
. Instead, they accept the required data as a constructor parameter. This decoupling means they aren't tied to a specific data source. -
Enhancing Testability: Testing
ChildWidgetA
andChildWidgetB
becomes straightforward. You can pass mock data to them directly, making unit testing a breeze. No need to set up a mockDataBloc
or navigate complex widget trees to test simple logic. -
Increasing Reusability: Both child widgets can now be reused with different data sources or even in entirely different applications. They just require the necessary data to be passed to them.
4.4 Mocking and Testing the Refactored Widgets#
Given that our widgets are now decoupled and accept data directly via constructors, the testing process becomes straightforward. Here's how you can test the refactored widgets:
void main() {
group('ChildWidgetA Tests', () {
testWidgets('displays the correct data', (WidgetTester tester) async {
// 1. Define the mock data
final mockData = 'Mocked Data A';
// 2. Build our widget with the mock data
await tester.pumpWidget(ChildWidgetA(data: mockData));
// 3. Check if the widget displays the mocked data
expect(find.text(mockData), findsOneWidget);
});
});
group('ChildWidgetB Tests', () {
testWidgets('displays the correct data', (WidgetTester tester) async {
// 1. Define the mock data
final mockData = 'Mocked Data B';
// 2. Build our widget with the mock data
await tester.pumpWidget(ChildWidgetB(data: mockData));
// 3. Check if the widget displays the mocked data
expect(find.text(mockData), findsOneWidget);
});
});
}
Explanation:
-
Define the Mock Data: We're creating simple string data for our mock. This emulates what the widget would typically receive in a real-world scenario.
-
Building the Widget with Mock Data: We tell the widget tester to build our target widget and supply it with the mock data.
-
Assertions: We simply check if our widget displays the text that matches our mock data.
These tests ensure that given some data, ChildWidgetA
and ChildWidgetB
display it correctly. Due to the simplicity of these widgets, the tests are
pretty straightforward. If the widgets had more complex logic or behavior, we
would expand our tests to cover those aspects.
Sum up#
By addressing these complex scenarios in such a manner, we ensure that our widget tree remains scalable, maintainable, and testable. Future changes or additions to the application become easier to manage, and individual components can be tested and reused with ease.