In this tutorial, we’ll develop a Backbone.js application, while testing it with Jasmine. Not good enough for you? We’ll do it all using CoffeeScript. Trifecta!
We’re going to work on the application in isolation – using a static, serverless environment. This has multiple advantages:
- Testing and running code is extremely fast.
- Decoupling our Backbone application from the server side makes it just another client. We could build a mobile application, for example, that would consume the same API.
Our test application will be a simple website where we can manage a database containing nothing more than restaurants.
Starting Boilerplate
To start, we need to move a few pieces into place. Simply download this tarball that contains:
- Backbone.js, version 0.9.2
- Jasmine version 1.2.0
- Jasmine-jQuery, to easily load html fixtures in our tests
- Twitter Bootstrap for some basic styling
- Hogan.js to compile Mustache templates
- Backbone validations, a Backbone extension that makes it very easy to add
validation rules to a Backbone model - jQuery for basic DOM manipulation
There are also two HTML files: index.html
and SpecRunner.html
. The former shows our app running, while the latter runs our Jasmine specs.
Let’s test our setup by running the application through a web server. There are various options for this, but I usually rely on a very simple Python command (available on OsX):
python -m SimpleHTTPServer
Backbone provides a nice API to define events in the scope of a specific view.
Next, navigate your browser to http://localhost:8000/index.html
, and you should see a congratulations message. Also open http://localhost:8000/SpecRunner.html
; the page should contain a sample spec running green.
You should also find a Cakefile
in the root directory. This is a very simple CoffeeScript file that you can use to automatically compile all the .coffee
files we’re going to write. It assumes that you have CoffeeScript installed as a globally available Node module, and you can refer to this page for instructions. Alternatively, you can use tools like CodeKit or Livereload to accomplish the same result.
To run the cake task, just type cake compile
. This task will keep running. You can watch for changes every time you save, but you may need to restart the script if you add new files.
Step 1 – The Restaurant Model
Namespacing
Using Backbone means we’re going to create models, collections and views. Therefore, having a namespace to keep them organized is a good practice, and we can do that by creating an app file and a relevant spec:
touch javascript/app.coffee touch javascript/spec/app_spec.coffee
The spec file contains just one test:
describe "App namespace", -> it "should be defined", -> expect(Gourmet).toBeDefined()
Switching to the javascript/app.coffee
file, we can add the following namespace declaration:
window.Gourmet = Models: {} Collections: {} Views: {}
Next, we need to add the app file to index.html
:
... <script type="text/javascript" src="/javascript/app.js"></script> ...
We need to do the same in SpecRunner.html
, but this time for both app and spec:
<!-- lib --> <script type="text/javascript" src="/javascript/app.js"></script> <!-- specs --> <script type="text/javascript" src="/javascript/spec/toolchain_spec.js"></script> <script type="text/javascript" src="/javascript/spec/app_spec.js"></script>
Repeat this for every file we create from now on.
Basic Attributes
The core entity of our app is a restaurant, defined by the following attributes:
- a name
- a postcode
- a rating (1 to 5)
As adding more attributes would not provide any advantages in the scope of the tutorial, we can just work with these three for now.
Let’s create the Restaurant
model and the relevant spec file:
mkdir -p javascript/models/ mkdir -p javascript/spec/models/ touch javascript/models/restaurant.coffee touch javascript/spec/models/restaurant_spec.coffee
Now we can open both files and add some basic specs to restaurant_spec.coffee
, shown here:
describe "Restaurant Model", -> it "should exist", -> expect(Gourmet.Models.Restaurant).toBeDefined() describe "Attributes", -> ritz = new Gourmet.Models.Restaurant it "should have default attributes", -> expect(ritz.attributes.name).toBeDefined() expect(ritz.attributes.postcode).toBeDefined() expect(ritz.attributes.rating).toBeDefined()
The test is very simple:
- We check that a
Restaurant
class exists. - We also check that a new
Restaurant
instance is always initialized with defaults that mirror the requirements we have.
Refreshing /SpecRunner.html
will show the specs failing. Now let’s implement models/restaurant.coffee
. It’s even shorter:
class Gourmet.Models.Restaurant extends Backbone.Model defaults: name: null postcode: null rating: null
Backbone will take care of sending the correct Ajax requests.
We just need to create a class on the window
namespace to make it globally available–we’ll will worry about the namespace in the second part. Now, our specs should pass. Refresh /SpecRunner.html
, and the specs should pass.
Validations
As I said before, we will use Backbone Validations for client side validation. Let’s add a new describe
block to models/restaurant_spec.coffee
to express our expectations:
describe "Restaurant Model", -> ... describe "Validations", -> attrs = {} beforeEach -> attrs = name: 'Ritz' postcode: 'N112TP' rating: 5 afterEach -> ritz = new Gourmet.Models.Restaurant attrs expect(ritz.isValid()).toBeFalsy() it "should validate the presence of name", -> attrs["name"] = null it "should validate the presence of postcode", -> attrs["postcode"] = null it "should validate the presence of rating", -> attrs["rating"] = null it "should validate the numericality of rating", -> attrs["rating"] = 'foo' it "should not accept a rating < 1", -> attrs["rating"] = 0 it "should not accept a rating > 5", -> attrs["rating"] = 6
We define an empty attributes object that will be modified in every expectation. Each time we will set only one attribute with an invalid value, thus testing the thoroughness of our validation rules. We can also use an afterEach
block to avoid a lot of repetition. Running our specs will show 6 failures. Once again, we have an extremely concise and readable implementation, thanks to Backbone validations:
class Gourmet.Models.Restaurant extends Backbone.Model defaults: name: null postcode: null rating: null validate: name: required: true postcode: required: true rating: required: true type: 'number' min: 1 max: 5
Our specs will now pass, and with these changes in place, we have a quite solid Restaurant model.
The Restaurants Collection
Because we want to manage a list of restaurants, it makes sense to have a RestaurantsCollection
class. We don’t know yet how complicated it needs to be; so, let’s focus on the bare minimum requirements by adding a new describe
block to the models/restaurant_spec.coffee
file:
describe "Restaurant model", -> ... describe "Restaurants collection", -> restaurants = new Gourmet.Collections.RestaurantsCollection it "should exist", -> expect(Gourmet.Collections.RestaurantsCollection).toBeDefined() it "should use the Restaurant model", -> expect(restaurants.model).toEqual Gourmet.Models.Restaurant
Backbone provides an extensive list of methods already defined for a collection, so our work here is minimal. We don’t want to test methods defined by the framework; so, we just have to make sure that the collection uses the right model. Implementation-wise, we can append the following few lines to models/restaurant.coffee
:
class Gourmet.Collections.RestaurantsCollection extends Backbone.Collection model: Gourmet.Models.Restaurant
It’s clear that CoffeeScript and Backbone are a very powerful team when it comes to clarity and conciseness. Let’s rerun our specs to verify that everything’s green.
Step 2 – The Restaurants View
The Markup
Until now, we haven’t even looked at how we’re going to display or interact with our data. We’ll keep it visually simple and focus on two actions: adding and removing a restaurant to/from the list.
Thanks to Bootstrap, we can easily add some basic markup that results in a decent looking prototype table. Let’s open the index.html
file and add the following body content:
<div class="container"> <div class="navbar"> <div class="navbar-inner"> <div class="container"> <a href="#" class="brand">Awesome restaurants</a> </div> </div> </div> <div class="container"> <div class="row"> <div class="span4"> <form action="#" class="well form-horizontal" id="restaurant-form"> <div class="control-group"> <label for="restaurant_name">Name</label> <input type="text" name="restaurant[name]" id="restaurant_name" /> <span class="help-block">Required</span> </div> <div class="control-group"> <label for="restaurant_rating">Rating</label> <input type="text" name="restaurant[rating]" id="restaurant_rating" /> <span class="help-block">Required, only a number between 1 and 5</span> </div> <div class="control-group"> <label for="restaurant_postcode">Postcode</label> <input type="text" name="restaurant[postcode]" id="restaurant_postcode" /> <span class="help-block">Required</span> </div> <input type="button" class="btn btn-primary" value="Save" id="save"/> </form> </div> <div class="span8"> <table class="table" id="restaurants"> <thead> <tr> <th>Name</th> <th>Postcode</th> <th>Rating</th> </tr> </thead> <tbody></tbody> </table> </div> </div> </div> </div>
What we really care about is the #restaurant-form
and the #restaurants
table. The input elements use a conventional pattern for their names (entity[attribute]
), making them easily processable by most back-end frameworks (especially Rails). As for the table, we are leaving the tbody
empty, as we will render the content on the client with Hogan. In fact, we can add the template we’re going to use right before all other <script>
tags in the <head>
.
... <link rel="stylesheet" media="screen" href="/css/bootstrap.css" > <script type="text/mustache" id="restaurant-template"> <tr> <td>{{ name }}</td> <td>{{ postcode }}</td> <td>{{ rating }}</td> <td> <i class="icon-remove remove" id="{{ id }}"></i> </td> </tr> </script> <script type="text/javascript" src="/javascript/vendor/jquery.min.js"></script> ...
Being a Mustache template, it needs the correct text/mustache
type and an id
we can use to retrieve it from the DOM. All the parameters enclosed in {{ }}
are attributes of our Restaurant
model; this simplifies the rendering function. As a last step, we can add a remove
icon that, when clicked, deletes the corresponding restaurant.
The Restaurants View Class
As previously stated, we have two core view components: the restaurants list and the restaurant form. Let’s tackle the first by creating both the directory structure for views and the needed files:
mkdir -p javascript/views mkdir -p javascript/spec/views touch javascript/views/restaurants.coffee touch javascript/spec/views/restaurants_spec.coffee
Let’s also copy #restaurant-template
to the SpecRunner.html
file:
... <script type="text/javascript" src="/javascript/vendor/jasmine-jquery.js"></script> <!-- templates --> <script type="text/mustache" id="restaurant-template"> <tr> <td>{{ name }}</td> <td>{{ postcode }}</td> <td>{{ rating }}</td> <td> <i class="icon-remove remove" id="{{ id }}"></i> </td> </tr> </script> <!-- vendor js --> <script type="text/javascript" src="/javascript/vendor/jquery.min.js"></script> ...
In addition, we need to include the .js
files in the head of SpecRunner.html
. We can now open views/restaurant_spec.coffee
and start editing.
describe "Restaurants view", -> restaurants_data = [ { id: 0 name: 'Ritz' postcode: 'N112TP' rating: 5 }, { id: 1 name: 'Astoria' postcode: 'EC1E4R' rating: 3 }, { id: 2 name: 'Waldorf' postcode: 'WE43F2' rating: 4 } ] invisible_table = document.createElement 'table' beforeEach -> @restaurants_collection = new Gourmet.Collections.RestaurantsCollection restaurants_data @restaurants_view = new Gourmet.Views.RestaurantsView collection: @restaurants_collection el: invisible_table it "should be defined", -> expect(Gourmet.Views.RestaurantsView).toBeDefined() it "should have the right element", -> expect(@restaurants_view.el).toEqual invisible_table it "should have the right collection", -> expect(@restaurants_view.collection).toEqual @restaurants_collection
Fixtures are a simple way to import HTML fragments in our tests without having to write them inside the spec file itself.
It looks like a lot of code, but this is a standard start for a view spec. Let’s walk through it:
- We begin by instantiating an object that holds some restaurant data. As suggested by the Backbone documentation, it’s a good practice to feed a Backbone app the data it needs directly in the markup to avoid a delay for the user and an extra HTTP request when the page opens.
- We create an invisible table element without appending it to the DOM; we don’t need it for user interaction.
- We define a
beforeEach
block where we instantiate aRestaurantsCollection
with the data we created before. Doing it in abeforeEach
block guarantees that every spec will start with a clean slate. - We then instantiate a
RestaurantsView
class and pass both the collection and the invisible table in the initializer. The object keys,collection
andel
, are default Backbone methods for aView
class. They identify the container where the view will be rendered and the data source used to populate it. - The specs simply check that everything we assume in the
beforeEach
block is true.
Running our tests throws an error because the RestaurantsView
class is not yet defined. We can easily get everything to green by adding the following content to views/restaurant.coffee
:
class Gourmet.Views.RestaurantsView extends Backbone.View
We don’t need to override or change the constructor defined by the Backbone.View
prototype because we instantiated the view with a collection
and an el
attribute. This single line is enough to get our specs green; it will, however, do pretty much nothing from the end result point of view.
Assuming there are restaurants added to the collection, the view class should render them on the page as soon as the page loads. Let’s translate this requirement into a spec that we can add at the bottom of the views/restaurant_spec.coffee
file:
it "should render the the view when initialized", -> expect($(invisible_table).children().length).toEqual 3
We can test the number of children (<tr/>
elements) that the invisible table needs to have, considering that we have defined a sample dataset of three restaurants. This will result in a red spec because we haven’t even started working on rendering. Let’s add the relevant piece of code to the RestaurantsView
class:
class Gourmet.Views.RestaurantsView extends Backbone.View template: Hogan.compile $('#restaurant-template').html() initialize: -> @render @collection render: => @$el.empty() for restaurant in @collection.models do (restaurant) => @$el.append @template.render(restaurant.toJSON())
…the real benefit is the possibility to work effectively on testable pieces of functionality that follow predictable patterns.
You will see this pattern very frequently in a Backbone application, but let’s break it into pieces:
- The
template
function isolates the templating logic we use inside the application. We’re using mustache templates compiled through Hogan, but we could’ve used Underscore or Mustache itself. All of them follow a similar API structure; so, switching would not be difficult (albeit a bit boring). In addition, isolating the template function gives a clear idea of which template a view uses. - The
render
function empties theel
(note that@$el
is a cached, jQuery wrapped version of the element itself made available by default by Backbone), iterates on the models inside the collection and render the result, and appends it to the element. This is a naive implementation, and you may want to refactor it toappend
just once instead of doing it at every loop. - Finally, we call
render
when the view is initialized.
This will make our spec green and will give us a minimal amount of code useful to actually show it on the page. Let’s open index.html
and add the following:
... <body> <script type="text/javascript"> restaurants_data = [ { id: 0, name: 'Ritz', postcode: 'N112TP', rating: 5 }, { id: 1, name: 'Astoria', postcode: 'EC1E4R', rating: 3 }, { id: 2, name: 'Waldorf', postcode: 'WE43F2', rating: 4 } ]; $(document).ready(function(){ restaurants = new Gourmet.Collections.RestaurantsCollection(restaurants_data); restaurants_view = new Gourmet.Views.RestaurantsView({ collection: restaurants, el: '#restaurants tbody' }) }); </script> ...
We’re basically replicating the default dataset and the setup needed to get the app running. We’re also doing it inside the HTML file because this code is useful only in this static version of the app.
Refresh the page and behold! The restaurants table will be populated with results.
Next, we need to handle what happens when we add or remove a restaurant from the collection. It’s important to remember that the form is just one possible way to act on the collection; we could also have push events from other users, for example. Therefore, it is essential that this logic is separated in a clean and independent manner.
What do we expect to happen? Let’s add this specs to the views/restaurants\_view\_spec.coffee
file (right after the last one):
it "should render when an element is added to the collection", -> @restaurants_collection.add name: 'Panjab' postcode: 'N2243T' rating: 5 expect($(invisible_table).children().length).toEqual 4 it "should render when an element is removed from the collection", -> @restaurants_collection.pop() expect($(invisible_table).children().length).toEqual 2
In essence, we add and remove a restaurant to the collection, expecting our table to update itself accordingly. Adding this behavior to the view class requires a couple of lines in the initializer, as we can leverage on Backbone events on the collection:
... initialize: -> @render @collection @collection.on 'add', @render @collection.on 'remove', @render ...
We can re-render the whole table using the collection in the current state (after an element has been added or removed) because our rendering logic is pretty simple. This will make our specs to pass.
When you now open the index.html
file, you will see that the remove icon on each table row doesn’t do anything. Let’s spec out what we expect to happen at the end of the views/restaurants\_view\_spec.coffee
file:
it "should remove the restaurant when clicking the remove icon", -> remove_button = $('.remove', $(invisible_table))[0] $(remove_button).trigger 'click' removed_restaurant = @restaurants_collection.get remove_button.id expect(@restaurants_collection.length).toEqual 2 expect(@restaurants_collection.models).not.toContain removed_restaurant
Jasmine spies are quite powerful, and I encourage you to read about them.
The test is pretty verbose, but it summarizes exactly what needs to happen:
- We find the remove icon of the first row in the table with jQuery.
- We then click that icon.
- We identify which restaurant needs to be removed by using the
id
of the remove button, which corresponds to theid
of the restaurant model. - We test that the restaurants collection has an element less, and that element is exactly the one we identified before.
How can we implement this? Backbone provides a nice API to define events in the scope of a specific view. Let’s add one to the RestaurantsView
class:
class Gourmet.Views.RestaurantsView extends Backbone.View events: 'click .remove': 'removeRestaurant' ... removeRestaurant: (evt) => id = evt.target.id model = @collection.get id @collection.remove model
When clicking on an element with class .remove
, the view calls the removeRestaurant
function and passes the jQuery event object. We can use it to get the id
of the element and remove the relevant model from the collection. We already handle what happens when removing an element from the collection; so, this will be enough to get the spec to green.
In addition, you can open index.html
and see it in action in the browser.
The Restaurant Form Class
We now need to handle the user input when using the form to add a new restaurant:
- If the user inputs invalid data, we’re going to display inline validation errors.
- If the user inputs valid data, the restaurant will be added to the collection and displayed in the table.
As we’ve already added validations to the Restaurant
model, we now need to wire them to the view. Not surprisingly, we will start by creating a new view class and the relevant spec file.
touch javascript/views/restaurant_form.coffee touch javascript/spec/views/restaurant\_form\_spec.coffee
Once again, let’s remember to add the JavaScript compiled version of the view to index.html
and both compiled versions to SpecRunner.html
.
It’s a good time to introduce fixtures, a piece of functionality made available by Jasmine-jQuery, because we will be dealing with the form markup. In essence, fixtures are a simple way to import HTML fragments in our tests without having to write them inside the spec file itself. This keeps the spec clean, understandable, and can eventually lead to reusability of the fixture among multiple specs. We can create a fixture for the form markup:
mkdir -p javascript/spec/fixtures touch javascript/spec/fixtures/restaurant_form.html
Let’s copy the whole form in index.html
to the restaurant_form.html
fixture:
<form action="#" class="well form-horizontal" id="restaurant-form"> <div class="control-group"> <label for="restaurant_name">Name</label> <input type="text" name="restaurant[name]" id="restaurant_name" /> <span class="help-block">Required</span> </div> <div class="control-group"> <label for="restaurant_rating">Rating</label> <input type="text" name="restaurant[rating]" id="restaurant_rating" /> <span class="help-block">Required, only a number between 1 and 5</span> </div> <div class="control-group"> <label for="restaurant_postcode">Postcode</label> <input type="text" name="restaurant[postcode]" id="restaurant_postcode" /> <span class="help-block">Required</span> </div> <input type="button" class="btn btn-primary" value="Save" id="save"/> </form>
Now open views/restaurant\_form\_spec.coffee
and add the fixture along with some boilerplate:
describe "Restaurant Form", -> jasmine.getFixtures().fixturesPath = 'javascript/spec/fixtures' beforeEach -> loadFixtures 'restaurant_form.html' @invisible_form = $('#restaurant-form') @restaurant_form = new Gourmet.Views.RestaurantForm el: @invisible_form collection: new Gourmet.Views.RestaurantsCollection it "should be defined", -> expect(Gourmet.Views.RestaurantForm).toBeDefined() it "should have the right element", -> expect(@restaurant_form.$el).toEqual @invisible_form it "should have a collection", -> expect(@restaurant_form.collection).toEqual (new Gourmet.Views.RestaurantsCollection)
The jasmine.getFixtures().fixtures_path
attribute change is needed as we have a custom directory structure that differs from the library default. Then, in the beforeEach
block, we load the fixture and define an @invisible_form
variable that targets the form we just imported. Finally, we define an instance of the class we’re going to create, passing in an empty restaurants collection and the @invisible_form
we just created. As usual, this spec will be red (the class is still undefined), but if we open restaurant_form.coffee
we can easily fix it:
class Gourmet.Views.RestaurantForm extends Backbone.View
Next, we need to think about our spec’s structure. We have two choices:
Using Backbone means we’re going to create models, collections and views. Therefore, having a namespace to keep them organized is a good practice
- We can spy on the form content with jasmine and mock it.
- We could manually change the content of the fields and then simulate a click.
Personally, I favor the first approach. The second would not eliminate the need for proper integration testing, but it would increase the complexity of the spec.
Jasmine spies are quite powerful, and I encourage you to read about them. If you come from a Ruby testing background, they’re very similar to RSpec’s mocks and feel very familiar. We do need to have an idea of the pattern we are going to implement, at least with broad strokes:
- The user enters data in the form.
- When he presses save, we get the form content in a serialized form.
- We transform that data and create a new restaurant in the collection.
- If the restaurant is valid, we save it, otherwise we will display validation errors.
As said before, we’re going to mock the first step, and we’ll do so by defining a new describe block where we instantiate an object that represents a well formed, valid data structure coming from a form.
describe "Restaurant Form", -> ... describe "form submit", -> beforeEach -> @serialized_data = [ { name: 'restaurant[name]', value: 'Panjab' }, { name: 'restaurant[rating]', value: '5' }, { name: 'restaurant[postcode]', value: '123456' } ] spyOn(@restaurant_form.$el, 'serializeArray').andReturn @serialized_data
At the end, we define a spy on the serializeArray
method for our form. That means that if we call @restaurant_form.$el.serializeArray()
, we already know that it’s going to return the object we created above. This is the mocking facility we needed; it simulates the user input we need to test with. Next, we can add some specs:
it "should parse form data", -> expect(@restaurant_form.parseFormData(@serialized_data)).toEqual name: 'Panjab', rating: '5', postcode: '123456' it "should add a restaurant when form data is valid", -> spyOn(@restaurant_form, 'parseFormData').andReturn name: 'Panjab', rating: '5', postcode: '123456' @restaurant_form.save() # we mock the click by calling the method expect(@restaurant_form.collection.length).toEqual 1 it "should not add a restaurant when form data is invalid", -> spyOn(@restaurant_form, 'parseFormData').andReturn name: '', rating: '5', postcode: '123456' @restaurant_form.save() expect(@restaurant_form.collection.length).toEqual 0 it "should show validation errors when data is invalid", -> spyOn(@restaurant_form, 'parseFormData').andReturn name: '', rating: '5', postcode: '123456' @restaurant_form.save() expect($('.error', $(@invisible_form)).length).toEqual 1
In the first spec, we verify that our RestaurantForm
class has a method that parses the data from the form. This method should return an object that we can feed to the restaurant collection. In the second spec, we mock the previous method because we don’t need to test it again. Instead, we focus on what happens when the user clicks ‘Save’. It will probably trigger an event that calls a save
function.
We should tweak the second spec’s mock to return invalid data for a restaurant in order to verify that the restaurant doesn’t get added to the collection. In the third spec, we verify that this also triggers validation errors in the form. The implementation is somewhat tricky:
class Gourmet.Views.RestaurantForm extends Backbone.View events: 'click #save': 'save' save: -> data = @parseFormData(@$el.serializeArray()) new_restaurant = new Restaurant data errors = new_restaurant.validate(new_restaurant.attributes) if errors then @handleErrors(errors) else @collection.add new_restaurant parseFormData: (serialized_array) -> _.reduce serialized_array, @parseFormField, {} parseFormField: (collector, field_obj) -> name = field_obj.name.match(/\[(\w+)\]/)[1] collector[name] = field_obj.value collector handleErrors: (errors) -> $('.control-group').removeClass 'error' for key in (_.keys errors) do (key) -> input = $("#restaurant_#{key}") input.closest('.control-group').addClass 'error'
This is a good practice to make sure that we use the fake server only where we need to, minimizing interference with the rest of the test suite.
Let’s see each function:
- We have an
events
hash that binds the user’s mouse click to asave
function. - The save function parses the data (more on that below) in the form and creates a new restaurant. We call the
validate
function (available by Backbone and defined by Backbone-validations). It should returnfalse
when the model is valid, and an error object when it’s invalid. If valid, we add the restaurant to the collection. - The two ‘parse’ functions are needed to extract the attribute names from the form and create an object in the desired Backbone-ready format. Bear in mind that this complexity is needed because of the markup. We could change it, but this is a good example of how you could work on top of an existing form to enhance it.
- The
handleErrors
function iterates over theerrors
object and finds the corresponding input fields, adding the.error
class when appropriate.
Running the specs now shows a reassuring series of green dots. To have it running in the browser, we need to extend our initialize function:
$(document).ready(function(){ restaurants = new Gourmet.Collections.RestaurantsCollection(restaurants_data); restaurants_view = new Gourmet.Views.RestaurantsView({ collection: restaurants, el: '#restaurants tbody' }); restaurant\_form\_view = new Gourmet.Views.RestaurantForm({ el: '#restaurant-form', collection: restaurants }); });
There’s only one caveat: for now you can’t delete a restaurant that you added because we rely on the id
attribute to target the correct model in the restaurants collection (Backbone needs a persistence layer to assign it). This is where you would add, depending on your needs, a real back-end–like a Rails server or a LocalStorage
adapter.
Step 3 – Testing server interaction
Even though we’re on a server-less environment, we can take advantage of a couple of extra libraries that let us wire up our application for a server deploy. As a proof of concept, we will assume to be working on top of a Ruby on Rails stack.
To use Backbone with a Rails application, we need to have an additional adapter for syncing; Backbone doesn’t provide that by default (it’s a server agnostic tool). We can use the one included in the Backbone-rails project.
curl -o javascript/vendor/backbone\_rails\_sync.js http://raw.github.com/codebrew/backbone-rails/master/vendor/assets/javascripts/backbone\_rails\_sync.js
Next, we need to include it both in index.html
and SpecRunner.html
, right after the script that requires Backbone itself. This adapter takes care of executing all the asyncronous requests we need, provided that we setup our Restaurant
model and our RestaurantsCollection
with the right URLs.
How are we going to test this? We can use Sinon.js, a very powerful JavaScript mocking library that is also able to instantiate a fake server object that will intercept all XHR requests. Once again, we can simply:
curl -o javascript/vendor/sinon.js http://sinonjs.org/releases/sinon-1.4.2.js
Don’t forget to add it to the SpecRunner.html
file right after Jasmine.
Now we can start thinking about the server API. We can assume it follows a RESTful architecture (a direct consequence of choosing Rails as a backend) and uses the JSON format. Because we’re managing restaurants, we can also assume that the base URL for every request will be /restaurants
.
We can add two specs to the models/restaurant_spec.coffee
file to make sure that both collection and model are properly setup:
... it "should have default attributes", -> expect(ritz.attributes.name).toBeDefined() expect(ritz.attributes.postcode).toBeDefined() expect(ritz.attributes.rating).toBeDefined() it "should have the right url", -> expect(ritz.urlRoot).toEqual '/restaurants' ... it "should use the Restaurant model", -> expect(restaurants.model).toEqual Gourmet.Models.Restaurant it "should have the right url", -> expect(restaurants.url).toEqual '/restaurants'
To implement this, we need to define two methods on the Restaurant
model and the RestaurantsCollection
class:
class Gourmet.Models.Restaurant extends Backbone.Model urlRoot: '/restaurants' ... class Gourmet.Collections.RestaurantsCollection extends Backbone.Collection url: '/restaurants' model: Gourmet.Models.Restaurant
Watch out for the different method name!
Decoupling our Backbone application from the server side makes it just another client.
This is what is needed to setup server integration. Backbone will take care of sending the correct Ajax requests. Fore example, creating a new restaurant triggers a POST
request to /restaurants
with the new restaurant attributes in JSON format. As these requests are always the same (that is guaranteed by the rails_sync
adapter), we can reliably test that interaction on the page will trigger those requests.
Let’s open the views/restaurants_spec.coffee
file and setup Sinon. We will use its fakeServer
facility to check the requests sent to the server. As a first step, we have to instantiate a sinon server in a beforeEach
block. We will also need to make sure to restore the normal functionality right after running our specs. This is a good practice to make sure that we use the fake server only where we need to, minimizing interference with the rest of the test suite.
beforeEach -> @server = sinon.fakeServer.create() @restaurants_collection = new Gourmet.Collections.RestaurantsCollection restaurants_data @restaurants_view = new Gourmet.Views.RestaurantsView collection: @restaurants_collection el: invisible_table afterEach -> @server.restore()
Next, we add a spec to test that a DELETE request is sent to the server when we press the remove icon for a restaurant:
it "should remove a restaurant from the collection", -> evt = { target: { id: 1 } } @restaurants_view.removeRestaurant evt expect(@restaurants_collection.length).toEqual 2 it "should send an ajax request to delete the restaurant", -> evt = { target: { id: 1 } } @restaurants_view.removeRestaurant evt expect(@server.requests.length).toEqual 1 expect(@server.requests[0].method).toEqual('DELETE') expect(@server.requests[0].url).toEqual('/restaurants/1')
We can easily inspect @server.requests
, an array of all the XHR requests made in the test. We check protocol and URL of the first request and ensure it matches with the expectation. If you run the spec, it will fail because our current logic simply removes the restaurant from the collection without deleting it. Let’s open views/restaurants.coffee
and revise the removeRestaurant
method:
removeRestaurant: (evt) => id = evt.target.id model = @collection.get id @collection.remove model model.destroy()
By calling destroy
, we effectively trigger the DELETE request, making our spec pass.
Next up, the restaurant form. We want to test that every time a form with valid data is submitted, a POST request is sent to the server with the correct data. We will also refactor our tests to isolate valid and invalid attributes in two variables; this will reduce the amount of repetition that we already have. For clarity, here is the full Form submit
block from views/restaurant\_form\_spec.coffee
:
describe "Form submit", -> # attrs need to be alphabetical ordered! validAttrs = name: 'Panjab', postcode: '123456', rating: '5' invalidAttrs = name: '', postcode: '123456', rating: '5' beforeEach -> @server = sinon.fakeServer.create() @serialized_data = [ { name: 'restaurant[name]', value: 'Panjab' }, { name: 'restaurant[rating]', value: '5' }, { name: 'restaurant[postcode]', value: '123456' } ] spyOn(@restaurant_form.$el, 'serializeArray').andReturn @serialized_data afterEach -> @server.restore() it "should parse form data", -> expect(@restaurant_form.parseFormData(@serialized_data)).toEqual validAttrs it "should add a restaurant when form data is valid", -> spyOn(@restaurant_form, 'parseFormData').andReturn validAttrs @restaurant_form.save() # we mock the click by calling the method expect(@restaurant_form.collection.length).toEqual 1 it "should not add a restaurant when form data is invalid", -> spyOn(@restaurant_form, 'parseFormData').andReturn invalidAttrs @restaurant_form.save() expect(@restaurant_form.collection.length).toEqual 0 it "should send an ajax request to the server", -> spyOn(@restaurant_form, 'parseFormData').andReturn validAttrs @restaurant_form.save() expect(@server.requests.length).toEqual 1 expect(@server.requests[0].method).toEqual('POST') expect(@server.requests[0].requestBody).toEqual JSON.stringify(validAttrs) it "should show validation errors when data is invalid", -> spyOn(@restaurant_form, 'parseFormData').andReturn invalidAttrs @restaurant_form.save() expect($('.error', $(@invisible_form)).length).toEqual 1
The pattern is exactly the same as the one we used in the previous spec: we instantiate a sinon server and check the requests
array for a POST request with the valid attributes.
To implement this, we need to modify a line in views/restaurant_form.coffee
:
save: -> data = @parseFormData(@$el.serializeArray()) new_restaurant = new Gourmet.Models.Restaurant data errors = new_restaurant.validate(new_restaurant.attributes) if errors then @handleErrors(errors) else @collection.create new_restaurant
Instead of simply adding the restaurant to the collection, we call the create
method to trigger the server save.
Conclusion
If you have never worked with Backbone and Jasmine before, this is lot to digest, however the real benefit is the possibility to work effectively on testable pieces of functionality that follow predictable patterns. Here are some suggestions on how to improve from here:
- Would it be possible to add a message to the validation errors?
- How could we reset the form after adding a restaurant?
- How could we edit a restaurant?
- What if we need to paginate the table?
Try it out and let me know in the comments!
No hay comentarios:
Publicar un comentario