1 Comment

Marionette.js Behaviors, Part 2: Testing Behaviors

This post is a follow-up to Al’s high-level introduction to Marionette Behaviors, published yesterday. As Al said, Marionette.Behaviors are an abstraction for sharing UI code between different views. They are a recent addition to the Marionette toolbelt (added in version 1.7). Prior to the introduction of behaviors, code sharing between views in Marionette had to be handled at the Javascript language level with inheritance or by delegating to external modules.

The Challenges of Testing Behaviors

Behaviors offer an awesome abstraction, but they exist only as a mix-in in the context of a view, which offers some challenges when it comes to testing:

  • Testing a behavior independently of a view is challenging – Behaviors are tightly coupled to views with listeners and callbacks, which means testing them in isolation requires a lot of setup and doesn’t add much value.
  • Creating fake “test views” doesn’t ensure that all view-behavior interactions are working – One way to test behaviors is stick a behavior inside an empty view, or a view with only the features that the behavior requires. Since behaviors are so tightly linked to the views that contain them, however, this doesn’t feel like a complete test. We want to be confident that our behavior is mixing into our view properly.
  • Avoiding duplicate test code – To test view-behavior integration, you can test the behavior directly alongside the view. If your behavior only ever interacts with one view, this works fine. However, as soon as you want to bring the behavior into multiple views, you start facing test duplication, since testing the behavior will be essentially the same within both views.

Tips & Tricks for Testing Behaviors

1. Share behavior tests across views

Our approach to this problem is to create a unique test for the behavior that can be called from the view tests. This eliminates duplicate code, allows us to test the behavior view interaction. Also, as we expand our behavior with new functionality and tests, these tests will be added to all views containing this behavior.  Note: we use a “context” to pass important view information into the shared behavior test.  The behavior we are testing here is the same one we introduced in part one. Our Jasmine tests look something like this:

var saveSharedBehavior = function(context) {
	var view, model, inputSelector;
 
	beforeEach(function() {
		view = new context.ViewClass(context.viewArgs);
		model = view[context.modelProperty] || view.model;
		inputSelector = context.inputSelector;
	});
 
	it("saves", function() {
		view.render();
		var boundInput = view.$(inputSelector).first();
		var propertyName = boundInput.attr("name");
		boundInput.val("peanuts");
 
		var spy = sinon.spy();
		view.model.on("change:" + propertyName, spy);
 
		view.triggerMethod("save");
 
		expect(spy).toHaveBeenCalledWith(sinon.match.any, "peanuts", sinon.match.any);
	});
};
describe("NewToBuyView", function() {
	var newToBuyView;
 
	beforeEach(function() {
		newToBuyView = new NewToBuyView();
	});
 
	describe("postAndClearModel", function() {
		it("resets its model", function() {
			newToBuyView.model.set("foo", "bar");
 
			newToBuyView.postAndClearModel();
 
			expect(newToBuyView.model.get("foo")).not.toBeDefined();
		});
	});
	describe("Save", function() {
		var context = {
			ViewClass: NewToBuyView,
			viewArgs: {},
			inputSelector: ".app-toBuy-field"
		};
		saveSharedBehavior(context);
	});
});

2. Invoke the feature under test with events

Behaviors are easier to maintain and test when they listen to view events instead of ui events. We did this with the “triggers” hash in our views:

triggers: {
	"change @ui.dataFields": "save"
},

And then hooked the event into our behavior using the on[event] function like this:

onSave: function() {
	var self = this;
	this.$(this.options.fieldSelector).each(function() {
		var $el = $(this);
		self.view.model.set($el.attr("name"), $el.val());
	});
 
	this.view.model.save();
}

The same events can be used to trigger the feature under test:

var spy = sinon.spy();
view.model.on("change:" + propertyName, spy);
 
view.triggerMethod("save");
 
expect(spy).toHaveBeenCalledWith(sinon.match.any, "peanuts", sinon.match.any);

3. Validate View – Behavior Interactions

Behaviors react to and update the state of the view they are composed into. To validate that the Behavior is working properly, make assertions on the view that verify the view’s state changed as expected. Spies are a powerful tool for verifying interactions.

it("saves", function() {
	view.render();
	var boundInput = view.$(inputSelector).first();
	var propertyName = boundInput.attr("name");
	boundInput.val("peanuts");
 
	var spy = sinon.spy();
	view.model.on("change:" + propertyName, spy);
 
	view.triggerMethod("save");
 
	expect(spy).toHaveBeenCalledWith(sinon.match.any, "peanuts", sinon.match.any);
});

This behavior updates the view’s model, so the test watches the model’s change event to verify the behavior’s functionality.


It’s common knowledge that effective tests have many advantages over the lifecycle of a software product.  Creating and maintaining effective tests, however, is easier said than done.  Marionette behaviors offered unique testing challenges, but by writing good behavior specific tests and running them for every view that uses the behavior, you can easily maintain effective tests.
Full code for this post is available on github