Testing Marionette.js Behaviors

(23 November, 2015)

How to unit test your Marionette.js Behaviors with ease.

Context

This is a talk I gave on November 10th, 2015 at meetup Backbone.js Paris S02E01.

Video (FR)

Slides

Concretly

Problems

When you want to test your Behavior, the first problem that generally comes up is:

Damn, how to I instantiate my Behavior so I can test its API?

In fact, the Behavior API are not that much public methods you declared inside. These never are directly called:

const Alert = Marionette.Behavior.extend( {

  defaults: {
    title: "Alert!",
    message: "Not really urgent"
  },

  events: {
    "click": "emitAlert"
  },

  emitAlert() {
    alert( this.options.message );
  }

} );
it( "should emit an alert", () => {

  // => This won't work
  expect( Behavior.emitAlert() ).toEmitAnAlert();

} );

A Behavior reacts to events — DOM interactions, trigger from the view, etc.

If you want to test a Behavior you then have to trigger these events then observe the Behavior’s impacts on the system to check if it reacted appropriately. Behaviors work with side effects, this is what you need to test.

A Behavior is declared and instantiated within the context of a view:

const ShareView = Marionette.ItemView.extend( {

  template: "#card",

  behaviors: {
    AlertOnShare: {
      behaviorClass: AlertBehavior,
      title: "Shared",
      message: "Your message has been shared!"
    }
  }

} );

To test a Behavior, you then need to instantiate a view in which the Behavior is declared.

OK! Then I’ll mock a view with my Behavior declared so I can test it.

describe( "Alert Behavior", () => {

  let view;

  beforeEach( () => {

    view = Marionette.ItemView.extend( {
      template: _.template( "" ),

      behaviors: {
        Alert: {
          behaviorClass: AlertBehavior,
          title: "Title",
          message: "My message."
        }
      }
    } );

  } );

  // …

} );

This is an option.

However, you won’t have the Behavior actually behave within the context of your application’s views. This is not necessarily wrong since we’re talking about unit tests here. But that requires a lot of ceremony to mock whatever should be:

  • mock a view with default parameters
  • mock a view with configured parameters
  • mock whatever should be tested — template, events, triggers…

Another solution would be to test the instantiated Behavior within each view of our applicaation, directly in these views tests actually.

OK! So I’ll test the Behavior within each of my views… But well… what about duplication?!

Yep, if you go testing how your Behaviors behave for every view’s context, you will duplicate tests. That would be a pitty for something which is supposed to isolate views behaviors so you don’t duplicate code.

What can we do then?

GitHub repo to illustrate the proposed solution

The idea is to refactor Behavior’s tests into a function that will take context as a param.

function addOnClickTests ( context ) {

  let model, view;

  beforeEach( () => {
    model = new context.ModelClass();
    view = new context.ViewClass( { model: model } );
  } );

  it( "should increase the model size by 1 when we click on the view", () => {
    model.set( "size", 1 );

    view.$el.trigger( "click" );

    expect( model.get( "size" ) ).toBe( 2 );
  } );

}

This factory embeds tests of your Behavior and run them within a specific context.

This allows you to instantiate tests with the context of your view, providing correct parameters:

describe( "Like View", () => {

  const View = LikeView.extend( { template: _.template( "" ) } );

  describe( "AddOnClick Behavior", () => {

    addOnClickTests( { ViewClass: View, ModelClass: LikeModel } );

  } );

} );

Sure, but what you’re doing here is testing default parameters of the Behavior: « increase the model size by 1 ». How to test specific parameters? Should we pass them through the context? If so, that’s just duplication again. We’d better completely mock the view at the end.

That’s exactly why Marionette is publicly exposing the array of instantiated Behaviors of a view in its _behaviors attribute since v2.2.0.

The trick is to be able to retrieve your Behavior instance in the view context so you can adapt tests regarding parameters that it actually uses.

I specify an id to my Behaviors for that, so I can retrieve them easily:

const OnClick = Marionette.Behavior.extend( {

  id: "addOnClick",

  defaults: {
    propertyToIncrease: "size",
    increaseStep: 1
  },

  events: {
    "click": "add"
  },

  add() {
    // increase `propertyToIncrease` by `increaseStep`
  }

} );
function addOnClickTests ( context ) {

  let model, view, behavior, options;

  beforeEach( () => {
    model = new context.ModelClass();
    view = new context.ViewClass( { model: model } );

    // Retrieve instantiated behavior and its actual options under this context.
    behavior = _.findWhere( view._behaviors, { id: "addOnClick" } );
    options = behavior.options;

    model.set( options.propertyToIncrease, 1 );
  } );

  it( "should be instantiated", () => {
    expect( behavior ).not.toBeUndefined(  );
  } );

  it( "should increase the model value when we click on the view", () => {
    var expectedValue = model.get( options.propertyToIncrease ) + options.increaseStep;

    view.$el.trigger( "click" );

    expect( model.get( options.propertyToIncrease ) ).toBe( expectedValue );
  } );

}

To sum it up

  • test the API of your Behavior = its impact on the system, reacting to some events
  • describe your Behavior tests in a factory that takes a context as a parameter
  • embed your tests in each of your views tests, using the according context
  • use this.view._behaviors to retrieve your Behavior — you can use an id for that — and its actual paremeters within the context of the view