DEV Community

Kirill Novik
Kirill Novik

Posted on • Edited on

How to make UI testable and easy to change?

Intro

How to make UI testable and easy to change?

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.

Given these two conditions will exhibit the ability to be changed easily.

However, there exist problems with current UI approaches that make these two conditions difficult to achieve.

Problems with architecture and design patterns

React and similar view libraries

React and many other similar front-end libraries tend to fall into the same trap. They tightly couple business, IO, and state management logic.

You develop your components with hot reloading, adding server requests (IO) logic inside of the body of the component, as well as adding redux or similar hooks. Not only it is very convenient, but also allows you to develop something that works and do so very quickly.

This works well for small projects. But this does not work for development at scale.

By allowing you this flexibility, you introduced everything that makes you view layer not just the view layer, but a mix of various layers, and it’s now going to be hard to turn back.

Surprisingly, React was introduced as a view library, that was intended for only handling the view, but in reality, it rarely does handle just view as hooks have now taken over the world.

Survey of architectural approaches

The problem of tight coupling of logic with view is not new and there have been many approaches to solve this problem.

Model-View-Controller (MVC)

Related frameworks: Ruby on Rails (Ruby), Django (Python), Laravel (PHP), Spring MVC (Java), ASP.NET MVC (C#)

The MVC pattern is perhaps the most classic architectural pattern in UI development. This pattern separates application logic into three interconnected components:

  • The Model manages the data, logic, and rules of the application.

  • The View abstracts the details of the technology used for presentation

  • The Controller accepts inputs and converts them to commands for the Model or View

The issue with MVC is that it can often become convoluted as the application scales, with the controller handling a significant portion of the logic. It becomes challenging to manage and test due to the increased dependencies.

// == EXAMPLE OF MVC ==

// MODEL
class ButtonModel {
    constructor() {
        this.click_count = 0;
    }

    incrementCount() {
        this.click_count++;
    }

    getCount() {
        return this.click_count;
    }
}

// VIEW
class ButtonView {
    constructor() {
        this.button = document.getElementById('myButton');
        this.label = document.getElementById('clickCounter');
    }

    updateLabel(count) {
        this.label.innerText = `Clicked: ${count} times`;
    }
}

// CONTROLLER
class ButtonController {
    constructor(model, view) {
        this.model = model;
        this.view = view;
        this.view.button.addEventListener('click', () => this.buttonClicked());
    }

    buttonClicked() {
        this.model.incrementCount();
        const count = this.model.getCount();
        this.view.updateLabel(count);
    }
}

// Main
const buttonModel = new ButtonModel();
const buttonView = new ButtonView();
const buttonController = new ButtonController(buttonModel, buttonView);
Enter fullscreen mode Exit fullscreen mode

Model-View-Presenter (MVP):

Related frameworks: GWT (Java), Vaadin (Java)

MVP is a derivative of the MVC architecture and is mostly used for building user interfaces. In MVP:

  • The Model is the data layer.

  • The View is just like in MVC but now handles subscription to the events too (not in Controller anymore) as well as keeps a reference to the presenter.

  • The Presenter is a essentially a controller but without subscriptions that is a bit easier to test on its own.

Even though the pattern was meant to make testing easier, in reality, we get the complete opposite of what we wanted: the view layer knows everything about the presenter, and presenter doesn't know anything about the view.

// == EXAMPLE OF MVP ==

// MODEL
class ButtonModel {
    constructor() {
        this.click_count = 0;
    }

    incrementCount() {
        this.click_count++;
    }

    getCount() {
        return this.click_count;
    }
}

// VIEW
class ButtonView {
    constructor() {
        this.button = document.getElementById('myButton');
        this.label = document.getElementById('clickCounter');
        this.button.addEventListener('click', () => this.onClick());
    }

    setPresenter(presenter) {
        this.presenter = presenter;
    }

    onClick() {
        this.presenter.handleButtonClick();
    }

    updateLabel(count) {
        this.label.innerText = `Clicked: ${count} times`;
    }
}

// PRESENTER
class ButtonPresenter {
    constructor(view, model) {
        this.view = view;
        this.model = model;
        this.view.setPresenter(this);
    }

    handleButtonClick() {
        this.model.incrementCount();
        const count = this.model.getCount();
        this.view.updateLabel(count);
    }
}

// Main
const buttonModel = new ButtonModel();
const buttonView = new ButtonView();
const buttonPresenter = new ButtonPresenter(buttonView, buttonModel);
Enter fullscreen mode Exit fullscreen mode

Model-View-ViewModel (MVVM)

Related frameworks: Knockout.js (JavaScript), Vue.js (JavaScript), Angular (JavaScript/TypeScript), WPF (Windows Presentation Foundation) with C#

MVVM is another derivative of MVC where the controller is really a controller that also handles pub-sub data binding for the view layer. It also relies on declarative markup. However, testing presentation in separation is very difficult as there is not explicit state view can be easily set to.

// == EXAMPLE OF MVVM ==

// MODEL
class ButtonModel {
    constructor() {
        this.click_count = 0;
    }

    incrementCount() {
        this.click_count++;
    }

    getCount() {
        return this.click_count;
    }
}

// VIEWMODEL
class ButtonViewModel {
    constructor() {
        this.model = new ButtonModel();
        this.button = document.getElementById('myButton');
        this.label = document.getElementById('clickCounter');

        // Binding the ViewModel's method to the button's click event
        this.button.addEventListener('click', () => this.handleButtonClick());
    }

    handleButtonClick() {
        this.model.incrementCount();
        this.updateView();
    }

    updateView() {
        this.label.innerText = `Clicked: ${this.model.getCount()} times`;
    }
}

// Initialize ViewModel
const buttonViewModel = new ButtonViewModel();

Enter fullscreen mode Exit fullscreen mode

Model-View-Update (MVU)

Popular frameworks: Elm (Elm Language), Fabulous (F#), SwiftUI (Swift)

The MVU pattern, popularized by The Elm Architecture, is a relatively new approach to front-end development. In MVU, the model defines the state of the application, the view renders the UI based on the state (model), and the update function modifies the state based on messages (like user actions or server responses). This unidirectional data flow ensures that the UI is predictable and easier to debug.

// == EXAMPLE OF MVU ==

// MODEL
const initialModel = {
    clickCount: 0
};

// VIEW
function view(model) {
    const counterLabel = document.getElementById('clickCounter');
    counterLabel.innerText = `Clicked: ${model.clickCount} times`;
}

// UPDATE
function update(model, action) {
    switch (action.type) {
        case 'INCREMENT':
            return { ...model, clickCount: model.clickCount + 1 };
        default:
            return model;
    }
}
Enter fullscreen mode Exit fullscreen mode

Flux

Popular frameworks: Original Flux, Redux, Alt.js, RefluxJS, Marty.js, McFly, Fluxible, Delorean, NuclearJS

Flux is an application architecture that Facebook uses for building client-side web applications. It complements React’s composable view components by utilizing a unidirectional data flow, making the application’s behavior more predictable and easier to understand.

Components of Flux Architecture

  • Action: These are payloads of information that send data from the application to the Dispatcher.
  • Dispatcher: A central hub that manages all data flow in the application. It is essentially a registry of callbacks into the stores.
  • Store: This contains the application state and logic. They are somewhat similar to models in a traditional MVC pattern but manage the state of many objects.
  • View: The final output of the application based on the current state of the Store.

In Flux, user interactions, server responses, and form submissions are all examples of Actions. The dispatcher processes these Actions and updates the Stores. The View retrieves the new state from the Stores and updates the UI accordingly. This unidirectional flow (Action -> Dispatcher -> Store -> View) is similar to MVU’s (Model-View-Update) data flow where the user input generates a message, the Model updates based on the message, and the View is a function of the Model.

Redux builds upon the Flux architecture but simplifies it by enforcing a few rules:

  • Single Source of Truth: The state of your whole application is stored in one object tree within a single store.
  • State is read-only: The only way to change the state is to emit an action, which is an object describing what happened.
  • Changes are made with pure functions: To specify how the state tree is transformed by actions, you write pure reducers.

These rules help maintain consistency and predictability within the application, making it easier to track state changes and debug the application.

Redux is closer to MVU than traditional MVC. Redux, like MVU, uses a unidirectional data flow where an Action (analogous to MVU’s “message”) triggers a change in the application’s state (Redux’s “single source of truth” is similar to MVU’s “Model”), and the View is updated based on this new state.

While these approaches provide a structured way of developing UIs, they come with their own sets of challenges.

While none of these approaches is a magic wand, I have to clearly state that the philosophy behind these articles is based on the assumption — gained from the insight from years of experience — that the tight coupling of the view layer to the rest of the application is the root of all evils and, therefore, will be exploring the only approach that allows overcoming this — the MVU pattern.

// == EXAMPLE OF FLUX ==

// DISPATCHER
const Dispatcher = function() {
    this._lastID = 0;
    this._callbacks = {};
}

Dispatcher.prototype.register = function(callback) {
    const id = 'CID_' + this._lastID++;
    this._callbacks[id] = callback;
    return id;
}

Dispatcher.prototype.dispatch = function(action) {
    for (const id in this._callbacks) {
        this._callbacks[id](action);
    }
}

const AppDispatcher = new Dispatcher();

// STORE
const ButtonStore = (function() {
    let clickCount = 0;

    function incrementCount() {
        clickCount++;
    }

    function getCount() {
        return clickCount;
    }

    AppDispatcher.register(function(action) {
        if (action.type === 'INCREMENT') {
            incrementCount();
            updateView();
        }
    });

    return {
        getCount: getCount
    }
})();

// ACTIONS
const ButtonActions = {
    increment: function() {
        AppDispatcher.dispatch({
            type: 'INCREMENT'
        });
    }
};

// VIEW
function updateView() {
    const counterLabel = document.getElementById('clickCounter');
    counterLabel.innerText = `Clicked: ${ButtonStore.getCount()} times`;
}

document.getElementById('myButton').addEventListener('click', function() {
    ButtonActions.increment();
});
Enter fullscreen mode Exit fullscreen mode

Elm MVU

Elm is a much better solution for front-end development in terms of architectural design patterns, particularly for its ability to completely decouple the view as a pure function of state via its use of Model-View-Update (MVU) pattern.

Unfortunately, even though it’s a great tool, it lives in a completely different world with a very peculiar language and isolated ecosystem, it exhibits an unattractive pattern of not being able to opt-in gradually (like with TypeScript), which makes it hard to jump on the bandwagon as it would vendor-lock you in.

If Elm wasn’t a tightly coupled combination of an ML language, framework, and MVU pattern all baked into one, enforcing all-or-nothing choice, I would have been just exploring Elm.

Because Elm is a really great technology, 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 functional programming style and 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 very tricky and no fun. Another way both Redux and Elm tightly couple view and the rest of the loop is by dispatching actions from controls (UI elements) thus connecting to the logic.

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 the 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, actions should be separated from the view layer.

-- == EXAMPLE OF ELM MVU ==

-- Import necessary modules
import Browser
import Html exposing (..)
import Html.Events exposing (onClick)

-- Main function to start the application
main =
    Browser.sandbox { init = init, update = update, view = view }

-- Define the Model (the state of our application)
type alias Model =
    { clickCount : Int }

-- Initialize the model with a default state
init : Model
init =
    { clickCount = 0 }

-- Define possible actions that can change the state
type Msg
    = Increment

-- The update function describes how to handle each action and update the state
update : Msg -> Model -> Model
update msg model =
    case msg of
        Increment ->
            { model | clickCount = model.clickCount + 1 }

-- The view function displays the state
view : Model -> Html Msg
view model =
    div []
        [ button [ onClick Increment ] [ text "Click Me!" ]
        , p [] [ text ("Clicked: " ++ String.fromInt model.clickCount ++ " times") ]
        ]
Enter fullscreen mode Exit fullscreen mode

Model-View-Intent (MVI) with Cycle.js

Frameworks: Cycle.js (JavaScript)
Another great framework that I've learned recently about is Cycle.js

Just like Elm, it also allows to completely decouple the view layer from the rest of the application.

Cycle.js is a functional and reactive JavaScript framework for cleaner code. It introduces a variant of the MVU pattern called Model-View-Intent (MVI). In Cycle.js, each of these components has a unique and well-defined role:

Intent: This handles all user inputs or events, such as button clicks and form submissions. The intention behind the user's interaction is processed and mapped into an object or "message" to be utilized by the Model.

Model: The Model takes the "message" from the Intent and uses it to update the state of the application. This is done in a predictable, deterministic way, thus ensuring the consistency of state across the application.

View: The View in Cycle.js is a pure function that transforms the application's state into a Virtual DOM tree to be displayed to the user. The advantage of using a Virtual DOM is that it only updates parts of the actual DOM that have changed, enhancing performance.

Cycle.js and the MVI pattern offer a different approach to UI architecture. Rather than propagating actions throughout the application as in traditional MVC, Cycle.js enforces a unidirectional data flow and decouples the application's state from the UI. It also uses Observables to manage asynchronous data flow, further promoting the separation of concerns.

It's very similar to MVU in Elm. So far this framework checks out best for the criteria that help to avoid legacy code.

// == EXAMPLE OF MVI ==

// INTENT
function intent() {
    const button = document.getElementById('myButton');
    button.addEventListener('click', () => {
        model({type: 'INCREMENT'});
    });
}

// MODEL
let clickCount = 0;

function model(action) {
    switch (action.type) {
        case 'INCREMENT':
            clickCount++;
            break;
        default:
            break;
    }
    view(clickCount);
}

// VIEW
function view(count) {
    const counterLabel = document.getElementById('clickCounter');
    counterLabel.innerText = `Clicked: ${count} times`;
}

// Initialize
intent();
Enter fullscreen mode Exit fullscreen mode

Design patterns

Each known architectural approach has many prescribed design patterns. I will not go in-depth on them but will attempt to provide some resources at the end of the article.

Problems with testing

In the previous articles, we mentioned that design patterns help to partially solve the problem of the tendency behind code becoming legacy, a much more important task is the quick correctness feedback in a black-box manner. MVU is going to prove quite helpful in ensuring that such black-box feedback is easy to obtain.

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, as RTL does, tests become integration tests.

And since they are integration tests, the setup might become very complex. The setup for the RTL library itself is straightforward, but the procedure of mocking the dependencies can become unwieldy for larger projects.

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, which could be quite difficult when we have to be aware of many dependencies.

In the context of the metaphor of a line with dots, our tests would be equivalent to locating the dots, and at some point, this process might get too complex and unwieldy making it a burden rather than the 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 in real-world applications.

How can we do better?

To improve the situation, 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 black box tests that would let us easily check if everything is correct.

Very important to note, that once we have these black-box tests we can refactor our code, implementing the necessary design patterns that would allow us to keep our logic from becoming jumbled.

This would then allow us to refactor the underlying code accordingly and would allow us to do timely refactoring 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 the view in Storybook again to see how they interact.

Overall, this approach would be highly storybook-friendly, which, in turn, means that the most important part about developing legacy-proof code would be out of the way — quick feedback.

Conclusion

As we mentioned earlier, in order for code to not become legacy there needs to be quick correct feedback and good patterns in place.

Given these two conditions will exhibit the ability to be changed easily.

I wanted to demonstrate that there exists an inherent problem with coupling in UI development that makes these conditions hard to achieve.

This problem makes it difficult to adapt code as well as get quick feedback.

Among the known architectural solutions, only the MVU pattern helps to provide a strong separation of view from the rest of the application, which is an important consideration and aligns best with the philosophy behind these articles.

However, the current solutions relying on unidirectional flow still exhibit a high coupling of business logic and view, and this needs to be resolved if we want to be able to get quick feedback as well as adapt quickly.

Over the course of the next articles, we will attempt to resolve this problem.

Part 4 — React as a decoupled stateless view in Storybook: In this article, we will consider an example of what it means to have a stateless view separate from business logic.

Useful links

Useful Links on Cycle.js and the Model-View-Intent Pattern

Top comments (0)