Testing Ember with Jasmine 2.0
Testing can be hard and Ember doesn’t exactly have the greatest resources for testing. QUnit is the “official” testing framework for Ember.js apps and it has the most resources dedicated to it which can make using something like Jasmine a bit harder.
The Setup
I’m using Rails with the ember-rails
gem along with the jasmine
gem. If you’re using Rails 4.1 beta you need to specify the github repo, gem 'jasmine', github: 'pivotal/jasmine-gem'
. All you need to run now is rails g jasmine:install
to get Jasmine setup.
Ember Specifics
Ember requires a bit of setup to get working properly with Jasmine. Before anything else, we need to set up our helpers/spec_helper.js
file. The following code sets Ember up for testing, gives us our test helpers, and even puts a small div showcasing our app in the bottom right corner. Some of this is taken from Eric Bryn’s example of testing which can be found on Github here.
document.write('<div id="ember-testing-container"><div id="ember-testing"></div></div>');
document.write('<style>#ember-testing-container { position: absolute; background: white; bottom: 0; right: 0; width: 640px; height: 384px; overflow: auto; z-index: 9999; border: 1px solid #ccc; } #ember-testing { zoom: 50%; }</style>');
App.rootElement = '#ember-testing';
App.setupForTesting();
App.injectTestHelpers();
App.Store = DS.Store.extend({
adapter: DS.FixtureAdapter.extend(),
});
beforeEach(function() {
Ember.run(function() {
App.reset();
});
});
Testing Ember Routes
I don’t really test much in my routes unless there is some complex logic going on or if there is something very important that one of the actions has to trigger. I’ll take an example out of my pet project Headquarters where I test that activate
and deactivate
set a specific property on another controller.
describe('App.LoginRoute', function() {
var controller, route;
beforeEach(function() {
controller = jasmine.createSpyObj('controller', ['set']);
route = App.LoginRoute.create();
spyOn(route, 'controllerFor').and.returnValue(controller);
});
it("calls controllerFor with application", function() {
route.activate();
expect(route.controllerFor).toHaveBeenCalledWith('application');
});
it("sets loginLayout on applicationController on activate", function() {
route.activate();
expect(controller.set).toHaveBeenCalledWith('loginLayout', true);
});
it("unsets loginLayout on applicationController on deactivate", function() {
route.deactivate();
expect(controller.set).toHaveBeenCalledWith('loginLayout', false);
});
});
For this example we need to make sure the route sets a property on a controller. Instead of using a real controller we create a mock object with jasmine.createSpyObj
and give it a set
function. We then create a new instance of the route we’re going to test then spy on the controllerFor
method and tell it to return our mock. In our actual tests we are ensuring that it is getting the proper controller and is setting the right value depending on the method that is called.
A proper working implementation would look like this:
App.LoginRoute = Ember.Route.extend({
activate: function() {
this.controllerFor('application').set('loginLayout', true);
},
deactivate: function() {
this.controllerFor('application').set('loginLayout', false);
}
});
This seems like a lot of work to test something that isn’t very complicated, but it’s actually a very important part of the application that sets the layout to be our login layout.
Testing Controllers
Testing controllers has a pretty similar process. We’re going to test a ‘save’ method on a controller that creates a new model based on properties inside of it. We need to create our controller, mock and stub our store
property, return a mock record, and then we test that all the proper calls were made.
describe('App.ProjectsNewController', function() {
var controller;
beforeEach(function() {
controller = App.ProjectsNewController.create();
});
describe('#save', function() {
var store, record;
beforeEach(function() {
store = {
createRecord: function() {},
}
record = jasmine.createSpyObj('record', ['one', 'save']);
spyOn(store, 'createRecord').and.returnValue(record);
controller.set('store', store);
});
it('saves the project', function() {
controller.send('save');
expect(record.save).toHaveBeenCalled();
expect(record.one).toHaveBeenCalled();
});
it('creates the record with the right properties', function() {
properties = {
name: 'test',
description: 'test desc',
}
controller.setProperties(properties);
controller.send('save');
expect(store.createRecord).toHaveBeenCalledWith('project', properties);
});
});
});
First we create our controller for all of our specs and then we dive into our #save
method. We create our store and record mock and make store.createRecord
return our mock record. We then set the store property on our controller. The rest is straightforward enough to not warrant much discussion. We just call controller.save
and check that the right objects were called with the right properties.
Ember Data Fixtures
To prevent Ember from actually using AJAX to retrieve our records we used the following code in our spec_helper.js
above:
App.Store = DS.Store.extend({
adapter: DS.FixtureAdapter.extend(),
});
Now we just need to define our fixtures on each of our models. I put my fixtures in a fixture folder with a different file for each model to keep things sane and organized. A fixture for one of your models might look like this:
App.User.FIXTURES = [
{ id: 1, name: 'Foo', gravatar: 'http://www.gravatar.com/avatar/' },
{ id: 'me', name: 'Bar', gravatar: 'http://www.gravatar.com/avatar/' }
]
Thoughts and Conclusion
Testing Ember is pretty difficult so far since there are no “official” examples or guides and the ones found online are few and far with varying versions and persistence layers. So far this is what I have found out about testing Ember apps and can’t help but feel like it’s a bit stub happy. It’s either stubbing dependencies or making heavy use of App.__container__.lookup
which is a private method. I’ve tried both so far and I feel a bit more comfortable with stubs except when I’m required to use the container.