Testing a Backbone Marionette App with Jasmine and Karma

Isolating Tests When Using Event Aggregator

Raul Reynoso
5 min readMay 30, 2014

Leer en Español

Event Aggregation in Marionette

Avoiding interacting tests is vital. It makes your test code easier to maintain and expand, and prevents long debugging sessions. Subtle errors in test code wastes valuable time and ultimately yields no insight into how well production code works.

Marrionette.js is library for Backbone that helps developers create well organized large scale applications. Marionette provides an Event Aggregator to facilitate loose coupling.

App = new Backbone.Marionette.Application(); // Creates App.vent
App.vent.on('my-event',callback); // Handle Event
App.vent.trigger('my-event'); // Fire event

Every object in the application can use App.vent to trigger or listen for events. Objects do not need to know about one another to interact, they simply need to know what event to listen for and what arguments will be passed to the callback.

This is great for building a decoupled application, but presents a challenge for testing.

Unit Testing With Event Aggregator

The application Event Aggregator complicates two key unit testing goals 1) preventing unit test interactions; and 2) isolating the System Under Test (SUT).

Multiple unit tests may register callbacks for the same event. All unit tests use App.vent, so later tests will run callbacks from tests that ran earlier. This creates a unit test interaction that can have nasty results, potentially making tests fail when the production code runs correctly.

If you use a test runner like Karma, a common approach is to load all production code and all test specs and configure Karma to run tests any time a file changes. The benefit is that you get instant feedback when a change causes tests to fail. But, you have to make sure you are properly isolating the SUT.

In an app that uses the Event Aggregator, external code can interact with the part of the system being tested by listening to events that the SUT may trigger.

The following example tests that the SUT triggers a particular event and passes the correct event object to the callback

describe(“NonIsolatedSUTSpec”, function() {
it(“Fires an event and passes an order object ”,function(){
var returnedOrder= null;
var testedObject = new Orders();
App.vent.on('orders:new:order',function(orderObj){
returnedOrder=orderObj;
});
testedObject.order(333,20);
expect(returnedOrder).toEqual({ amount:20,
itemId:333,
price:19.99 });
});
});

Suppose the application includes a Discount module that listens for the “orders:new:order” event and discounts the price. Discount changes the price attribute in orderObj from what is initially set by Orders. This will either cause the test to fail or force you to account for the discount in the test. If the latter, your unit test is not properly isolated. It would be fragile and could fail when changes to Discount are made.

The easiest solution is to simply unbind all the events before each test is run. Then if you want an action to be taken when an even is fired, add the event handling within the test. Like so:

describe(“IsolatedSUTSpec”, function() {
beforeEach(function(){
App.vent.unbind();
});
it(“Fires an event and passes an order object ”,function(){
var returnedOrder= null;
var testedObject = new Orders();
App.vent.on('orders:new:order',function(orderObj){
returnedOrder=orderObj;
});
testedObject.order(333,20);
expect(returnedOrder).toEqual({ amount:20,
itemId:333,
price:19.99 });
});
});

The unbind will prevent the Discount object from firing the event and changing the final result. So you can test the Order class in isolation. This works great for unit tests, but what about integration tests?

Integration Testing With Event Aggregator

Now suppose you really do want to test that Order and Discount work together correctly. You would want Discount to listen for the Order event and take appropriate action. So you can’t unbind all events before each test.

This presents two challenges:

  1. All test App.vent handlers listening for the event will be run
  2. All application App.vent handlers listening for the event will be run

The first challenge has a fairly straightforward solution that leverages event contexts. All the handlers you add in your test code should use the ‘test’ context. Before each test you unbind all handlers in the ‘test’ context, like so:

describe(“IntegrationTestSpec”, function() {
beforeEach(function(){
App.vent.unbind(‘test’);
});
it(“Tracks the order in which orders were made”,function(){
var returnedOrder= null;
var testedObject = new Orders();
App.vent.on(‘orders:new:order’,function(orderObj){
returnedOrder = orderObj;
},’test’);
testedObject.order(333,1);
expect(returnedOrder).toEqual({ amount:20,
itemId:333,
price:19.99 });
});
});

The second challenge is thornier. Imagine a situation in which you have Order, Discount, and Bonus objects. Discount changes the price when a new order is made and Bonus changes the quantity. Both actions are taken when a new order event is fired.

Suppose you want to test the integration of Order and Discount. You might test that the total cost of an order (price X quantity) is accurate after a discount is applied. However if Bonus has changed the quantity, your test will fail, even if the production code works correctly. There are a couple ways to address this problem.

Create Mocks of Objects Not Being Tested

The most straight forward way to prevent interacting is to Mock objects not under test. Jasmine spies can do just that. In our situation we would want to spy on the Bonus object and prevent it from executing its code.

describe(“IntegrationTestSpec”, function() {
beforeEach(function(){
App.vent.unbind('test');
});
it(“Discounts the total cost of the order”,function(){
var returnedOrder= null;
spyOn(App.Bonus,"giveBonus");
App.vent.on(‘orders:new:order’,function(orderObj){
returnedOrder = orderObj;
},’test’);
App.OrderService.order(333,1);
expect(returnedOrder.totalCost).toEqual(100);
});
});

In the code above we access the application level objects in the App namespace. This is how we prevent the Bonus item from being added to the order.

This approach is simple and works well. The one drawback is that you may have to add additional spies if other objects begin listening for the new order event and making changes to the order.

Reimplement Handler Logic in Test

Most applications will include a controller class that composes and coordinates Order, Discount and Bonus. The controller listens for events and invoke the appropriate methods on the collaborating objects.

This approach also makes testing easier. One approach to preventing unpredictable interactions is to again unbind all events before each test, with: App.vent.unbind();. Then reimplement the controller’s handler within the test. This works best when the controller’s handler is a simple pass through function and the controller does not save any state information.

In this case you can implement a handler that will simply call the appropriate Discount method. For example:

describe(“IntegrationTestSpec”, function() {
beforeEach(function(){
App.vent.unbind();
});
it(“Discounts the Total Cost of the order”,function(){
var returnedOrder= null;
var ordersService = new Orders();
var discountService = new Discount();
App.vent.on(‘orders:new:order’,function(orderObj){
discountService.giveDiscount(orderObj);
returnedOrder = orderObj;
},’test’);
ordersService.order(333,1);
expect(returnedOrder.totalCost).toEqual(100);
});
});

The advantage is that adding any new objects to that listen to the new order event will not affect this test. There are drawbacks as well.

First, this technique can’t easily be used with more complex controller handlers that track the current state. Imagine a handler that tracked the number of orders and only gives discounts for the first three orders. Pretty soon you could be re-implementing a fairly complex controller method that could easily get out of sync with the production version.

Second, you might need to change the test when the controller handler changes, even though the controller class is not being tested.

Additional Thoughts

Backbone and Marrionette.js are great and increasingly popular tools for developing JavaScript applications. I hope this post helps other developers create effective tests for their apps, just as thinking through these issues has helped me.

There are no doubt many other ways to test Marionette.js apps and much more to be said about the techniques discussed above. Please let me know your thoughts. I will incorporate them into this post.

Have feedback? Take to Twitter and Let me know.

--

--

Raul Reynoso

Software Engineer, Entrepreneur, Blockchain Enthusiast