With the addition of stubs and mocks to our test infrastructure, we are ready to tackle the remaining components of our Backbone.js application that we will cover in this book: the App.Views.NotesItem
view and the App.Routers.Router
router. For those following along in the code examples, we will integrate the specs for these application components into the test driver page chapters/05/test/test.html
.
One preliminary Sinon.JS issue that can trip up developers is making sure that spies, stubs, and mocks are actually bound to the expected methods of a Backbone.js application object during a test.
Let's start with a simple Backbone.js view named MyView
. The view has a custom method named foo()
that is bound to two event listeners, wrapped
and unwrapped
. The listeners are functionally equivalent, except that wrapped
wraps the call in a function (function () { this.foo(); }
) while unwrapped
binds the real (or "naked") this.foo
method:
var MyView = Backbone.View.extend({ initialize: function () { this.on("wrapped", function () { this.foo(); }); this.on("unwrapped", this.foo); }, foo: function () { return "I'm real"; } });
Although quite similar, the event listeners have an important difference when using Sinon.JS fakes; once initialize()
is called, naked method references, such as the one passed to unwrapped
, cannot be faked by Sinon.JS later. The underlying reason is that Sinon.JS can only change properties on the view object and not on the direct method references.
Let's examine a test that instantiates a MyView
object and then stubs foo
. When we trigger the wrapped
listener, our stub is called and returns the faked value I'm fake
. However, triggering the unwrapped
listener never calls the stub and invokes the real foo
method instead. Note that we use the Sinon.JS reset()
method to clear out any recorded function call information and return a spy, stub, or mock to its original state:
it("stubs after initialization", sinon.test(function () { var myView = new MyView(); // Stub prototype **after** initialization. // Equivalent to: // this.stub(myView, "foo").returns("I'm fake"); this.stub(MyView.prototype, "foo").returns("I'm fake"); // The wrapped version calls the **stub**. myView.foo.reset(); myView.trigger("wrapped"); expect(myView.foo) .to.be.calledOnce.and .to.have.returned("I'm fake"); // However, the unwrapped version calls the **real** function. myView.foo.reset(); myView.trigger("unwrapped"); expect(myView.foo).to.not.be.called; }));
One solution to the issue is to stub before the object is instantiated. In the following code snippet, creating the stub before the call to new MyView()
correctly hooks the stub into both the wrapped
and unwrapped
listeners:
it("stubs before initialization", sinon.test(function () { // Stub prototype **before** initialization. this.stub(MyView.prototype, "foo").returns("I'm fake"); var myView = new MyView(); // Now, both versions are correctly stubbed. myView.foo.reset(); myView.trigger("wrapped"); expect(myView.foo) .to.be.calledOnce.and .to.have.returned("I'm fake"); myView.foo.reset(); myView.trigger("unwrapped"); expect(myView.foo) .to.be.calledOnce.and .to.have.returned("I'm fake"); }));
It is fairly straightforward to keep track of the order in which Backbone.js objects are initialized and stubbed for a single test, such as in the previous two code snippets. However, it is important to keep binding in mind for a test suite setup and teardown, especially when an object is instantiated in a different place from where it will later be mocked or stubbed. Additionally, the issue can manifest in various other places in Backbone.js applications, such as in the following:
events
property that binds UI events to methods by the string name of the method. This internally behaves similar to a naked function reference when a new view object is initialized by Backbone.js. An example of this type of declaration is as follows:events: { "click #id": "foo" }
routes
property that binds hash/URL fragments to named methods on the router object.The most important takeaway point is to always consider how a Sinon.JS fake will be bound to the Backbone.js component that is being tested. It is sometimes easier to avoid naked function references in a Backbone.js application component, and at other times, it is better to reformulate test code so that stubs can be bound before the component is initialized. In the Notes application, we will use both of the approaches for the remaining tests in this chapter.
The last Notes view that we will discuss and test in this book is the list item view. When a user navigates to the home page of the Notes application, they are presented with a list of notes identified by their titles. The App.Views.NotesItem
view is responsible for rendering each individual note row and allowing a user to view, edit, or delete a note. The following screenshot illustrates the rendered output for a single list item view:
The title text of a list item can be clicked on to view the rendered Markdown for a single note. A list item also contains two action buttons, one with a pencil icon for editing and the other with a trash can icon for deleting.
The list item template string is declared as the template-notes-item
property of App.Templates
in notes/app/js/app/templates/templates.js
:
App.Templates["template-notes-item"] = "<td class="note-name">" + " <div class="note-title note-view"><%= title %></div>" + "</td>" + "<td class="note-action">" + " <div class="btn-group pull-right">" + " <button class="btn note-edit">" + " <i class="icon-pencil"></i>" + " </button>" + " <button class="btn note-delete">" + " <i class="icon-trash"></i>" + " </button>" + " </div>" + "</td>";
The template renders two td
cells within a table row, one for the note title and the other for the edit/delete buttons.
The App.Views.NotesItem
view is defined in notes/app/js/app/views/notes-list.js
. The class definition starts with DOM attributes for rendering a tr
tag, a notes-item
class, and an id
property that corresponds to the note model's identifier:
App.Views.NotesItem = Backbone.View.extend({ id: function () { return this.model.id; }, tagName: "tr", className: "notes-item", template: _.template(App.Templates["template-notes-item"]),
Click events on a list item's title and edit/delete buttons are bound to their respective view methods, viewNote
, editNote
, and deleteNote
. In terms of our earlier Sinon.JS binding discussion, note that all of the event callbacks have function wrappers that allow us to create App.Views.NotesItem
objects that can be stubbed at any time during the tests:
events: { "click .note-view": function () { this.viewNote(); }, "click .note-edit": function () { this.editNote(); }, "click .note-delete": function () { this.deleteNote(); } },
In initialize
, the view stores a router reference and sets listeners that re-render or remove the view in response to model events. The render
method binds the model data to the template in a fairly conventional manner:
initialize: function (attrs, opts) { opts || (opts = {}); this.router = opts.router || app.router; this.listenTo(this.model, { "change": function () { this.render(); }, "destroy": function () { this.remove(); } }); }, render: function () { this.$el.html(this.template(this.model.toJSON())); return this; },
Turning to the actions we can perform on a single list item, the viewNote
and editNote
methods navigate to a single-note view in viewing or editing mode. The deleteNote
function deletes the underlying note model that then triggers events that will clean up and remove the view from the list of all notes:
viewNote: function () { var loc = ["note", this.model.id, "view"].join("/"); this.router.navigate(loc, { trigger: true }); }, editNote: function () { var loc = ["note", this.model.id, "edit"].join("/"); this.router.navigate(loc, { trigger: true }); }, deleteNote: function () { // Destroying model triggers view cleanup. this.model.destroy(); } });
The App.Views.NotesItem
view behaviors that we wish to verify in our test suite file chapters/05/test/js/spec/views/notes-item.spec.js
include the following:
The test suite starts with the before()
setup method where we create an App.Views.NotesItem
object with a fake router object literal (containing a navigate
stub) and a real App.Models.Note
model. In the afterEach()
method, we reset the navigate
stub so that each spec gets a stub that is free of any previously recorded function information. The after()
teardown function removes the view under test.
Again, keeping the Sinon.JS method's binding issues in mind, we note that this.view
is created in the before()
setup for the entire test suite. This means that stubs, spies, and/or mocks will only work on wrapped App.Views.NotesItem
view methods. At the same time, if the existing App.Views.NotesItem
suite is not amenable to all of the test double bindings that we need, we can easily create an additional suite that fakes the class prototype before instantiation, to provide additional flexibility in testing the desired application behavior:
describe("App.Views.NotesItem", function () { before(function () { this.navigate = sinon.stub(); this.view = new App.Views.NotesItem({ model: new App.Models.Note({ id: "0", title: "title" }) }, { router: { navigate: this.navigate } }); }); afterEach(function () { this.navigate.reset(); }); after(function () { this.view.remove(); });
The first nested test suite checks whether the underlying model's destroy
event triggers the view.remove()
method, cleaning up the view. We stub view.remove()
to prevent the view from actually being removed from the test environment when called. Then, we trigger the desired model event so that we can verify that the stub was called once:
describe("remove", function () { it("is removed on model destroy", sinon.test(function () { // Empty stub for view removal to prevent side effects. this.stub(this.view, "remove"); this.view.model.trigger("destroy"); expect(this.view.remove).to.be.calledOnce; })); });
In the next two specs, we tackle an analogous scenario, verifying that the note model's change
event will trigger a render()
call on the view. We make the same assertions in both the specs, using stubs in one and mocks in the other to demonstrate how to write the same functional spec using either abstraction. The spec renders on model change w/ stub
uses a stub to verify the view's behavior:
describe("render", function () { // One way to verify is with a stub. it("renders on model change w/ stub", sinon.test(function () { this.stub(this.view); this.view.model.trigger("change"); expect(this.view.render).to.have.been.calledOnce; }));
In the renders on model change w/ mock
spec, we rely on a mock to make the same assertion using the Sinon.JS once()
expectation modifier and mock.verify()
instead of Chai assertions on a stub:
// Here is another way to do the same check with a mock. it("renders on model change w/ mock", sinon.test(function () { var exp = this.mock(this.view).expects("render").once(); this.view.model.trigger("change"); exp.verify(); })); });
In the next two specs, we examine the scenarios in which a user clicks on the list item title (for viewing) or the pencil button (for editing). We need to check if both the clicks call an appropriate view function and cause the router to navigate us to the expected single-note page. In the ensuing code snippet, we verify this behavior by asserting that the router's navigate
stub has been called with appropriate arguments:
describe("actions", function () { it("views on click", function () { this.view.$(".note-view").click(); expect(this.navigate) .to.be.calledOnce.and .to.be.calledWith("note/0/view"); }); it("edits on click", function () { this.view.$(".note-edit").click(); expect(this.navigate) .to.be.calledOnce.and .to.be.calledWith("note/0/edit"); });
Finally, we ensure that clicking on the trash can button triggers the underlying note model to be destroyed. We stub the model's destroy
method to verify that it was called and to prevent the model from actually being mutated:
it("deletes on click", sinon.test(function () { // Empty stub for model destroy to prevent side effects. this.stub(this.view.model, "destroy"); this.view.$(".note-delete").click(); expect(this.view.model.destroy).to.be.calledOnce; })); }); });
All in all, our tests for App.Views.NotesItem
demonstrate how replacing method behaviors with mocks and stubs can simplify our tests and limit the program method's side effects.
The final Backbone.js component that we will test in the Notes application is the router, App.Routers.Router
. The router is responsible for managing client-side page locations (URLs or hash fragments) and binding routes to views, events, and actions.
For the purposes of this chapter, we will use a simplified version of the App.Routers.Router
class, available at chapters/05/test/js/spec/routers/router.js
, instead of the real Notes router file (found in the code samples at notes/app/js/app/routers/router.js
).
While the real Backbone.js router is not the most complex beast, it has sufficiently complicated dependencies and application logic to warrant omitting the full implementation in the text of this chapter, particularly when we just need to introduce a few testing tips for routers.
At the same time, we don't shy away from tests just because we have component dependencies. Accordingly, we provide a comprehensive test suite for the real App.Routers.Router
component in the code samples at notes/test/js/spec/routers/router.spec.js
. You are encouraged to review the implementations of the full router and its corresponding test suite.
The Notes application contains two routes corresponding to the notes list page and the single-note page. We encompass this behavior in the simplified App.Routers.Router
class:
App.Routers.Router = Backbone.Router.extend({ routes: { "": "notes", "note/:id/:action": "note", }, // Show notes list. notes: function () { // ... omitted ... }, // Common single note edit/view. note: function (noteId, action) { // ... omitted ... } });
Our tests should check if the route specifications bind to the correct router methods and if the URLs / hash fragments are correctly parsed into parameters for the router method. We verify this behavior in the test suite file chapters/05/test/js/spec/routers/router.spec.js
.
Our setup logic begins by creating stubs around the router's note
and notes
methods. We then instantiate a router object and start history (which enables actual routing). Our setup concludes with binding an anonymous spy to every route
event (fired any time a route is activated).
describe("App.Routers.Router", function () { // Default option: Trigger and replace history. var opts = { trigger: true, replace: true }; beforeEach(function () { // Stub route methods. sinon.stub(App.Routers.Router.prototype, "note"); sinon.stub(App.Routers.Router.prototype, "notes"); // Create router with stubs and manual fakes. this.router = new App.Routers.Router(); // Start history to enable routes to fire. Backbone.history.start(); // Spy on all route events. this.routerSpy = sinon.spy(); this.router.on("route", this.routerSpy); });
Our teardown logic stops the history and unwinds the stubs:
afterEach(function () { Backbone.history.stop(); App.Routers.Router.prototype.note.restore(); App.Routers.Router.prototype.notes.restore(); });
The first spec checks if we can navigate to a single note to edit it by calling the router's navigate
method on the desired route "note/1/edit"
. We assert that this calls the router's note
method (which we have stubbed) with the extracted parameters "1"
and "edit"
. We also confirm the same type of information with the routerSpy
event listener:
it("can route to note", function () { this.router.navigate("note/1/edit", opts); // Check router method. expect(App.Routers.Router.prototype.note) .to.have.been.calledOnce.and .to.have.been.calledWithExactly("1", "edit"); // Check route event. expect(this.routerSpy) .to.have.been.calledOnce.and .to.have.been.calledWith("note", ["1", "edit"]); });
Our second spec verifies that we can navigate to the home page, then to a single-note page, and then back to the home page. We use similar verification logic as in the previous spec, relying on the notes
stub (called twice on the ""
home page route) and the routerSpy
spy (called on all three routes):
it("can route around", function () { // Bounce between routes. this.router.navigate("", opts); this.router.navigate("note/1/edit", opts); this.router.navigate("", opts); // Check router method. expect(App.Routers.Router.prototype.notes) .to.have.been.calledTwice.and .to.have.been.calledWithExactly(); // Check route event. expect(this.routerSpy) .to.have.been.calledThrice.and .to.have.been.calledWith("notes"); }); });
These router tests are not that different from Backbone.js view tests for events—both of them bind strings (a route or a UI event) to component methods (via a string name or function). All in all, the Sinon.JS mocking and stubbing methods we have learned in this chapter should generally apply to any type of Backbone.js component.
Now that we have test suites for App.Views.NotesItem
and App.Routers.Router
, we can integrate them into a test driver page. Building on the previous chapters/04/test/test.html
driver page (with a few highlighted additions), our final driver page chapters/05/test/test.html
includes the following relevant parts:
<head> <!-- ... snipped ... --> <!-- JavaScript Application Libraries --> <script src="../app/js/app/namespace.js"></script> <script src="../app/js/app/config.js"></script> <script> // Test overrides (before any app components). App.Config = _.extend(App.Config, { storeName: "notes-test" // localStorage for tests. }); </script> <script src="../app/js/app/models/note.js"></script> <script src="../app/js/app/collections/notes.js"></script> <script src="../app/js/app/templates/templates.js"></script> <script src="../app/js/app/views/note-nav.js"></script> <script src="../app/js/app/views/note-view.js"></script> <script src="../app/js/app/views/note.js"></script> <script src="../app/js/app/views/notes-item.js"></script> <!-- The shortened, teaching router for Chapter 05 --> <script src="js/spec/routers/router.js"></script> <!-- ... snipped ... --> <!-- Tests. --> <script src="js/spec/views/notes-item.spec.js"></script> <script src="js/spec/routers/router.spec.js"></script> </head>
At this point, we have accrued a large number of JavaScript files between the vendor libraries and our Backbone.js application components. While this is acceptable for tests (and sometimes even desired), it is good practice to concatenate and optimize your JavaScript files in production applications with a tool such as the Google Closure Compiler (https://developers.google.com/closure/compiler/) or UglifyJS (https://github.com/mishoo/UglifyJS2).
We can now navigate a browser window to chapters/05/test/test.html
to run the tests.
One thing you may notice is that invocations of the navigate
method in the router tests actually modify the browser location, adding hash fragments. While this doesn't affect the correctness of our tests, it is a bit unexpected. Taking an alternative approach, the Backbone.js library test suite gets around this issue by creating a fake Location
object to substitute for the real browser navigation bar. See https://github.com/documentcloud/backbone/blob/master/test/router.js for further details.
18.118.0.240