Learning Behavior-Driven Development with Ember CLI - part I
This tutorial walks through BDDāing a feature with Ember CLI, Mocha and Chai.
Iām writing it as I learn Emberjs, its way of doing things and its toolset, and try to adapt my usual workflow coming from Ruby. Itās meant as an initial guide for the RGSoC team working on Participate. Hopefully this post will be helpful to others learning Ember as well, and even better, invite people to show how theyāre doing things themselves in the comments.
The feature Iāll build here in baby-steps will be Posting a New Initiative. In Participate, a user can post proposals for civic initiatives, to which other users can then suggest changes, and then vote on.
This first installment will involve nothing but filling out a simple form with a title and a description, and submitting it. The next installment will add validation checking both fields are filled-in.
As the feature gets incremented - an initiative must have an author and belong to an issue, for instance - new installments will come describing the process of adding them in. At some point Iāll talk about integrating with the separate API backend app.
Besides Ember CLI, Mocha and Chai, weāll also use Emblem and EasyForm.
I go through installing all of them on another blog post.
Once you you got them and have generated your app (weāll assume itās called āpost-initiativeā here in the tute), create a new file app/adapters/application.js and add this line to it:
export default DS.FixtureAdapter.extend();
This defines weāll be using fixtures, so we donāt need to worry about the backend for now.
Starting with an acceptance test
Letās start with a test that drives the interface, describing user interactions and expectations first, then implement these by letting test errors be the guide as much as possible.
Create a file named posting-an-initiative-test.js under tests/acceptance.
import startApp from 'post-initiative/tests/helpers/start-app'; import Resolver from 'post-initiative/tests/helpers/resolver'; var App; suite('Posting an initiative', { setup: function(){ App = startApp(); }, teardown: function() { Ember.run(App, 'destroy'); } });
Letās add a test for a link to create a new initiative:
test('Successfully', function(){ visit('/').then(function() { click( $("a:contains('Start a new initiative')") ).then(function() { expect(currentPath()).to.equal('/initiatives/new'); }); }); });
And, from the command line, run ember test:
ā post-initiative git:(simple-new-initiative) ā ember test version: 0.0.37 Built project successfully. Stored in "/Users/work/Projects/post-initiative/tmp/class-tests_dist-Bv3r6aYr.tmp". not ok 1 PhantomJS 1.9 - Posting an initiative Successfully --- message: > Error: Element [object Object] not found.
The key here is the message output. The opaque error means Jquery hasnāt found the link.
And understandably so, since it doesnāt exist yet.
Letās implement it by adding it to application.emblem, under app/templates.
h2 Participate App #menu = link-to 'initiatives.new' | Start a new initiative =outlet
Run ember test again and youāll get a new message:
message: > Assertion Failed: The attempt to link-to route 'initiatives.new' failed. The router did not find 'initiatives.new' in its possible routes: 'loading', 'error', 'index', 'application'
In the router (app/router.js), letās add a route to a new initiative resource:
Router.map(function() { this.resource('initiatives', function() { this.route('new'); }); });
1..1 # tests 1 # pass 1 # fail 0 # ok
This is the basic flow. Letās add another expectation:
Adding the template and form for the new initiative
After clicking the link, the user should be able to fill in a title for the initiative. Add this line to the test
fillIn('div.title input', 'Public health clinic');
So it now looks like this:
test('Successfully', function(){ visit('/').then(function() { click( $("a:contains('Start a new initiative')") ).then(function() { expect(currentURL()).to.equal('/initiatives/new'); fillIn('div.initiative div.title input', 'Public health clinic') }); }); });
message: > Error: Element div.initiative div.title input not found.
To satisfy this, letās create a template, and in it our form:
Create a directory initiatives under app/templates, and then add a file called new.emblem.
Paste the following in it:
form-for model = input title
Run the tests again, and they should pass.
Letās add the remainder of the form-filling steps in our test:
visit('/').then(function() { click( $("a:contains('Start a new initiative')") ).then(function() { expect(currentURL()).to.equal('/initiatives/new'); fillIn('div.title input', 'Public health clinic'); fillIn('div.description textarea', 'Allocate compensation money to create a local public health clinic'); click('form input[type=submit]'); });
Running the tests again will give us:
message: > Error: Element div.description textarea not found.
Add the next input field and the save button to the form:
form-for controller = input title = input description as="text" = submit
The tests should now pass again.
Of course, submitting the form doesnāt do anything yet :)
So what would a user expect to see after submitting the form. Likely sheāll:
Expect to see the url change
Expect to see the new initiativeās content so she can be sure it went through correctly.
She would also expect a confirmation message, but testing that is a little more involved from what I could find so far. So Iām leaving it for a later installment.
Letās add these expectations within a then() function chained to click():
click('form input[type=submit]').then(function() { expect(currentPath()).to.equal('initiatives.show'); expect(find('.title').text()).to.equal('Public health clinic'); expect(find('.description').text()).to.equal('Allocate compensation money to create a local public health clinic'); });
then() returns a āpromiseā, and writing the expectations in a callback passed to it means theyāll get run once click() is done and the resulting rendering is finished. Promises can be a confusing concept at first (Iām still grokking them), but powerful - they let us not worry about all the issues coming from async events.
message: > AssertionError: expected 'initiatives.new' to equal 'initiatives.show'
To satisfy this and get to the next error, weāll need to take a few steps, inherent to how Ember wires routes and data being passed around. The errors I got when going through each of the steps werenāt very informative, and I got things working by trial & error & lot of googling and asking things on #emberjs. So Iām pragmatically breaking tdd here and just wiring enough to get to a useful error.
(For more info on what these steps are about, read the Ember guides on routing and controllers, and this thread on Discuss, which clarified things a lot for me. Emberās architecture is still a moving target.)
First, letās add this route handler for app/routes/initiatives/new.js:
import Ember from 'ember'; var InitiativesNewRoute = Ember.Route.extend({ model: function() { return this.store.createRecord('initiative'); }, actions: { submit: function() { this.transitionTo('initiatives.show'); } } }); export default InitiativesNewRoute;
And this model definition (app/models/initiative.js) to go with it:
var Initiative = DS.Model.extend({ title: DS.attr('string'), description: DS.attr('string') }); export default Initiative;
Next, update the router (app/router.js) to include a path to /initiatives/show:
Router.map(function() { this.resource('initiatives', function() { this.route('new'); this.route('show'); }); });
And add the corresponding template (app/templates/initiatives/show.emblem). It can be empty for now.
Run the tests and weāll get
AssertionError: expected '' to equal 'Public health clinic'
Which means that we got the route transition to work, and are now looking at this test:
expect(find('.title').text()).to.equal('Public health clinic');
We made some progress. So far the user can:
navigate to our appās homepage
click on the link for a new initiative
fill in a form with the title and description for it
get redirected to the created initiative page.
But thereās no initiative created yet. Letās tackle this next:
Handling the form submission
Here Iām also going to wire a few things up to get to the next test error.
Letās update InitiativesNewRoute to handle the submitted params, and then transition to /initiatives/show/:initiative_id
var InitiativesNewRoute = Ember.Route.extend({ model: function(params) { return this.store.createRecord('initiative', params); }, actions: { submit: function() { var _this = this; var initiative = this.get('controller.model'); initiative.save().then(function(model) { _this.transitionTo('initiatives.show', model.get('id')); }); } } });
Update the router to accept the :initiative_id segment:
this.resource('initiatives', function() { this.route('new'); this.route('show', {path: '/:initiative_id'}); });
Create a InitiativesShowRoute (app/routes/initiatives/show.js) to fetch the initiative model:
import Ember from 'ember'; var InitiativesShowRoute = Ember.Route.extend({ model: function(params) { return this.store.find('initiative', params.initiative_id); } }); export default InitiativesShowRoute;
And, finally, a new template for showing the initiative (app/templates/initiatives/show.emblem)
.title h2 model.title .description p model.description
Run the tests and they should pass.
Start up ember-cliās server by running ember serve, and point your browser to http://localhost:4200/, for a good sanity check. If everything went well, this tiny feature should just work :)
Coming soon: Adding validations