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