Testing Backbone Views

We already have seen some of the advantages of using the View pattern in Chapter 3, Testing Frontend Code, and are already creating our interface components in such a manner. So how can a Backbone View be different from what we have done so far?

It retains a lot of the patterns that we have discussed as best practices for creating maintainable browser code, but with some syntax sugar and automation to make our life easier.

They are the glue code between the HTML and the model, and the Backbone View's main responsibility is to add behavior to the interface, while keeping it in sync with a model or collection.

As we will see, Backbone's biggest triumph is how it makes an easy-to-handle DOM event delegation, a task usually done with jQuery.

Declaring a new View

Very similar to what we have seen so far, declaring a new View is going to be a matter of extending the base Backbone.View object.

To demonstrate how it works we need an example. We are going to create a new View and its responsibility is going to be to render a single investment on the screen.

We are going to create it in such a way that allows its use by the InvestmentListView component discussed briefly in Chapter 3, Testing Frontend Code.

This is a new component and spec, written in src/InvestmentView.js and spec/InvestmentViewSpec.js respectively.

In the spec file, we can write something similar to what we have seen previously:

describe("InvestmentView", function() {
  var view;

  beforeEach(function() {
    view = new InvestmentView();
  });

  it("should be a Backbone View", function() {
    expect(view).toEqual(jasmine.any(Backbone.View));
  });
});

Which translates into an implementation that extends the base Backbone View component:

(function (Backbone) {
  var InvestmentView = Backbone.View.extend()
  this.InvestmentView = InvestmentView;
})(Backbone);

And now we are ready to explore some of the new functionalities provided by Backbone.

The el property

Like the View pattern described in Chapter 3, Testing Frontend Code, a Backbone View also has an attribute containing the reference to its DOM element.

The difference here is that Backbone comes with it by default, providing:

  • view.el: The DOM element
  • view.$el: The jQuery object for that element
  • view.$: A scoped jQuery lookup function (the same way we have implemented)

And if you don't provide an element on the constructor, it creates an element for you automatically. Of course the element it creates is not attached to the document, and is up to the View's user code to attach it.

Here is a common pattern you see while using Views:

  1. Instantiate it:
    var view = new InvestmentView();
  2. Call the render function to draw the View's components (as we will see in the next section):
    view.render()
  3. Append its element to the page document:
    $('body').append(view.el);

Given our clean implementation of the InvestmentView, if you would go ahead and execute the preceding code on a clean page, you would get the following result:

<body>
  <div></div>
</body>

An empty div element; that is the default element created by Backbone. But we can change that with a few configuration parameters on the InvestmentView declaration.

Let's say we want the DOM element of InvestmentView to be a list item (li) with an investment CSS class. We could write this spec using the familiar Jasmine jQuery matchers:

describe("InvestmentView", function() {
  var view;

  beforeEach(function() {
    view = new InvestmentView();
  });

  it("should be a list item with 'investment' class", function() {
    expect(view.$el).toBe('li.investment'),
  });
});

You can see that we didn't use the setFixtures function, since we can run this test against the element instance available on the View.

Now to the implementation; all we have to do, is define two simple attributes in the View definition, and Backbone will use them to create the View's element:

var InvestmentView = Backbone.View.extend({
  className: 'investment',
  tagName: 'li'
});

By looking at the implementation you might be wondering if we shouldn't test it like we did with in the Backbone Model: Sync and AJAX requests section. Here I would recommend against it, since you wouldn't get any benefit from that approach, as this spec is much more solid.

That is great, but how do we add content to that DOM element? That is up to the render function we are going to see next.

Remember that we could have passed an element while constructing the View, in the same way we were doing in Chapter 3, Testing Frontend Code:

var view = new InvestmentView({ el: $('body') });

But by letting the View handle its rendering, we get better componentization and we can also gain on performance.

Rendering

Now that we understand that it is a good idea to have an empty element available on the View, we must get into the details of how to draw on this empty canvas.

Backbone Views already come with an available render function, but it is a dummy implementation, so it is up to you to define how it works.

Going back to the InvestmentView example, let's add a new acceptance criterion to describe how it should be rendered. We are going to start by expecting that it renders the return of investment as a percentage value. Here is the spec implementation:

describe("InvestmentView", function() {
  var view, investment;

  beforeEach(function() {
    investment = new Investment();
    view = new InvestmentView({ model: investment });
  });

  describe("when rendering", function() {
    beforeEach(function() {
      investment.set('roi', 0.1);
      view.render();
    });

    it("should render the return of investment", function() {
      expect(view.$el).toContainHtml('10%'),
    });
  });
});

That is a very standard spec with concepts that we have seen before and the implementation is just a matter of defining the render function on the InvestmentView declaration:

var InvestmentView = Backbone.View.extend({
  className: 'investment',
  tagName: 'li',

  render: function () {
    this.$el.html('<p>'+ formatedRoi.call(this) +'<p>'),
    return this;
  }
});

function formatedRoi () {
  return (this.model.get('roi') * 100) + '%';
}

It is using the this.$el property to add some HTML content to the View's element. There are some details that are important for you to notice regarding the render function implementation:

  • We are using the jQuery.html function, so that we can invoke the render function multiple times without duplicating the View's content.
  • The render function returns the View instance once it has completed rendering. This is a common pattern to allow chained calls, such as:
    $('body').append(new InvestmentView().render().el);

Now back to the test. You can see that we weren't testing for the specific HTML snippet, but rather, that just 10 percent text was rendered. You could have done a more thoroughly written spec by checking the exact same HTML at the expectation, but that ends up adding test complexity with little benefit.

Updating the View on model changes

We understand how Views are rendered and how model events work, wouldn't it be great if we could tie these things together and make the View render itself every time a model changes? That is exactly what we can do!

Back at the InvestmentView spec we can add a new spec to check if the View renders itself once the model gets updated:

describe("when the investment changes", function() {
  beforeEach(function() {
    spyOn(view, 'render'),
    investment.trigger('change', investment);
  });

  it("should update the interface", function() {
    expect(view.render).toHaveBeenCalled();
  });
});

The spec works by triggering a change event, the same way the model does when we set an attribute, and then expect that the render function was called.

That looks ok, so the next step is to add the implementation in the InvestmentView constructor:

var InvestmentView = Backbone.View.extend({
  className: 'investment',
  tagName: 'li'
  initialize: function () {
    this.model.on('change', this.render, this);
  },

  render: function () {
    this.$el.html('<p>'+ formatedRoi.call(this) +'<p>'),
    return this;
  }
});

Just like other Backbone abstractions, the constructor can be implemented by adding an initialize function to the View's definition.

Next, we use the model's event infrastructure to bind the change event to the View's render function:

this.model.on('change', this.render, this);

This implementation is correct, but if you try to run the spec, it should be failing because of a detail on how Spies work.

You see, by spying on the View's render function, we are actually replacing its original implementation by a spy function. Since the InvestmentView binds the event on its constructor, it is still binding on the original render implementation, not our spy.

To make this test work, we need to set up the spy on the render function before instantiating the View. We can do that by setting the spy on the render function in the InvestmentView's prototype, before instantiating the View:

beforeEach(function() {
  spyOn(InvestmentView.prototype, 'render'),

  investment = new Investment();
  view = new InvestmentView({
    model: investment
  });
});

describe("when the investment changes", function() {
  beforeEach(function() {
    investment.trigger('change', investment);
  });

  it("should update the interface", function() {
    expect(view.render).toHaveBeenCalled();
  });
});

And now the specs should be passing!

Before we can go to the next section, there is one more thing that is important to know, and it concerns memory leaking. By adding that event listener, the View and model instances are going to be bound forever, so even after destroying the View instance, it is never going to be freed from the memory until the model instance has been freed.

To fix that, Backbone provides another function to all of its components to allow listening to other component events; the listenTo function.

So instead of adding the event handler to the model:

this.model.on('change', this.render, this);

We ask the View to listen for that event on the model:

this.listenTo(this.model, 'change', this.render, this);

By using listenTo, the View knows of all the event handlers created by it, and once it is destroyed, it can remove all of them.

But to make it work, you must remember that whenever you finish using a View, you explicitly remove it by invoking the View's remove function:

view.remove()

Binding DOM events

Up until now we have the View rendering and being updated on every model change, but what about updating the model once the View changes?

It is all a matter of adding event handlers to the View DOM elements and once they are triggered, we update the model.

To demonstrate this concept, let's add another acceptance criteria to our InvestmentView. We want to add a new button, that once clicked, triggers the destruction of the investment model.

To be able to click on the destroy button, first it needs to be rendered. So let's write this spec as follows:

describe("InvestmentView", function() {
  var view, investment;

  beforeEach(function() {
    investment = new Investment();
    view = new InvestmentView({
      model: investment
    });
  });

  describe("when rendering", function() {
    beforeEach(function() {
      view.render();
    });

    describe("when the destroy button is clicked", function() {
      beforeEach(function() {
        spyOn(investment, 'destroy'),
        view.$('.destroy-investment').click();
      });

      it("should destroy the model", function() {
        expect(investment.destroy).toHaveBeenCalled();
      });
    });
  });
});

We add a spy on the investment.destroy function, then we simulate the click, and finally expect the destroy function to have been called.

Now comes the nice part. Normally you would have to add that click event on jQuery by hand, but with Backbone, all you have to do is add an events object to the InvestmentView definition.

This events object must contain the DOM events that you want to bind, next to a function definition or function name, and Backbone does the binding for you.

Here is the code for the InvestmentView:

var InvestmentView = Backbone.View.extend({
  className: 'investment',
  tagName: 'li',
  events: {
    'click .destroy-investment': function () {
      this.model.destroy();
    }
  },

  initialize: function () {
    this.listenTo(this.model, 'change', this.render, this);
  },

  render: function () {
    this.$el.html('<p>'+ formatedRoi.call(this) +'<p>'),
    return this;
  }
});

Here you can see that we are listening for clicks on the destroy-investment button and passing a function handler that calls the destroy function of the model.

To define the event, we need to pass which event we want to listen to and on which element. Remember that this is all scoped to the View's DOM element:

'click .destroy-investment'

But this definition can also receive just an event type, in case you want to add events to the View DOM element itself. Suppose we want to get all clicks to View:

events: {
  'click': function () {}
}

It also supports passing a function name, letting Backbone look for the function definition on the View instance, and call it for you:

var InvestmentView = Backbone.View.extend({
  events: {
    'click .destroy-investment': 'destroyTheModel'
  },

  destroyTheModel: function () {
    this.model.destroy();
  },
});

Another thing you might have noticed is that Backbone is taking care of calling your event handlers while binding the this value to the View instance, a little detail you don't have to worry anymore.

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

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