Now it’s time to create a TodoList.Collections.Tasks collection. Generally in backbone.js collections are ordered sets of models. In our application we will use this collection for fetching tasks from the serer (fetch method) and for creating new task (create method).
The basic application provides model Task(name: string, complete: boolean) and corresponding controller with RESTFUL json interface:
GET /tasks.json
POST /tasks.json
PUT /tasks/:id.json
Don’t forget about rake db:create:all and rake db:migrate.
You could seed the database with initial tasks: rake db:seed.
Now you can run rails: rails s and navigate to http://localhost:3000.. and you should see nothing special, just an another todo list app without any fancy features and JavaScripts.
Sinon.js - standalone test spies, stubs and mocks for JavaScript. No dependencies, works with any unit testing framework. Also it has very nice api for stubbing server responses.
jasmine-sinon - A collection of Jasmine matchers for Sinon.JS
Running the tests
Initial application already contains pre-configured Guardfile for jasmine. It can run JavaScript specs for our application without the browser!
In order to execute our JavaScript tests just type in the console guard --group frontend wait for rails to boot and after several seconds you should see the following output:
tests results
123456789
$ guard --group frontend
Guard is now watching at '/home/lucassus/Projects/tdd-with-backbonejs'
Guard::Jasmine starts webrick test server on port 8888 in development environment.
Jasmine test runner is available at http://localhost:8888/jasmine
Run all Jasmine suites
Run Jasmine suite at http://localhost:8888/jasmine
0 specs, 0 failures
in 0.002 seconds
TIP 1: phantomjs in already included in ./spec/javascipt/support/phantomjs but you may have to compile it on your machine.
TIP 2: you can also see more detailed tests output in the browser, just navigate to http://localhost:8888/jasmine.
Step one: class TodoList.Models.Tasks should be defined and it can be instantiated
Create file ./spec/javascripts/models/task_spec.js with the following content:
spec/javascripts/models/task_spec.js
12345678910
describe('TodoList.Models.Task',function(){it('should be defined',function(){expect(TodoList.Models.Task).toBeDefined();});it('can be instantiated',function(){vartask=newTodoList.Models.Task();expect(task).not.toBeNull();});});
Obviously this test will fail since:
We don’t have TodoList.Models namespace
and Task model is not defined within this namespace
required JavaScript files and dependencies are not loaded via assets pipeline
tests results
123456
TodoList.Models.Task
✘ should be defined
➤ ReferenceError: Can't find variable: TodoList in models/task._spec.js on line 3
✘ can be instantiated
➤ ReferenceError: Can't find variable: TodoList in models/task._spec.js on line 7
ERROR: 2 specs, 2 failures
Create ./app/assets/javascripts/todo_list.js file with the following content. It will be the entry point for our application.
In the first require section we require all necessary javascipt libraries: jQuery, underscore and finnaly Backbone.js
Second section loads our application’s JavaScripts.
Add following content to the ./spec/javascripts/spec.js file:
spec/javascripts/spec.js
12
//= require todo_list//= require_tree .
These directives will load our application along with all test and helper files defined in the ./spec/javascripts folder.
And finally define initial TodoList.Models.Task class:
app/assets/javascripts/models/task.js
1
TodoList.Models.Task=Backbone.Model.extend({});
It’s green!
tests results
12345
TodoList.Models.Task
✔ should be defined
✔ can be instantiated
2 specs, 0 failures
in 0.031 seconds
Step two: the new instance should have default values for name and complete flag
spec/javascripts/models/task_spec.js
1234567891011121314151617
describe('TodoList.Models.Task',function(){// ...beforeEach(function(){this.task=newTodoList.Models.Task();});describe('new instance default values',function(){it('has default value for the .name attribute',function(){expect(this.task.get('name')).toEqual('');});it('has default value for the .complete attribute',function(){expect(this.task.get('complete')).toBeFalsy();});});});
tests results
12345
TodoList.Models.Task
new instance default values
✘ has default value for name
➤ Expected undefined to equal ''.
ERROR: 4 specs, 1 failure
TodoList.Models.Task
new instance default values
✔ should be defined
✔ can be instantiated
✔ has default value for the .name attribute
✔ has default value for the .complete attribute
4 specs, 0 failures
It seems that we don’t have to define default value for the complete flag. It’s false by default.
Step three: define getters
Generally backbone.js for fetching attributes values has a build-in model.get(attribute) method, for instance model.get('name') or model.get('complete') but in my opinion this approach is prone to typos and other strange errors. To avoid this kind of problems in my backbone models I’m creating getters for all model’s attributes, for example the name attribute will have model.getName() method.
Lets create a simple test case for those methods.
First of all create a model.getId() method:
spec/javascripts/models/task_spec.js
1234567891011121314151617181920
describe('TodoList.Models.Task',function(){// ..describe('getters',function(){describe('#getId',function(){it('should be defined',function(){expect(this.task.getId).toBeDefined();});it('returns undefined if id is not defined',function(){expect(this.task.getId()).toBeUndefined();});it("otherwise returns model's id",function(){this.task.id=66;expect(this.task.getId()).toEqual(66);});});});});
Test will fail with the following messages:
tests results
12345678910
TodoList.Models.Task
getters
#getId
✘ should be defined
➤ Expected undefined to be defined.
✘ returns undefined if id is not defined
➤ TypeError: Result of expression 'this.task.getId' [undefined] is not a function. in models/task._spec.js on line 32
✘ otherwise returns model's id
➤ TypeError: Result of expression 'this.task.getId' [undefined] is not a function. in models/task._spec.js on line 37
ERROR: 7 specs, 3 failures
TodoList.Models.Task
new instance default values
✔ should be defined
✔ can be instantiated
✔ has default value for name
✔ has default value for complete flag
getters
#getId
✔ should be defined
✔ returns undefined if id is not defined
✔ otherwise returns model's id
7 specs, 0 failures
Write some specs for model.getName() and model.getComplete() methods. In the following example I’m going to use sinon’s test stubs. In this case backbone’s get(attribute) method is stubbed and in the test I’m asserting that this method was called with valid attribute name.
TIP 1: don’t forget to require sinon.js in our spec helper, just add //= require sinon to the ./spec/javascript/spec.js file.
TIP 2: sinon should be required before our JavaScripts specs.
spec/javascripts/models/task_spec.js
123456789101112131415161718192021222324
describe('TodoList.Models.Task',function(){// ..describe('getters',function(){// .. describe('#getId', function() {});describe('#getName',function(){it('should be defined',function(){expect(this.task.getName).toBeDefined();});it('returns value for the name attribute',function(){varstub=sinon.stub(this.task,'get').returns('Task name');expect(this.task.getName()).toEqual('Task name');expect(stub.calledWith('name')).toBeTruthy();});});describe('#getComplete',function(){// TODO try do it by yourself});});});
Obviously it will fail:
tests results
12345678910111213
TodoList.Models.Task
getters
#getName
✘ should be defined
➤ Expected undefined to be defined.
✘ returns value for the name attribute
➤ TypeError: Result of expression 'this.task.getName' [undefined] is not a function. in models/task._spec.js on line 49
#getComplete
✘ should be defined
➤ Expected undefined to be defined.
✘ returns value for the complete attribute
➤ TypeError: Result of expression 'this.task.getComplete' [undefined] is not a function. in models/task._spec.js on line 62
ERROR: 11 specs, 4 failures
Green again! Not it’s time for something less trivial.
Step four: creating and updating our model via ajax
For creating a new tasks and updating its complete flag we’ll use built-in in backbone save method. Let’s see whether this method meets all our requirements:
spec/javascripts/models/task_spec.js
1234567891011121314151617181920212223
describe('TodoList.Models.Task',function(){// ..describe('#save',function(){beforeEach(function(){this.server=sinon.fakeServer.create();});afterEach(function(){this.server.restore();});it('sends valid data to the server',function(){this.task.save({name:'A new task to do'});varrequest=this.server.requests[0];varparams=JSON.parse(request.requestBody);expect(params.task).toBeDefined();expect(params.task.name).toEqual('A new task to do');expect(params.task.complete).toBeFalsy();});});});
tests results
12345
TodoList.Models.Task
#save
✘ sends valid data to the server
➤ Error: A "url" property or function must be specified in backbone.js on line 1287
ERROR: 12 specs, 1 failure
It seems that our model hasn’t required url property. Basically url can be a property or a function and it returns the relative URL where the model’s resource would be located on the server.
Let’s add this property with some arbitrary value:
TodoList.Models.Task
#save
✘ sends valid data to the server
➤ Expected undefined to be defined.
➤ TypeError: Result of expression 'params.task' [undefined] is not an object. in models/task._spec.js on line 84
ERROR: 12 specs, 2 failures
It seems that our tasks attributes are not wrapped within task property. In order to fix it we should override model’s toJSON method.
In the backbone docs for this method we find the following description:
Return a copy of the model’s attributes for JSON stringification. This can be used for persistence, serialization, or for augmentation before being handed off to a view.
Green again but url attribute definitely is not what we want. For creating task it should be /tasks.json (along with POST request method) and for updating existing task’s attributes it should be /tasks/:id.json (along with PUT request method). Let write some specs for those scenarios:
describe('TodoList.Models.Task',function(){// ..describe('#save',function(){// .. fakeServer// .. it('sends valid data to the server', function() { });describe('request',function(){describe('on create',function(){beforeEach(function(){this.task.id=null;this.task.save();this.request=this.server.requests[0];});it('should be POST',function(){expect(this.request.method).toEqual('POST');});it('should be async',function(){expect(this.request.async).toBeTruthy();});it('should have valid url',function(){expect(this.request.url).toEqual('/tasks.json');});});describe('on update',function(){beforeEach(function(){this.task.id=66;this.task.save();this.request=this.server.requests[0];});it('should be PUT',function(){expect(this.request.method).toEqual('PUT');});it('should be async',function(){expect(this.request.async).toBeTruthy();});it('should have valid url',function(){expect(this.request.url).toEqual('/tasks/66.json');});});});});});
It will fail since the url is not set correctly.
tests results
12345678910
TodoList.Models.Task
#save
request
on create
✘ should have valid url
➤ Expected '/something' to equal '/tasks.json'.
on update
✘ should have valid url
➤ Expected '/something' to equal '/tasks/66.json'.
ERROR: 18 specs, 2 failures
TIP: we could define custom jasmine matchers in order to make the test cases above more DRY.
Create spec/javascripts/support/request_matchers.js file with the following content:
spec/javascripts/support/request_matchers.js
123456789101112131415161718192021222324252627
beforeEach(function(){this.addMatchers({toBeGET:function(){varactual=this.actual.method;returnactual==='GET';},toBePOST:function(){varactual=this.actual.method;returnactual==='POST';},toBePUT:function(){varactual=this.actual.method;returnactual==='PUT';},toHaveUrl:function(expected){varactual=this.actual.url;this.message=function(){return"Expected request to have url "+expected+" but was "+actual};returnactual===expected;},toBeAsync:function(){varactual=this.actual.async;returnactual;}});});
And now instead:
123
it('should be POST',function(){expect(this.request.method).toEqual('POST');});
We could write:
123
it('should be POST',function(){expect(this.request).toBePOST();});
TIP: Try to refactor other test scenarios.
We can also do one more step further and create the following macros:
1234567891011121314151617181920212223
window.itShouldBePOST=function(){it('should be POST',function(){expect(this.request).toBePOST();});};window.itShouldBePUT=function(){it('should be PUT',function(){expect(this.request).toBePUT();});};window.itShouldBeAsync=function(){it('should be async',function(){expect(this.request).toBeAsync();});};window.itShouldHaveUrl=function(url){it('should have url '+url,function(){expect(this.request).toHaveUrl(url);});};
jasmine.Matchers.prototype.toThrow=function(expected){varresult=false;varexception;if(typeofthis.actual!='function'){thrownewError('Actual is not a function');}try{this.actual();}catch(e){exception=e;}if(exception){result=(expected===jasmine.undefined||this.env.equals_(exception.message||exception,expected.message||expected));}varnot=this.isNot?"not ":"";this.message=function(){if(exception&&(expected===jasmine.undefined||!this.env.equals_(exception.message||exception,expected.message||expected))){return["Expected function "+not+"to throw",expected?expected.message||expected:"an exception",", but it threw",exception.message||exception].join(' ');}else{return"Expected function to throw an exception.";}};returnresult;};
Jasmine is a behavior-driven development framework for testing your JavaScript code. It does not depend on any other JavaScript frameworks. It does not require a DOM. And it has a clean, obvious syntax so that you can easily write tests.
require'spec_helper'describeApplicationHelperdodescribe"#flash_messages"docontext"when there is no flash messages"doit"should return nothing"dohelper.flash_messages.shouldbe_nilendendcontext"when there are some flash messages"dolet(:flashes)do{:notice=>'Battlestation operational',:error=>'Hudson, we have a problem!',:warning=>"I'm sorry Dave, I'm afraid I can't do that"}endbeforedoflashes.each{|type,message|flash[type]=message}endsubject{helper.flash_messages}it"should render a list with flash messages"doshouldhave_selector('ul',:id=>'flash-messages')flashes.eachdo|type,message|shouldhave_selector("li",:class=>"flash-message #{type}",:content=>message)endendendendend
The usage
1
<%= flash_messages %>
..and the output
123
<ulid="flash-messages"><liclass="flash-message notice">Vote was added.</li></ul>