How to make UI testable and effectively modifiable?
In the previous articles, we arrived at the conclusion that in order for code to not become legacy there needs to be quick correctness feedback and good patterns in place.
Problems with design patterns
React and many other similar front-end libraries tend to fall into same trap.
They tightly couple business, io and state management logic.
This works well for small sandbox pet projects, but unacceptable for enterprise development.
Surprisingly, React was introduced as a view library, that was intended for only handling the view, but it wasn’t able to deliver on that promise and it has become another spaghetti framework. Pardon if I’m too direct, but I will do my best to justify this point of view.
Elm and Redux
Elm, on the other hand, is a much better solution for front-end development in terms of design patterns, and particularly for its use of Model-View-Update (MVU) pattern, but since it lives in a completely different world with a very peculiar language and isolated ecosystem, it exhibits a terrible pattern of not being able to opt-in gradually (like with TypeScript), which makes it hard to jump the band wagon as it would be a vendor-lock-in situation.
Of course, there were attempts to introduce similar patterns that Elm relies on, like unidirectional data flow, and similar decoupling of IO, with Redux.
Elm and Redux are similar in many ways, as both implement a sort of functional programming style and both utilize a unidirectional data flow, but they have different approaches when it comes to connecting the view to the application’s state.
In Elm, the pattern is Model-Update-View. The whole model is passed to the view function each time an update occurs, meaning the entire state of the app is available when rendering the view.
Redux, on the other hand, is more flexible and less prescriptive about how you connect your state to your views. With Redux, you can use the connect function (when using React-Redux) to bind just the part of the state that the specific component needs, rather than the entire state.
In Redux, however, this makes view tightly coupled to the rest of the loop, so you can’t swap the view easily, without disentangling it properly, which is no fun.
In Elm, the coupling is also very tight, but in different regard. You would be able to change implementation of view, without rewriting much logic outside of it, but it is still tightly coupled to the loop, as the view can only be done using the constructs available in the elm language and ecosystem.
Another way both Redux and Elm tightly couple view and the rest of the loop is by connecting actions to controls.
For example, if you have a button that is supposed to increment a value by one, you name an action “increment” and add that to the handler of a button.
However, if later on you decide, to change underlying logic to use “multiply”, you would have to go to the view and change it.
But view shouldn’t really know about business logic, as well as business logic shouldn’t know about the view.
Therefore, this needs to be separated.
Problems with testing
React, as well as many other frameworks, have various solutions for testing, however there are also very important shortcomings.
For libraries that rely on running React under the hood, like RTL does, tests become integration tests.
And since they are integration tests, the setup might become very complex.
And since we mentioned that requirements will change often, the tests might also become obsolete, so we would need to be able to adapt quickly.
In the context of the metaphor of a line with dots, our tests would be equivalent to locating the dots, and at some point they might get too complex and unwieldy making them burden rather than solution.
Regarding Redux, even though it’s a library that (finally) allows you to have a unidirectional pure-function-like flow, I haven’t seen a single test written to test its behavior.
How can we do better?
To improve the situation with Elm and Redux, we should separate business logic from view. This would allow us to test both (very complex) parts of applications separately, and be able to swap one part without touching the other.
This approach would allow us to put the entire view in Storybook, where we would be able to see it in different states easily. Would also allow us to do visual snapshots that would help us with refactoring.
Regarding the business logic, if it follows the unidirectional data flow and is separate from view and other IO, we could write pure-function blackbox tests that would let us easily check if everything is correct.
This would then allow us to refactor the underlying code according and would allow us to do timely refactor and introduction of necessary design patterns — one of the most important aspects of legacy-proof code.
On top of that, we could then connect this business logic and view in Storybook again to see how they interact.
Overall, this approach would be highly storybook-friendly.
Which, in turn, mean that the most important part about developing legacy-proof code would be out of the way — quick feedback.
I wanted to demonstrate that the current solutions relying on unidirectional flow still exhibit high-coupling of business-logic and view, and this needs to be resolved.
In the next articles, I will demonstrate an approach that would allow us to do that.
Thanks for reading!