Testing Backbone.js components with stubs and mocks

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.

Ensuring stubs and mocks are actually bound

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:

  • View events: Views can declare an 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"
    }
  • Router routes: Similarly, routers typically declare a 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 Notes list item view

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 Notes list item view

Notes 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 list item view

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();
  }
});

Testing the list item view

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 view renders HTML for a single row in the notes list table and shows the note's title and the action buttons
  • It binds click events to the appropriate note actions (for example, edit) and navigates to the appropriate single note page to read or edit a note
  • It correctly cleans up the object state when a user deletes a note

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 Notes application router

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).

Note

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).

Note

Always on the lookout for Sinon.JS binding issues, note that we have to stub the router prototype before we instantiate a router object because the router object's routes property binds routes to method name strings and not to wrapped functions.

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.

Running the view and router tests

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>

Tip

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.

Note

If you are running the report from the code samples, a few extra view specs that have not been discussed in this book will appear in the results.

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.118.0.240