Testing Backbone.js components with spies

With our Sinon.JS spies and other utilities ready, we will begin spying on our Backbone.js application. In this section, we will introduce and test two Notes application views—the menu bar view and the single note view.

Tip

Working through the examples

Reiterating a point from the previous chapter, we will present the menu bar view and single note view implementations with the code first and the tests second, to help maintain a narrative structure that properly introduces the Notes application (and to keep things brief). This is not the preferred order for actual test development.

Accordingly, while working through this chapter, we suggest that you put this book down for a moment after reading the described behavior of each component. See if you can design and implement your own tests for the sample application components. After this exercise, you can continue reading and compare your tests with the component test suites in this chapter.

The Notes menu bar view

The Notes menu bar view, App.Views.NoteNav, controls the Edit, View, and Delete menu bar buttons for a single note. The following screenshot illustrates the menu bar with an active View button.

The Notes menu bar view

Single page menu bar view

The App.Views.NoteNav view coordinates incoming/outgoing events for the view, edit, and delete menu actions. For example, if the Edit button was clicked on in the previous figure, the App.Views.NoteNav view would emit the following custom Backbone.js events:

  • nav:update:edit: This causes the active HTML menu bar item to switch to a new selected action, for example, changing from View to Edit.
  • nav:edit: This is emitted to signal other Backbone.js components that the operative action (for example, view or edit) has changed. For example, the App.Views.Note view listens on this event and displays HTML for the appropriate corresponding action pane in its view area.

The menu bar view is attached to the DOM list #note-nav, which is provided by the notes/app/index.html application page. The HTML for #note-nav can be abbreviated to the following essential parts:

<ul id="note-nav"
  class="nav region region-note"
  style="display: none;">
  <li class="note-view active">View</li>
  <li class="note-edit">Edit</li>
  <li class="note-delete">Delete</li>
</ul>

The menu bar list is hidden by default (but shown by App.Views.Note). After instantiation, the App.Views.NoteNav view sets up various listeners and activates the proper menu bar item.

The menu bar view

Now that we have reviewed the display setup and overall functionality of the view, we can dive into the application code at notes/app/js/app/views/note-nav.js:

App.Views.NoteNav = Backbone.View.extend({

  el: "#note-nav",

After specifying a default el element to attach the view to, the view binds the user menu bar clicks to the appropriate actions (for example, edit) in events and sets listeners in initialize to update the menu bar on occurrence of external events:

  events: {
    "click .note-view":   "clickView",
    "click .note-edit":   "clickEdit",
    "click .note-delete": "clickDelete",
  },

  initialize: function () {
    // Defaults for nav.
    this.$("li").removeClass("active");

    // Update the navbar UI for view/edit (not delete).
    this.on({
      "nav:update:view": this.updateView,
      "nav:update:edit": this.updateEdit
    });
  },

The functions updateView and updateEdit switch the active CSS class, which visually changes the highlighted tab in the menu bar:

  updateView: function () {
    this.$("li").not(".note-view").removeClass("active");
    this.$(".note-view").addClass("active");
  },
  updateEdit: function () {
    this.$("li").not(".note-edit").removeClass("active");
    this.$(".note-edit").addClass("active");
  },

The clickView, clickEdit, and clickDelete functions emit the view events corresponding to the menu bar actions:

  clickView: function () {
    this.trigger("nav:update:view nav:view");
    return false;
  },
  clickEdit: function () {
    this.trigger("nav:update:edit nav:edit");
    return false;
  },
  clickDelete: function () {
    this.trigger("nav:update:delete nav:delete");
    return false;
  }
});

Testing and spying on the menu bar view

The App.Views.NoteNav view is fairly small and essentially just proxies events and updates the menu bar UI. Our testing goals are similarly modest:

  • Verify that App.Views.NoteNav is bound to the DOM correctly, either by defaulting to #note-nav or via a passed el parameter
  • Check that the menu bar action events are triggered and listened to correctly
  • Ensure that the menu bar HTML is modified in response to appropriate actions

With these guidelines in mind, let's step through chapters/04/test/js/spec/views/note-nav.spec.js, which is the suite for the menu bar view.

The suite starts out by setting up a test fixture and a view. The before() call creates the minimum HTML that we will need to produce a menu bar list suitable for testing the view. The beforeEach() function attaches this.$fixture to the #fixtures container already in the DOM and creates a new App.Views.NoteNav object. The afterEach() call removes the view and after() empties out the #fixtures container completely:

describe("App.Views.NoteNav", function () {
  before(function () {
    this.$fixture = $(
      "<ul id='note-nav'>" +
        "<li class='note-view'></li>" +
        "<li class='note-edit'></li>" +
        "<li class='note-delete'></li>" +
      "</ul>"
    );
  });

  beforeEach(function () {
    this.$fixture.appendTo($("#fixtures"));
    this.view = new App.Views.NoteNav({
      el: this.$fixture
    });
  });

  afterEach(function () {
    this.view.remove();
  });

  after(function () {
    $("#fixtures").empty();
  });

The first nested suite, events, contains one spec that verifies if a click on a menu bar item fires the appropriate nav:* and nav:update:* events. We create three Sinon.JS spies to help us with this task:

  • navSpy and updateSpy: These objects spy on the events nav:view and nav:update:view and should be called when the View menu bar item is clicked on
  • otherSpy: This spy listens on all other potential action events and is used to check whether the other events did not fire

We use the Sinon-Chai adapter extensions to make our spy assertions:

  describe("events", function () {
    it("fires events on 'view' click", function () {
      var navSpy = sinon.spy(),
        updateSpy = sinon.spy(),
        otherSpy = sinon.spy();

      this.view.on({
        "nav:view": navSpy,
        "nav:update:view": updateSpy,
        "nav:edit nav:update:edit": otherSpy,
        "nav:delete nav:update:delete": otherSpy
      });

      this.$fixture.find(".note-view").click();

      expect(navSpy).to.have.been.calledOnce;
      expect(updateSpy).to.have.been.calledOnce;
      expect(otherSpy).to.not.have.been.called;
    });
  });

The specs in the menu bar display suite inspect DOM content and page interactions with the view. The first spec, has no active navs by default, checks that the menu bar HTML has no active selection by default—which, for a Bootstrap-based navigation bar, means the absence of the active CSS class:

  describe("menu bar display", function () {
    it("has no active navs by default", function () {
      // Check no list items are active.
      expect(this.view.$("li.active")).to.have.length(0);

      // Another way - manually check each list nav.
      expect($(".note-view")
        .attr("class")).to.not.include("active");
      expect($(".note-edit")
        .attr("class")).to.not.include("active");
      expect($(".note-delete")
        .attr("class")).to.not.include("active");
    });

Then, the remaining specs check whether clicking on the Edit menu bar tab or firing a direct nav:update:edit event causes the corresponding menu bar item to be activated (via insertion of the CSS class active):

    it("updates nav on 'edit' click", function () {
      $(".note-edit").click();
      expect($(".note-edit").attr("class")).to.include("active");
    });

    it("updates nav on 'edit' event", function () {
      this.view.trigger("nav:update:edit");
      expect($(".note-edit").attr("class")).to.include("active");
    });
  });
});

With the previous tests, we can verify that App.Views.NoteNav fires appropriate events and its HTML responds to user clicks and external events.

The Notes single note view

The App.Views.Note view controls everything that we have encountered so far with respect to a single note. Each App.Views.Note object instantiates a new App.Views.NoteView object and refers to an external App.Views.NoteNav object.

The main responsibilities of the class, which we will want to verify in the tests, include the following:

  • Update the appropriate viewing pane mode (for example, edit or view) in response to menu bar action events
  • Delete a single note model, and then clean up views and route back to the all notes list view
  • Require user confirmation before deleting a note
  • Save note model data into the backend storage in response to edit form field changes
  • Update the HTML display panes in response to model data changes

We will first look at the HTML template string used by the view. It is found in our application template file, notes/app/js/app/templates/templates.js:

App.Templates["template-note"] =
  "<div id="note-pane-view" class="pane">" +
  "  <div id="note-pane-view-content"></div>" +
  "</div>" +
  "<div id="note-pane-edit" class="pane">" +
  "  <form id="note-form-edit">" +
  "    <input id="input-title" class="input-block-level"" +
  "           type="text" placeholder="title"" +
  "           value="<%= title %>">" +
  "    <textarea id="input-text" class="input-block-level"" +
  "              rows="15"><%= text %></textarea>" +
  "  </form>" +
  "</div>";

The template provides two div UI panes for action modes—note-pane-view for viewing a note and note-pane-edit for editing data. It also binds two template variables—title and text—to the editing inputs in the note-form-edit form.

The single note view

Getting into the application code at notes/app/js/app/views/note-nav.js, we start by declaring the DOM identifier and template and then set up two events—the first one saves note data on occurrence of the browser's blur event, and the second one prevents the editing form from doing a real HTTP page submission:

App.Views.Note = Backbone.View.extend({

  id: "note-panes",

  template: _.template(App.Templates["template-note"]),

  events: {
    "blur   #note-form-edit": "saveNote",
    "submit #note-form-edit": function () { return false; }
  },

The initialize function does most of the heavy lifting for the view. First, it sets this.nav from the parameter options and this.router from either options or from the external app application object.

Note

The reason we optionally take a router object from the opts parameter is that it makes it easier to override Backbone.js dependencies. In our tests, we will use opts to pass a Sinon.JS spy instead of a real router that records behavior but doesn't actually route. A different approach to this scenario (introduced in the next chapter) is to stub or mock app.router directly.

Then, the view sets up event listeners on various objects by calling the helper function _addListeners. Finally, the view object renders its Underscore.js template to HTML with model data, sets the action state, and instantiates a child App.Views.NoteView object to handle Markdown rendering:

initialize: function (attrs, opts) {
    opts || (opts = {});
    this.nav = opts.nav;
    this.router = opts.router || app.router;

    // Add our custom listeners.
    this._addListeners();

    // Render HTML, update to action, and show note.
    this.$el.html(this.template(this.model.toJSON()));
    this.update(opts.action || "view");
    this.render();

    // Add in viewer child view (which auto-renders).
    this.noteView = new App.Views.NoteView({
      el: this.$("#note-pane-view-content"),
      model: this.model
    });
  },

As a part of initialization, the _addListeners helper binds object events as follows:

  • Model (this.model): The view removes itself when the model is destroyed. It re-renders and saves the model to the backend when the model data changes.
  • Menu bar view (this.nav): The note view listens to the menu bar nav events and calls specific action functions such as viewNote() when a user clicks on View.
  • Note view (this): The note view also directly listens for action state (viewing or editing) events from external Backbone.js components. For instance, the application router uses these events to activate an existing App.Views.Note view object and set an appropriate action state.

Translating this into code produces the following function:

  _addListeners: function () {
    // Model controls view rendering and existence.
    this.listenTo(this.model, {
      "destroy": function () { this.remove(); },
      "change":  function () { this.render().model.save(); }
    });

    // Navbar controls/responds to panes.
    this.listenTo(this.nav, {
      "nav:view":   function () { this.viewNote(); },
      "nav:edit":   function () { this.editNote(); },
      "nav:delete": function () { this.deleteNote(); }
    });

    // Respond to update events from router.
    this.on({
      "update:view": function () { this.render().viewNote(); },
      "update:edit": function () { this.render().editNote(); }
    });
  },

The render() function displays the HTML for the single note view and hides any HTML content used by other views:

  // Rendering the note is simply showing the active pane.
  // All HTML should already be rendered during initialize.
  render: function () {
    $(".region").not(".region-note").hide();
    $(".region-note").show();
    return this;
  },

The remove() method first removes the contained App.Views.NoteView object and then the App.Views.Note object itself:

  remove: function () {
    // Remove child, then self.
    this.noteView.remove();
    Backbone.View.prototype.remove.call(this);
  },

The update() method takes an action string parameter ("view" or "edit"), then triggers the menu bar view to update to the new state, shows the appropriate HTML action pane, and updates the URL hash fragment:

  update: function (action) {
    action = action || this.action || "view";
    var paneEl = "#note-pane-" + action,
      loc = "note/" + this.model.id + "/" + action;

    // Ensure menu bar is updated.
    this.nav.trigger("nav:update:" + action);

    // Show active pane.
    this.$(".pane").not(paneEl).hide();
    this.$(paneEl).show();

    // Store new action and navigate.
    if (this.action !== action) {
      this.action = action;
      this.router.navigate(loc, { replace: true });
    }
  },

The next three methods—viewNote(), editNote(), and deleteNote()—handle the basic actions for a single note. The first two methods simply call update() with the appropriate action, while deleteNote() destroys the note model and routes back to the all notes list (that is, the application home page):

  viewNote: function () {
    this.update("view");
  },
  editNote: function () {
    this.update("edit");
  },
  deleteNote: function () {
    if (confirm("Delete note?")) {
      this.model.destroy();
      this.router.navigate("", { trigger: true, replace: true });
    }
  },

Finally, saveNote() takes the edit form input and updates the underlying note model:

  saveNote: function () {
    this.model.set({
      title: this.$("#input-title").val().trim(),
      text: this.$("#input-text").val().trim()
    });
  }
});

Testing the single note view

Our tests for App.Views.Note center around the various responsibilities of the class we discussed while introducing the view. Specifically, we want to verify that the note view can update UI elements for actions (for example, view and edit), delete notes, save model data, and correctly bind events across various other Backbone.js application components.

Walking through chapters/04/test/js/spec/views/note.spec.js, the single note test suite, we start by creating an initial test state. In the suite-wide before() function, we add fixture elements for regions (of which App.Views.Note uses region-note), an HTML fixture for the view itself, and then stub the note model prototype's save() method.

Note

While Sinon.JS stubs are not fully introduced in this chapter, we use one here to record calls to save() like a spy and also to prevent the method from trying to save to a remote backend, which would throw an error in this test context.

describe("App.Views.Note", function () {

  before(function () {
    // Regions for different views.
    $("#fixtures").append($(
      "<div class='region-note' style='display: none;'></div>" +
      "<div class='region-notes' style='display: none;'></div>"
    ));

    // App.Views.Note fixture.
    this.$fixture = $(
      "<div id='note-fixture region-note'>" +
        "<div id='#note-pane-view-content'></div>" +
      "</div>"
    );

    // Any model changes will trigger a `model.save()`, which
    // won't work in the tests, so we have to fake the method.
    sinon.stub(App.Models.Note.prototype, "save");
  });

In the beforeEach() setup method, we attach the view fixtures to the fixture container and create a spy function meant to replace our real Backbone.js router. Then, we create an App.Views.Note object and bind the fixtures and a new App.Models.Note to it. We also provide two initialization options to the App.Views.Note instance:

  • nav: We pass a raw Backbone.View object as a replacement for the menu bar view to proxy events through, while omitting the real view logic and DOM interaction
  • router: We pass this.routerSpy to record the Backbone.js routing events without actually changing our browser history/URL state
    beforeEach(function () {
        this.routerSpy = sinon.spy();
        this.$fixture.appendTo($("#fixtures"));
    
        this.view = new App.Views.Note({
          el: this.$fixture,
          model: new App.Models.Note()
        }, {
          nav: new Backbone.View(),
          router: {
            navigate: this.routerSpy
          }
        });
      });

It is worth noting that we inject four view dependencies (el, model, nav, and router) into App.Views.Note to help isolate the instance and make it testable. With this configuration, the specs in our suite could be considered partial integration tests because we are using (and testing) real Backbone.js objects beyond the view under test.

Another observation with the previous setup is that the nav and router option parameters are specifically chosen to avoid triggering the real behavior of the full application; for example, manipulating the menu bar DOM or changing the browser's URL. As we will learn in Chapter 5, Test Stubs and Mocks, this type of behavior replacement is much more concisely and appropriately performed with Sinon.JS stubs or mocks.

Moving on to the test teardown in afterEach(), we clear out the test fixtures and delete any view objects still around. (The specs may already have destroyed the test view object.) Finally, at the end of the suite in after(), we clear out the top-level fixture container and restore the save() method of the App.Models.Note class to its original state:

  afterEach(function () {
    this.$fixture.empty();
    if (this.view) { this.view.model.destroy(); }
  });

  after(function () {
    $("#fixtures").empty();
    App.Models.Note.prototype.save.restore();
  });

With our setup/teardown complete, we move on to the first nested test suite, view modes and actions, which verifies that the user DOM interaction and Backbone.js events can control the note view and cause it to switch between editing, viewing, and deleting modes:

  describe("view modes and actions", function () {

By default, an App.Views.Note view routes to the URL hash fragment #note/:id/view and displays the viewing mode HTML. We use our router spy to verify the suffix of the called hash fragment using the Sinon-Chai calledWithMatch extension. Then, we assert that only the viewing pane #note-pane-view is visible with a simple CSS display property check:

    it("navigates / displays 'view' by default", function () {
      expect(this.routerSpy).to.be.calledWithMatch(/view$/);

      // Check CSS visibility directly. Not necessarily a best
      // practice as it uses internal knowledge of the DOM, but
      // gets us a quick check on what should be the visible
      // view pane.
      expect($("#note-pane-view")
        .css("display")).to.not.equal("none");
      expect($("#note-pane-edit")
        .css("display")).to.equal("none");
    });

The next spec triggers the update:edit event and then verifies that this changes the URL hash fragment to #note/:id/edit and displays the editing pane:

    it("navigates / displays 'edit' on event", function () {
      this.view.trigger("update:edit");
      expect(this.routerSpy).to.be.calledWithMatch(/edit$/);

      expect($("#note-pane-edit")
        .css("display")).to.not.equal("none");
      expect($("#note-pane-view")
        .css("display")).to.equal("none");
    });

We test the note deletion behavior by stubbing out the confirm() pop up to always return false (preventing the actual note deletion) and then calling deleteNote(). We need this stub to prevent an actual browser confirmation window from popping up during our test run. Then, we use the spy properties of the stub to verify that confirm() was called correctly:

    it("confirms note on delete", sinon.test(function () {
      this.stub(window, "confirm").returns(false);
      this.view.deleteNote();
      expect(window.confirm)
        .to.have.been.calledOnce.and
        .to.have.been.calledWith("Delete note?");
    }));
  });

The next test suite, model interaction, contains a single spec that verifies that the deletion of a model causes the App.Views.Note object to remove itself and its contained App.Views.NoteView object. Accordingly, we set up spies on the remove() methods of both the views.

Tip

Failure to clean up the views, models, and so on once they are no longer used can lead to memory leaks, which may have significant impact on the overall application performance. Triggering App.Views.Note and App.Views.NoteView object removals on the destruction of the underlying note model is one way of reclaiming used memory from the various components of a Backbone.js application.

At the same time, there are many other techniques to keep the memory in check. Zombies! RUN! (http://lostechies.com/derickbailey/2011/09/15/zombies-run-managing-page-transitions-in-backbone-apps/) and Backbone.js And JavaScript Garbage Collection (http://lostechies.com/derickbailey/2012/03/19/backbone-js-and-javascript-garbage-collection/) are posts by Derick Bailey that provide a great introduction to Backbone.js memory management issues and solutions.

  describe("model interaction", function () {
    afterEach(function () {
      // Wipe out to prevent any further use.
      this.view = null;
    });

    it("is removed on destroyed model", sinon.test(function () {
      this.spy(this.view, "remove"),
      this.spy(this.view.noteView, "remove");

      this.view.model.trigger("destroy");

      expect(this.view.remove).to.be.calledOnce;
      expect(this.view.noteView.remove).to.be.calledOnce;
    });
  });

The last nested test suite, note rendering, checks that model data is correctly rendered to HTML and that rendering is triggered in response to expected application events. The first spec, can render a note, verifies that render() shows the appropriate HTML region elements and hides the rest:

  describe("note rendering", function () {

    it("can render a note", function () {
      // Don't explicitly call `render()` because
      // `initialize()` already called it.
      expect($(".region-note")
        .css("display")).to.not.equal("none");
      expect($(".region-notes")
        .css("display")).to.equal("none");
    });

The next two specs check that the render() method is triggered on appropriate changes. The spec calls render on model events verifies that render() is called whenever the model changes:

    it("calls render on model events", sinon.test(function () {
      // Spy on `render` and check call/return value.
      this.spy(this.view, "render");

      this.view.model.trigger("change");

      expect(this.view.render)
        .to.be.calledOnce.and
        .to.have.returned(this.view);
    }));

The final spec modifies data in the single note edit form like a user would and then triggers the blur event to force model change events. The spec spies on the render() method and checks that the rendered Markdown HTML has been updated to reflect the new data:

    it("calls render on changed data", sinon.test(function () {
      this.spy(this.view, "render");

      // Replace form value and blur to force changes.
      $("#input-text").val("# A Heading!");
      $("#note-form-edit").blur();

      // `Note` view should have rendered.
      expect(this.view.render)
        .to.be.calledOnce.and
        .to.have.returned(this.view);

      // Check the `NoteView` view rendered the new markdown.
      expect($("#pane-text").html())
        .to.match(/<h1 id=".*?">A Heading!</h1>/);
    }));
  });
});

With all of the specs in this suite, we have increased confidence that the App.Views.Note class can emit/listen to appropriate events, clean up application objects on model deletion, and other behaviors that we earlier identified as the core responsibilities of the view.

Hooking up and running the view tests

Now that we have our test suites for App.Views.NoteNav and App.Views.Note, let's wire up the test driver page chapters/04/test/test.html. We can re-use the same code as in chapters/03/test/test.html, with a few (highlighted in the ensuing code) differences, that add in the Sinon-Chai plugin, more Notes application libraries, and our new spec files:

<head>
  <!-- ... snipped ... -->

  <!-- JavaScript Test Libraries. -->
  <script src="js/lib/mocha.js"></script>
  <script src="js/lib/chai.js"></script>
  <script src="js/lib/sinon-chai.js"></script>
  <script src="js/lib/sinon.js"></script>

  <!-- JavaScript Core Libraries -->
  <!-- ... 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>

  <!-- ... snipped ... -->

  <!-- Tests. -->
  <script src="js/spec/views/note-nav.spec.js"></script>
  <script src="js/spec/views/note.spec.js"></script>
</head>

We can run the tests by opening a browser to chapters/04/test/test.html. (Note that the code samples contain the additional specs that are omitted from this chapter for brevity).

Hooking up and running the view tests

Test report

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

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