Recently I worked on a Flutter project. I started from the UI definition. Then I extended the application with user interaction handlers and business logic rules. In no time the widget classes became polluted. Readability and reusability of the code were low. But the major problem was testing. I could not validate business rules without launching the whole application or without initialising the widgets.
After a while, I decoupled UI from the application logic. I refactored codebase to a set of widget classes, application state class, and the business rules class.
Such architecture allowed me to test every component in isolation. And I didn’t need to use widgets to verify application logic. Additionally, usage of the provider package allowed me to inject required application state into a widget instance and test UI behaviour in isolation. Before it, I did a sequence of UI interactions to bring the application to the needed state.
In this article, I would like to share my experience and show how I detached UI from application logic. As an example, I am going to write a simple calculator application.
I am going to build an application that has the following features:
- Calculate the result of mathematical operation with one or two operands (addition, division, square root)
- Memorise result
- Assign value in memory to operands
- Reset memory
My first step is to define the UI scaffold.
Based on the feature list I built the following elements:
- Calculator input (CalculatorInput)
- Memory management (MemoryManagement)
- Memory information (MemoryInfo)
- Decorative elements (Header, Divider)
Here is how the application user interface looks like:
The following snippet shows how UI elements wired together.
Note: snippets do not contain the full application source code. I am using them to highlight the important pieces of code. The full source code is available in the repository. There you can switch to different branches and see how the application evolved.
At this point, UI is a set of simple widgets, just a bit modified bootstrap Flutter project. Next, I am going to add functionality to the application.
Implementing application logic
There are two feature categories that the application should provide:
- Memory management
- Mathematical calculations
First, I am going to implement memory management. As you can see in Figure 3, three elements depend on memory. Therefore I am adding a memory state to the top-level widget. Finally, I am providing two callbacks to the MemoryManagement element. They handle user commands to memorise the current calculation result and reset the memory state.
Next, I am going to provide a mechanism to update the value of
calculatorResult. The calculation logic will be hosted in CalculatorInput widget class. Thus I am initializing it with
At this point, I am passing the memory state from the top-level element to the nested widgets. Using callbacks, nested elements notify their parent about changes. The following figure shows the control and data flows between parent and children elements. The left part of the diagram shows widgets hierarchy. While the right side represents widget classes scopes. Because of update callbacks and memory state are within the scope of CalculusScreen, they filled with the same colour.
Memory management feature finished. Next step is the implementation of calculations logic. I am starting with adding calculator value and format getters to CalculatorInput element.
Finally, I am injecting
onChanged callback to OperandInput element to get the latest value in CalculatorInput.
The following figure shows relations between CalculatorInput and OperandInput elements.
The next figure shows a top-level view of control and data flow in the whole application.
Now the application is fully functional. I can see the calculation result which changes when I switch between different operation types. Also, I can store the operation result and use it as an operand value. All requirements completed. But there is one problem here - calculation logic is embedded to widget classes. Therefore, to be able to test it I need to launch the application and test it manually. Or use Flutter test kit to initialize widgets and manipulate interface elements to provide user input to calculations.
The second option is better than manual testing and could be enough for small projects. But after adding more elements and conditions to business rules, testing becomes a mess. Thus, I would encourage you to separate the interface code from application logic code as soon as possible. In the end, the main aim of widget tests is validating interface completeness and responsiveness on user actions.
So, how can I improve the situation?
Decoupling application logic from UI
To solve the problem I am going to use principles of The Clean Architecture. These principles allow building robust and testable code. It is a layered architecture where every layer does only one thing - UI, database, logic, etc. The dependency injection rule is used to glue all layers together.
I am planning to refactor my application to make it cleaner and improve testability. First, I am going to take all calculations logic and move it to a separate class. I will call it CalculatorUseCase.
Following is the new CalculatorUseCase class.
CalculatorUseCase is a dart class that has no external dependencies (Flutter SDK or other packages). It means I can validate the code in isolation without initializing any of widgets.
Next, I am replacing application logic in widget classes, with the instance of CalculusUseCase. Note, I create only one instance of the use case class in the top-level element and pass it to its children. The following snippets show how the widget classes changed.
The next figure shows how the control and data flows changed in the application.
Now it is better than the flow in Figure 6. The logic provided by CalculatorUseCase instance. Widget classes use it to make calculation and show results. All decoupled. But there is room for improvement. There are a few concerns I have:
- Using different getters for value and format in CalculatorUseCase
switchin getters in CalculatorUseCase
- Using operand update callback to notify CalculatorInput about changes in OperandInput
So, what is wrong with these points? First, using different getters for
format it is possible to make changes in one method and forget about the other. To fix it I am going to create an Operation class. It encapsulates an operation calculation and formatting.
switch construction makes the method constantly growing and brittle to changes. Also,
switch statement coupled with
enum Operation. Having created Operation class and operation constants I don't need to use
The last concern is the usage of callbacks. I am using it to update the operation result text in CalculatorInput. But Flutter can automatically refresh widgets when the related state changed. And I would like to use this feature. To do it I need to create the application state.
Adding application state
Flutter comes with the ChangeNotifier class, which provides change notifications API. It can be extended or mixed in by the application classes. I could make CalculatorUseCase the extension of ChangeNotifier. But I want to keep use case classes independent of any platform, SDK or package. Therefore I am going to define a CalculatorState class to connect UI with the use case. The following figure shows the updated architecture.
CalculatorState extends ChangeNotifier. As a result, I need no callbacks to refresh UI when the state changes. Flutter does everything automatically. But I need to add callbacks to the use case class to propagate updates to listeners.
CalculatorState uses ChangeNotifier to inform Flutter about state changes and initiate UI refresh. At the same time, information about operations and calculations logic are in CalculatorUseCase. CalculatorState is a decorator for the use case with additional functionality. Now I can replace CalculatorUseCase with CalculatorState in CalculusScreen. For this, I would need to update constructors of all widget classes that depend on the state. Not only that it creates noise in constructors, but also requires code maintenance when dependencies added or deleted. Instead of hardcoding the dependency into widget classes constructors, I am going to use provider package.
The next snippets show how I changed the code and injected the state using the provider package. I also refactored CalculusScreen into two classes: one is responsible for the state initialization (CalculusScreen), other assembles the UI components (CalculusScreenContainer). I did it to improve widget testing experience (more about it in the next part - Widget testing).
Now CalculatorState available in all nested widgets. Here is how CalculatorInput changed.
Please check the repository to see the full code.
The following figure shows how the provider package used to inject dependencies to the application components. This is the final form of the application architecture.
As mentioned in the previous part, the CalculusScreen split into two classes. Doing this I made it possible to test UI at any state of the application. All I need to do is to initiate an instance of CalculatorState in test setup and pass it to widget constructor. The rest is to assert that the interface elements rendered correct.
Using state in widget tests allows validating not only interface elements (text, colour, selected option) but the state changes as well. State changes as important as UI changes and testing them together might prevent lots of bugs.
In this article, I tried to show/replicate the way I have gone to decouple the application user interface from logic. The main idea is to improve application architecture where: changes are easy to make, different components can be tested in isolation, multiple tests categories supported (unit tests, widget/UI tests).
I am new to Flutter and Dart and I struggled to find an example application with good testing. I hope this article could be helpful.