Tuesday 11 February 2014

Model View Presenter on the cheap

There are some really cool JavaScript (JS) frameworks out there that offer Model View Controller (MVC) and Model View Presenter (MVP) to a website. This guide is not here to compete with those but to describe how easy it can be to bring good practise to an existing website with the minimum of fuss and very few additional dependencies.

This example combines straight JS with Jasmine for Test Driven Development (TDD), jQuery for Asynchronous JavaScript And XML (AJAX) and User Interface (UI) manipulation and Maven for building/test-running/minifying.

Download the source from here: https://github.com/KolonelKustard/js-mvp/

The live final project can be seen here: http://kolonelkustard.github.io/js-mvp/

If you're not familiar with MVP take a look at the Wikipedia description. The main difference between MVP and MVC is there is 2-way communication between View and Presenter. The View informs the Presenter of events and the Presenter tells the View what to do.

Story: A traveller wants to know the weather

As a traveller
I want to know the weather at my destination
So that I know what to pack

Scenario 1:
Given that a traveller is going to Scunthorpe
When they specify where they're going
Then the weather there should be printed out

Using MVP we'll break this application into:

  • A Model which makes an AJAX request to http://openweathermap.org/api#weather.
  • A View which has a location input field, a search button and a text area for printing the weather.
  • A Presenter which is told by the View that the button has been clicked, gets the location from the input of the View, makes a request to the Model for the weather at that location and sets the response into the Views text area.

Setting Up

Create a Maven project configured for JavaScript testing using Jasmine. That means creating a file structure as follows:

project-root/
 ├─ src/
 │   ├─ main/
 │   │   └─ webapp/
 │   │       └─ scripts/
 │   └─ test/
 │       └─ javascript/
 └ pom.xml

The key elements of the pom.xml are those that configure the Jasmine plugin as follows:

<plugin>
    <groupId>com.github.searls</groupId>
    <artifactId>jasmine-maven-plugin</artifactId>
    <version>1.3.1.3</version>
    <executions>
        <execution>
            <goals>
                <goal>test</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <jsSrcDir>${project.basedir}/src/main/webapp/scripts</jsSrcDir>
        <jsTestSrcDir>${project.basedir}/src/test/javascript</jsTestSrcDir>
        <preloadSources>
            <source>//code.jquery.com/jquery-1.10.2.js</source>
            <source>//kolonelkustard.github.io/js-mvp/jquery.mockjax/jquery.mockjax-1.5.2.js</source>
        </preloadSources>
    </configuration>
</plugin>

The configuration of the plugin is hopefully quite self explanatory, first up setting the locations of the source JavaScript files and secondly setting up the locations of the Jasmine test spec's.

Once this has been done run mvn jasmine:bdd from the command line to start up a local server which will run your test spec's. You should see something along these lines in your console:

~$ mvn jasmine:bdd
...
Server started--it's time to spec some JavaScript! You can run your specs as you develop by visiting this URL in a web browser: 

  http://localhost:8234
...

Entering that URL in your browser will run your Jasmine spec's allowing you to test and code nicely. So as you get going just keep refreshing your browser until everything is green.

The Model

Starting with the model the initial test case might look something like the following:

it("Makes an AJAX request to the weather service to find it's raining in Aberystwyth", function() {
    // We know we'll want a new WeatherModel
    var weatherModel = new WeatherModel();

    // We know we'll want it to fetch the weather in Aberystwyth
    weatherModel.getWeather("Aberystwyth", function(weather) {
        // As this is asynchronous we'll expect the weather to be returned via a callback
        expect(weather.rain).toBe("Rain");
    });
}

To satisfy the test we'll need to define WeatherModel and #getWeather(String):

var WeatherModel = function() {
    this.getWeather = function(location, successCallback) {
        $.ajax({
            type: "GET",
            url: "http://api.openweathermap.org/data/2.5/weather",
            data: {
                q: location
            },
            dataType: "jsonp",
            success: function(response) {
                successCallback(response);
            }
        });
    };
};

Our implementation uses jQuery's AJAX handling, passing the location to the weather web service and then passing the successful response back to the caller via a callback. To get the test to pass then we'll need to set it up to run with Jasmine's asynchronous test support. It's also a good idea to use Mockjax so jQuery doesn't actually send the AJAX request off to the weather service (so our test won't fail if there are network issues or something like that).

See the final test case here: https://github.com/KolonelKustard/js-mvp/blob/master/src/test/javascript/com/totalchange/jsmvp/WeatherModelTests.js
And the final model implementation here: https://github.com/KolonelKustard/js-mvp/blob/master/src/main/webapp/scripts/com/totalchange/jsmvp/WeatherModel.js

The View

The view binds everything to the UI and, being a website that means the DOM (Document Object Model). We'll have a simple HTML form and will use jQuery to simplify our interactions with it. Knowing that we'll want a text input for entering the location to find weather for we'll start with exposing that information:

it("Returns the text entered into the location field", function() {
    // Set the value into the form field
    $("#location").val("Kuala Lumpa, Malaysia");

    // Make a view and expect #getLocation() to return what's in the field
    var weatherView = new WeatherView();
    expect(weatherView.getLocation()).toBe("Kuala Lumpa, Malaysia");
});

Based on that it's a pretty simple implementation of WeatherView#getLocation():

var WeatherView = function() {
    this.getLocation = function() {
        return $("#location").val();
    };
};

In the above case it'll also be necessary to mock up a form otherwise the calls to $("location") aren't going to return anything. This can either be done by mocking the jQuery $ function using something like Jasmine's spy objects or creating a little dummy UI which can be built and torn down in Jasmine's beforeEach and afterEach functions:

var uiDiv = null;

beforeEach(function() {
    uiDiv = $("<div />").appendTo("body");
    var form = $("<form id='weatherForm' />").appendTo(uiDiv);
    $("<input id='location' name='location' type='text' />").appendTo(form);
});

afterEach(function() {
    uiDiv.remove();
});

I'm not sure why but I kind of prefer this second approach, even though strictly speaking we shouldn't be testing jQuery in our unit level tests of WeatherView.

Next up we know the view is going to need to listen for when the form is submitted in order to trigger the fetching of the weather detail. This event is going to be passed to the presenter to do its work interacting with the model. This means the view is going to need to know about the presenter. In our test we'll:

  • Create a mock presenter and pass it into the view.
  • Tell the view to get started - this is where it will bind to DOM events.
  • Simulating the form being submitted.
  • Expect our mock presenter to have been told about the event.
beforeEach(function() {
    ...
    $("<input id='submit' name='submit' type='submit' />").appendTo(form);
});

it("Tells the presenter when the form is submitted", function() {
    // Make a fake (mock) presenter using Jasmine's spy objects
    var mockWeatherPresenter = jasmine.createSpyObj("mockWeatherPresenter", ["updateWeather"]);

    // Make a WeatherView and tell it about the presenter
    weatherView = new WeatherView();
    weatherView.setPresenter(mockWeatherPresenter);

    // Tell the view to get started (this becomes clearer in the implementation)
    weatherView.start();

    // Submit the form
    $("#submit").click();

    // Make sure the mock presenter was told to update the weather
    expect(mockWeatherPresenter.updateWeather).toHaveBeenCalled();
});

To turn the test from red to green we'll need to add #setPresenter(WeatherPresenter) and #start() to WeatherView:

var WeatherView = function() {
    var presenter = null;

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

    this.start = function() {
        $("#weatherForm").submit(function() {
            presenter.updateWeather();
            return false;
        });
    };

    ...
};

The last job for the view is to print out the weather in the chosen location. For the sake of simplicity we'll just set the text of a div, making our test case as follows:

beforeEach(function() {
    ...
    $("<div id='weather' />").appendTo(uiDiv);
});

it("Displays the weather text in the UI", function() {
    var weatherView = new WeatherView();
    weatherView.setWeatherReport("Scorchio");
    expect($("#weather").text()).toBe("Scorchio");
});

And the implementation of WeatherView#setWeatherReport(String):

var WeatherView = function() {
    ...

    this.setWeatherReport = function(weather) {
        $("#weather").text(weather);
    };
};

All that's left is a bit of tidying up and refactoring. See the final test case here: https://github.com/KolonelKustard/js-mvp/blob/master/src/test/javascript/com/totalchange/jsmvp/WeatherViewTests.js
And the final view implementation here: https://github.com/KolonelKustard/js-mvp/blob/master/src/main/webapp/scripts/com/totalchange/jsmvp/WeatherView.js

The Presenter

The presenter now ties together the model and the view. It's going to be told by the view when to update the weather, it'll fetch the location from the view, ask the model for the weather at that location and then tell the view to print out the result.

The initial test case will be as follows, we know the WeatherPresenter#updateWeather() function will be needed based on writing the view previously:

it("Updates the view with the current weather", function() {
    var weatherPresenter = new WeatherPresenter(new WeatherModel(), new WeatherView());
    weatherPresenter.updateWeather();
});

The trouble is there are no assertions to check that everything has worked, and our test is also reliant upon WeatherModel and WeatherView even though we're only testing WeatherPresenter. The solution is to use Jasmine's mock spy objects to first make a fake view with an assertion:

it("Updates the view with the current weather", function() {
    var mockWeatherView = new WeatherView();
    spyOn(mockWeatherView, "getLocation").andReturn("Punta Hermosa, Peru");
    spyOn(mockWeatherView, "setWeatherReport");

    var weatherPresenter = new WeatherPresenter(new WeatherModel(), mockWeatherView);
    weatherPresenter.updateWeather();

    expect(mockWeatherView.setWeatherReport).toHaveBeenCalledWith("Amazing");
});

We'll also need to mock out the model otherwise we can't know for sure what will be returned by the live weather service. We want it to always be "Amazing" in Punta Hermosa. From writing the model we know that WeatherModel#getWeather(String, function) will be called, so we'll make our mock implementation immediately call the successful callback function with an "Amazing" result:

it("Updates the view with the current weather", function() {
    ...
    spyOn(mockWeatherModel, "getWeather").andCallFake(
        function(location, successCallback) {
            successCallback({
                weather: [{
                    main: "Amazing"
                }]
            });
        }
    );
    var weatherPresenter = new WeatherPresenter(new WeatherModel(), mockWeatherView);
    ...
});

From our test case we know we're going to need to implement WeatherPresenter#updateWeather() to go green. Here's a simple implementation:

var WeatherPresenter = function(weatherModel, weatherView) {
    this.updateWeather = function() {
        var location = weatherView.getLocation();
        weatherModel.getWeather(location, function(response) {
            weatherView.setWeatherReport(response.weather[0].main);
        });
    };
};

This all looks pretty good so far, all the concerns are neatly separated and test coverage is pretty good. One thing that's missing though is the core plumbing between the presenter and the view. This needs to be implemented as follows:

var WeatherPresenter = function(weatherModel, weatherView) {
    // This is to work around JavaScript's scoping
    var self = this;

    var init = function() {
        // If we didn't have 'self' then 'this' would by WeatherPresenter#init() right now
        // whereas we actually want to pass in WeatherPresenter.
        weatherView.setPresenter(self);
        weatherView.start();
    };

    ...

    // Call the constructor last of all when building WeatherPresenter
    init();
};

This means the test case also needs to be changed to make sure WeatherView#setPresenter(WeatherPresenter) and WeatherView#start() are being called during initialisation. We'll also want a couple of test cases for unhappy paths such as the location field being empty or the result from the model being empty. See the final test case here: https://github.com/KolonelKustard/js-mvp/blob/master/src/test/javascript/com/totalchange/jsmvp/WeatherPresenterTests.js
And the final presenter implementation here: https://github.com/KolonelKustard/js-mvp/blob/master/src/main/webapp/scripts/com/totalchange/jsmvp/WeatherPresenter.js

Putting it All Together

The last thing to be done is to make an HTML page which pulls all the elements together. This can be seen in full here: https://github.com/KolonelKustard/js-mvp/blob/master/src/main/webapp/index.html

The real key is to import the JavaScript files and to kick things off once the DOM has loaded:

<script type="text/javascript">
    var weatherPresenter = null;
    $(document).ready(function() {
        weatherPresenter = new WeatherPresenter(new WeatherModel(), new WeatherView());
    });
</script>

This uses jQuery's on ready event handling to construct a new WeatherPresenter, passing in a new WeatherModel and WeatherView to its constructor. We'll keep hold of the weatherPresenter var as it can be handy for diagnosing any issues to be able to inspect the presenter from the browsers developer tools.

We also won't want to import all three separate JS files so will use the minify-maven-plugin to merge and minify the separate files into one jsmvp.min.js file. The following plugin configuration in the pom.xml does just that:

<plugin>
    <groupId>com.samaxes.maven</groupId>
    <artifactId>minify-maven-plugin</artifactId>
    <version>1.7.2</version>
    <executions>
        <execution>
            <id>minify</id>
            <configuration>
                <jsSourceDir>scripts</jsSourceDir>
                <jsSourceFiles>
                    <jsSourceFile>com/totalchange/jsmvp/WeatherModel.js</jsSourceFile>
                    <jsSourceFile>com/totalchange/jsmvp/WeatherView.js</jsSourceFile>
                    <jsSourceFile>com/totalchange/jsmvp/WeatherPresenter.js</jsSourceFile>
                </jsSourceFiles>
                <jsFinalFile>jsmvp.js</jsFinalFile>
            </configuration>
            <goals>
                <goal>minify</goal>
            </goals>
        </execution>
    </executions>
</plugin>

And here it is live at http://kolonelkustard.github.io/js-mvp/:

Next Steps

The whole thing still needs some tidying up. For example there's not enough unhappy path code in there, so perhaps consider adding some test cases for when things go wrong. Also the UI gives no clues as to what's going on, so try adding a loader image and disabling form submission when there's a request underway.

From here it would be worth reading up on concepts like Asynchronous Module Definition (AMD), JavaScript MVC/MVP frameworks like backbone.js and making use of tools like Grunt. But in the meantime hopefully this shows there's no excuse not to be following good practise without having to commit to new tooling.

The whole thing really should have started with an acceptance test based on the initial Given, When, Then statement. It would be quite easy to plumb in Cucumber to the build process.

No comments:

Post a Comment