Introduction.
In the previous post, we applied the humble object pattern idea to avoid having to write end-to-end tests for the interesting logic of a hard to test legacy Om view, and managed to write cheaper unit tests instead. Then, we saw how those unit tests were far from ideal because they were highly coupled to implementation details, and how these problems were caused by a lack of separation of concerns in the code design.
In this post we’ll show a solution to those design problems using effects and coeffects that will make the interesting logic pure and, as such, really easy to test and reason about.
Refactoring to isolate side-effects and side-causes using effects and coeffects.
We refactored the code to isolate side-effects and side-causes from pure logic. This way, not only testing the logic got much easier (the logic would be in pure functions), but also, it made tests less coupled to implementation details. To achieve this we introduced the concepts of coeffects and effects.
The basic idea of the new design was:
- Extracting all the needed data from globals (using coeffects for getting application state, getting component state, getting DOM state, etc).
- Using pure functions to compute the description of the side effects to be performed (returning effects for updating application state, sending messages, etc) given what was extracted in the previous step (the coeffects).
- Performing the side effects described by the effects returned by the called pure functions.
The main difference that the code of horizon.controls.widgets.tree.hierarchy
presented after this refactoring was that the event handler functions were moved back into it again, and that they were using the process-all! and extract-all! functions that were used to perform the side-effects described by effects, and extract the values of the side-causes tracked by coeffects, respectively. The event handler functions are shown in the next snippet (to see the whole code click here):
Now all the logic in the companion namespace was comprised of pure functions, with neither asynchronous nor mutating code:
Thus, its tests became much simpler:
Notice how the pure functions receive a map of coeffects already containing all the extracted values they need from the “world” and they return a map with descriptions of the effects. This makes testing really much easier than before, and remove the need to use test doubles.
Notice also how the test code is now around 100 lines shorter. The main reason for this is that the new tests know much less about how the production code is implemented than the previous one. This made possible to remove some tests that, in the previous version of the code, were testing some branches that we were considering reachable when testing implementation details, but when considering the whole behaviour are actually unreachable.
Now let’s see the code that is extracting the values tracked by the coeffects:
which is using several implementations of the Coeffect protocol:
All the coeffects were created using factories to localize in only one place the “shape” of each type of coeffect. This indirection proved very useful when we decided to refactor the code that extracts the value of each coeffect to substitute its initial implementation as a conditional to its current implementation using polymorphism with a protocol.
These are the coeffects factories:
Now there was only one place where we needed to test side causes (using test doubles for some of them). These are the tests for extracting the coeffects values:
A very similar code is processing the side-effects described by effects:
which uses different effects implementing the Effect protocol:
that are created with the following factories:
Finally, these are the tests for processing the effects:
Summary.
We have seen how by using the concept of effects and coeffects, we were able to refactor our code to get a new design that isolates the effectful code from the pure code. This made testing our most interesting logic really easy because it became comprised of only pure functions.
The basic idea of the new design was:
- Extracting all the needed data from globals (using coeffects for getting application state, getting component state, getting DOM state, etc).
- Computing in pure functions the description of the side effects to be performed (returning effects for updating application state, sending messages, etc) given what it was extracted in the previous step (the coeffects).
- Performing the side effects described by the effects returned by the called pure functions.
Since the time we did this refactoring, we have decided to go deeper in this way of designing code and we’re implementing a full effects & coeffects system inspired by re-frame.
Acknowledgements.
Many thanks to Francesc Guillén, Daniel Ojeda, André Stylianos Ramos, Ricard Osorio, Ángel Rojo, Antonio de la Torre, Fran Reyes, Miguel Ángel Viera and Manuel Tordesillas for giving me great feedback to improve this post and for all the interesting conversations.