widget_driver 0.1.0 widget_driver: ^0.1.0 copied to clipboard
A Flutter presentation layer framework, which will clean up your widget code and make your widgets testable without a need for thousands of mock objects. Let's go driving!
WidgetDriver #
A Flutter presentation layer framework, which will clean up your
widget code and make your widgets testable without a need for thousands of mock objects.
Let's go driving! 🚙💨
Features #
In Flutter everything is a Widget. And that is great!
But maybe you should not put all of your code directly in the widgets.
Doing that will:
- give you a headache when you try to write unit tests
- make it tougher to reuse your code
- clutter the declarative UI part of Flutter
WidgetDriver
to the rescue! WidgetDriver
gives you a MVVM style presentation layer framework.
It effectively guides you into moving all of the business logic parts of your code out from widgets and into something called WidgetDrivers
.
Core features of WidgetDriver #
- Clear separation of concern.
Business logic goes in theDriver
and only the view logic goes in theWidget
- Better testability of your
Widgets
.
When you useWidgetDriver
then you can test yourWidgets
in isolation!
You do not need to mock hundreds of child dependencies!
Testing benefits #
We have all been there. We write widgetTests
and over time we end up with 90% of our testing code being about creating mock objects. Why is that?
Well since widgets tend to contain other child widgets and these widget contains yet more widgets. All these widgets have their own set of dependencies which needs to be resolved when they get created.
So when you want to write a simple widgetTest
for a widget, then you end up needing to provide mock data for all the dependencies of all your children. 😱
WidgetDriver
to the rescue again! WidgetDriver
uses a special TestDriver
when you are running tests. These TestDrivers
provide predefined default values to all child widgets so that you do not need to provide any dependencies! 🥳
This mean you can finally test your widgets in isolation!
Just focus on the current widget under test, and forget about all other child widgets and their dependencies!
Installation #
Update your pubspec.yaml
with this:
dependencies:
widget_driver: <latest_version>
...
dev_dependencies:
build_runner: <latest_version>
widget_driver_generator: <latest_version>
Usage #
Let's get started and create our first WidgetDriver
!
1: counter_widget_driver.dart #
part 'counter_widget_driver.g.dart';
@GenerateTestDriver()
class CounterWidgetDriver extends WidgetDriver {
final CounterService _counterService;
final Localization _localization;
StreamSubscription? _subscription;
CounterWidgetDriver(
BuildContext context, {
CounterService? counterService,
}) : _counterService = counterService ?? GetIt.I.get<CounterService>(),
_localization = context.read<Localization>(),
super(context) {
_subscription = _counterService.valueStream.listen((_) {
notifyWidget();
});
}
@TestDriverDefaultValue('The title of the counter')
String get counterTitle => _localization.counterTitle;
@TestDriverDefaultValue(1)
int get value => _counterService.value;
@TestDriverDefaultValue()
void increment() {
_counterService.increment();
}
@TestDriverDefaultValue()
void decrement() {
_counterService.decrement();
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
void didUpdateBuildContextDependencies(BuildContext context) {}
}
Okay wait, what was all that?! That looks very complicated!
Well, not really. Let's dive into what happens:
-
First we just define a driver which extend the
WidgetDriver
.
The "part '...'
" definition and the@GenerateTestDriver()
annotation above it is needed later for the code generation to work.part 'counter_widget_driver.g.dart'; @GenerateTestDriver() class CounterWidgetDriver extends WidgetDriver {
And thats all you need to conform to the
WidgetDriver
interface. The rest of the code in aWidgetDriver
depends on your use case. -
Next, we define the dependencies which the driver needs. In our case we need access to some service which can keep track of the count and we need some localizations.
In the constructor of the driver we have the option to resolve these dependencies either from theBuildContext
(for example using something like the Provider package), or we can load them using a DI package such as get_it. (There are some caveats, please refer to Working with changing dependencies injected into the BuildContext)final CounterService _counterService; final Locator _locator; StreamSubscription? _subscription; CounterWidgetDriver( BuildContext context, { CounterService? counterService, }) : _counterService = counterService ?? GetIt.I.get<CounterService>(), _locator = context.read, super(context) { ... }
-
Now we need to add some logic where the
Driver
listens to changes from theCounterService
and when a changes happens, then it will notify the widget that it needs to update. You do this by calling thenotifyWidget()
method in the driver. As soon as yourDriver
has new data to display, then you want to call thenotifyWidget()
._subscription = _counterService.valueStream.listen((_) { notifyWidget(); });
-
Finally, we define the public API of the driver. Your widget will use these to grab data for its views.
The annotations are used by the
TestDriver
to provide default values during testing.For each public property and method you add the
@TestDriverDefaultValue({default value})
. As a parameter to this annotation you provide the default value which will be used in testing when other widgets create this widget.If you have a method or property which returns a future, then you can use the
@TestDriverDefaultFutureValue({default value})
instead. It will take the default value and return it as a future.@TestDriverDefaultValue(1) int get value => _counterService.value; @TestDriverDefaultValue() void increment() { _counterService.increment(); } // For methods which return futures, use this annotations: @TestDriverDefaultFutureValue(123) Future<int> getTheNextIncrement() { return _counterService.getNextIncrement(); }
As mentioned, these annotations are used by the
TestDriver
, and you have to provide them! The values provided there are only used in testing. Never in any production code. Because in production you use the real business logic.But in your
widgetTests
these values are used when your widget is rendered as a child-widget. This helps you to test widgets in isolation, without caring about which dependencies all your child widgets need.For a more in-depth documentation about
TestDrivers
read here.
Thats it! Wohoo! 🥳
Now we can continue to the next step!
2: Code generation #
Now it is time to generate some code so that we get our TestDriver
and WidgetDriverProvider
set up for us.
If you prefer to do this the old fashioned way without code generation, then that is also possible.
Read more about that here
At the root of your flutter project, run this command:
flutter pub run build_runner build
When the build runner completes then we are ready to start building our widget
3: counter_widget.dart #
class CounterWidget extends DrivableWidget<CounterWidgetDriver> {
@override
Widget build(BuildContext context) {
return Column(children: [
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(driver.counterTitle),
const SizedBox(width: 5),
Text('${driver.value}'),
],
),
ElevatedButton(
onPressed: driver.increment,
child: const Text('increment'),
),
ElevatedButton(
onPressed: driver.decrement,
child: const Text('decrement'),
),
]);
}
@override
WidgetDriverProvider<CounterWidgetDriver> get driverProvider => $CounterWidgetDriverProvider();
}
And voila, we are done!
Now check out that widget code! Isn't that clean.
Just focused on UI. No StreamBuilders or BuildContext being watched.
No business logic in your view code!
And no dependency being created or resolved!
Let's go over what happens:
-
First we name our widget
CounterWidget
and then we need to make itdrivable
! We do this by making it extendDrivableWidget
. Pass in the name of yourDriver
as a generic type to theDrivableWidget
.class CounterWidget extends DrivableWidget<CounterWidgetDriver> {
-
Now your
IDE
will complain and say that you areMissing concrete implementation ...
To fix this, tap theCreate 2 missing overrides
-button and voila! You get some placeholder code which looks like this:@override Widget build(BuildContext context) { ... } @override // TODO: implement driverProvider WidgetDriverProvider<CounterWidgetDriver> get driverProvider => throw UnimplementedError();
The build method is nothing new. There you just put your widget creation code!
And thedriverProvider
is the thing which knows how to create your driver. Now what do you put here?
No worries, we generated aDriverProvider
for you 🤩 Just replace that line with this:@override WidgetDriverProvider<CounterWidgetDriver> get driverProvider => $CounterWidgetDriverProvider();
For each
WidgetDriver
you get a generatedWidgetDriverProvider
. They are all prefixed with$
to indicate that they are generated. So just grab the provider which belongs to your driver and create an instance of it here. -
And that is all you need to do!
Now your widget will automatically have access to a property called
driver
. This driver is the very sameWidgetDriver
which we defined earlier. So now you can access all the data from it and assign it to your widgets. For example you can say this:Text(driver.counterTitle),
4: Handling updates to data in WidgetDriver #
But what if my data changes? How do I update the widget? Do I need a builder
? Or a context.watch
?
NO! The WidgetDriver frameworks handles this for you!
The driver decides when the widget needs to update. So inside your driver code, whenever something important changed and you want to update the widget, then you just call the notifyWidget()
.
This will automatically make sure that your widget get reloaded and it can consume the latest values from your driver.
If you do want to pass data from the widget to the driver #
Say we have a ListView which contains multiple coffees. When the user clicks on one of the items, we want to redirect him to a details page. So how do we pass that coffee object through our widget to the driver to properly use it. Easy...
-
First, we annotate the variable in the driver with the
@driverProvidableProperty
annotation. This tells the generator to allow this variable to be passed through from the widget.import 'package:widget_driver/widget_driver.dart'; import '../../../../models/coffee.dart'; part 'coffee_detail_page_driver.g.dart'; @GenerateTestDriver() class CoffeeDetailPageDriver extends WidgetDriver { final int index; final Coffee _coffee; CoffeeDetailPageDriver( BuildContext context, @driverProvidableProperty this.index, { @driverProvidableProperty required Coffee coffee, }) : _coffee = coffee, super(context); @TestDriverDefaultValue(TestCoffee.testCoffeeName) String get coffeeName { return '$index. ${_coffee.name}'; } @TestDriverDefaultValue(TestCoffee.testCoffeeDescription) String get coffeeDescription { return _coffee.description; } @TestDriverDefaultValue(TestCoffee.testCoffeeImageUrl) String get coffeeImageUrl { return _coffee.imageUrl; } }
-
Then we just run the generator like we did before...
-
We should get a compiler warning in the generated file. To resolve that we just have to add the generated abstract class
_$DriverProvidedProperties
to our driver like this:@GenerateTestDriver() class CoffeeDetailPageDriver extends WidgetDriver implements _$DriverProvidedProperties { ... }
-
This requires us to override
didUpdateProvidedProperties(...)
which gets called whenever the corresponding widgets gets asked to re-render by its parent (same asdidUpdateWidget
). That way we can respond to new values to our provided properties given to us by the widget. (Technical Note: This is because the Driver does not get rebuilt when the widget gets rebuilt. And a call tonotifyWidget()
is not necessary, this function gets called before the widget shows the new data.)@GenerateTestDriver() class CoffeeDetailPageDriver extends WidgetDriver implements _$DriverProvidedProperties { int index; Coffee _coffee; CoffeeDetailPageDriver( BuildContext context, @driverProvidableProperty this.index, { @driverProvidableProperty required Coffee coffee, }) : _coffee = coffee, super(context); ... @override void didUpdateProvidedProperties( int newIndex, Coffee newCoffee, ) { index = newIndex; _coffee = newCoffee; // And whatever else you want to do on state change. } }
-
After that we just need to hand the variable over to the
DriverProvider
and that's it. 🥳class CoffeeDetailPage extends DrivableWidget<CoffeeDetailPageDriver> { final int index; final Coffee coffee; CoffeeDetailPage({Key? key, required this.index, required this.coffee}) : super(key: key); @override Widget build(BuildContext context) { ... } @override WidgetDriverProvider<CoffeeDetailPageDriver> get driverProvider => $CoffeeDetailPageDriverProvider( index: index, coffee: coffee, ); }
If you want to pass data to the driver via the BuildContext #
You can have your Driver
grab dependencies from the BuildContext
in its constructor.
There are typically 2 options here:
-
Either you grab the dependency once by looking up the
BuildContext
ancestor tree. For example if you are using theProvider
package, then you would do this with acontext.read<MyDependencyType>()
-
Or you grab your dependency as an inherited widget from the
BuildContext
. For example if you are using theProvider
package, then you would do this with acontext.watch<MyDependencyType>()
.
If you grab the dependency as an inherited widget then when/if your dependency ever changes, the framework will call this method on your driver: didUpdateBuildContextDependencies(BuildContext context)
.
There you get access to the BuildContext
again and can grab the latest version of your dependency.
This is important! Since the constructor of the driver
is only called once. If you forget to handle potential updates in the didUpdateBuildContextDependencies
method, then your data in the driver might be outdated.
The didUpdateBuildContextDependencies
method is only called by the framework if your driver
declares a dependency to an inherited widget in its constructor. In case you do not do this, then you can leave this method implementation empty.
Example grabbing a dependency with Provider
We want to read a ThemeDataServiceService from the context, which could change between light and dark Theme at runtime. If we were to resolve it like this:
class SomeDriver extends WidgetDriver {
final ThemeDataServiceService _themeDataService;
SomeDriver(
BuildContext context, {
ThemeDataService? themeDataService,
}) : _themeDataService = themeDataService ?? context.read<ThemeDataService>(),
super(context);
...
// No need to do anything in this method since we only get the dependency using `context.read`
void didUpdateBuildContextDependencies(BuildContext context) {}
}
The driver would not get an update should the ThemeDataService be changed.
The Provider
package however offers us the option to watch
the provided value.
class SomeDriver extends WidgetDriver {
ThemeDataService _themeDataService;
SomeDriver(
BuildContext context, {
ThemeDataService? themeDataService,
}) : _themeDataService = themeDataService ?? context.watch<ThemeDataService>(),
super(context);
...
// Now we are using `context.watch`. So we will get notified if the `ThemeDataService` would be recreated.
// So we need to grab it again from the context.
void didUpdateBuildContextDependencies(BuildContext context) {
_themeDataService = context.watch<ThemeDataService>(),
}
}
Another approach to get data from the BuildContext
is to use the Locator
which is offered by the Provider
package. Then you store a reference to the Locator
in your driver
and you access content from it on demand when needed.
This way you do not grab data from the context and persist that data which at some point might get outdated.
Instead you hold a reference to a Locator
which knows how to read data from the context.
The same rule applies though, if you grab a Locator
using final locator = context.read
then your driver will not get notified if a dependency which you need changes. So you still need to combine this with the usage of context.watch.
The benefit using a Locator
is that you do not need to reassign anything in the didUpdateBuildContextDependencies
method. Since your Locator
always have access to the correct BuildContext
and can always look up the correct data.
Here is an example using the locator
approach:
class SomeDriver extends WidgetDriver {
final Locator _locator
SomeDriver(
BuildContext context,
) : _locator = context.read,
super(context) {
context.watch<ThemeDataService>()
};
bool get isDarkMode => _locator<ThemeDataService>().isDarkMode;
void didUpdateBuildContextDependencies(BuildContext context) {}
}
If your WidgetDriver exposes classes that require a lot of overrides #
Some classes have a lot of fields and functions that have to be overridden to construct them.
In this case, adding a proper TestDriverDefaultValue can be burdening. To make it easier, we added
a class called EmptyDefault
. By extending this and implementing the
complex class you want the test driver to mock, you can create a empty test class that you can
pass to the Test Driver.
-
Create the testDriver class by extending
EmptyDefault
and implementing the class you want to pass to the Widget Driverclass _TestDriverMyComplexService extends EmptyDefault implements MyComplexService { const _TestDriverReadyToPairConfirmationService(); }
-
Construct the newly created class and pass it as
TestDriverDefaultValue
class MyWidgetDriver extends WidgetDriver { ... @TestDriverDefaultValue(_TestDriverMyComplexService()) MyComplexService get value => myComplexService; ... }
Demo #
Here is a demo of how the final product looks like
Guidelines #
WidgetDriver
gives you two big benefits.
- Move business logic out of the widget. Clean up the UI code.
- Test widgets in isolation without the of mocks mocks mocks.
But you only gain these benefits if you follow these guidelines.
Guideline One - Architecture 🏗️ #
Move out Business Logic out from the Widgets!
That logic should now instead go inside the WidgetDriver
.
It is NOT the job of a Widget to create and combine dependencies!
The Driver
is the place where you want to create/resolve dependencies and combine them.
The Driver
has access to the BuildContext
which was used to create your widget, so you can resolve any needed dependency directly out from that context if you want.
The widget is ONLY interested in creating UI and reacting to interactions from the user.
WidgetDriver
gives you all the tools you need to stop putting business logic in the widget.
Guideline Two - Testing 🔬 #
Thanks to the TestDrivers
which get auto-created for you when running widget tests, you do not have to mock your dependencies in the tests.
When you run tests, then the real driver
is not created. Instead the testDriver
gets created and it just provides hard coded default values.
The only thing you need to do when testing a widget is to mock the driver
for that specific widget.
You just need to mock the driver
used by the widget which is currently under test and that is it! No other mocks are needed.
But you only get this benefit if you move the business logic out of the widget.
When you keep business logic in your widgets, then the parent widgets will need to provide mock values for all those child widgets and you end up in mock-mock-mock-world
So please follow guideline two -> Put your business logic in the Driver
, not in the Widget
!
More about testing and the benefits are described here
Providing dependencies into the build context #
If you need to inject some dependencies into the BuildContext then a typical use case is that you create/resolve them in a build method of a widget. And then you use some state management tool like Provider
to inject them into the widget tree. Like so:
Widget build(BuildContext context) {
return Provider(
create: MyService(someStuffFromContext: context.read<SomeStuff>()),
child: child,
);
}
Now this works fine when running the app normally. But when you run widget tests, then any parent of this widget would need to provide a mocked version of SomeStuff
into the build context, otherwise the context.read<SomeStuff>()
would fail and throw an exception.
To get around this we have created a helper class called DependencyProvider
. This class adds a small wrapper around the creation of your dependency and will automatically provide a test default value for you when running tests.
This is how you would use it:
Widget build(BuildContext context) {
return Provider(
create: _DependencyProvider().get(() => MyService(someStuffFromContext: context.read<SomeStuff>())),
child: child,
);
}
Please see the documentation for DependencyProvider for more information.
Examples #
Please see this example app for inspiration on how to use WidgetDrivers
in your app.
The app also contains examples on how you can test your DrivableWidgets
and their Drivers
.
WidgetDriver and State Management #
WidgetDriver
is NOT a state management framework/library.
It is a presentation layer framework. It gives structure and organization to the code which drives your widgets.
But the application state should not be stored in the Driver
! It is not the owner of state. Think of Drivers
as ViewModels
. They take in state and transform it and then provide a tailor made version of it to the widget. E.g. the driver might get in a Date
object which it then transforms into a string representation and gives this string to the widget.
So the Drivers
will need to access state somehow. Here is a list of recommended state management approaches. BUT just make sure to use these state management systems in the Driver
.
For e.g. if you choose to use Provider, then don't use your Provider
in the widgets. So don't use context.watch<T>()
in your widgets build method. Instead you then want to use the Providers
in your Driver
. See the example app for inspiration on how to do this.