Test driving ember.js crud operations

Test-Driving Ember.js CRUD Operations

In the final tutorial in this Ember.js series, we'll finalize our code and see how to test-drive standard CRUD operations in Ember.js.

Brought to you by

Semaphore

Introduction

This tutorial is the fifth part of our series on test-driving an Ember.js application. In this series, we have been building a complete application using Ember.js and Ruby on Rails. The premise of our application is a digital bookcase, where we can keep track of all the books we own.

If you've finished the fourth tutorial in the series, you're left with a working book list, but there's no way to manage your books. In this tutorial, we'll correct that. We'll talk about building the necessary CRUD for our application, which will be test-driven. CRUD stands for "Create", "Read", "Update", and "Delete." Those are the operations we need to fully manage our books.

Upgrading Ember CLI and Friends

We did this once before, in the third tutorial, but time has gone by and the Ember team has been releasing new versions — they release on a six week release cycle. If this is too fast for you or your organization, you'll be happy to know that they have announced Ember LTS (long-term support). We won't go into all the details here, but basically it means that the LTS version of Ember will be supported for roughly six months. Version 2.4 is slated to be the first LTS release, and as of this tutorial, we'll upgrade to the latest version — v2.4.3. Since we went over the step-by-step instructions in the third tutorial, you can visit the release notes which go over the steps. That said, we'll still look at the things you need to update, so you can easily move through the ember init step.

  1. Overwrite README.md? - Yes,
  2. Overwrite app/app.js? - Yes,
  3. Overwrite app/index.html? - No,
  4. Overwrite app/router.js - No,
  5. Overwrite app/templates/application.hbs? - No,
  6. Overwrite bower.json? - No, your file should look as follows:

    {
      "name": "bookcase",
      "dependencies": {
        "ember": "~2.4.3",
        "ember-cli-shims": "0.1.1",
        "ember-cli-test-loader": "0.2.2",
        "ember-qunit-notifications": "0.1.0",
        "jquery-mockjax": "2.0.1"
      }
    }
    
  7. Overwrite package.json? - No, your devDependencies in the file should look as follows:

    {
      ...
      "devDependencies": {
        "broccoli-asset-rev": "^2.4.2",
        "ember-ajax": "0.7.1",
        "ember-cli": "2.4.3",
        "ember-cli-app-version": "^1.0.0",
        "ember-cli-babel": "^5.1.6",
        "ember-cli-dependency-checker": "^1.2.0",
        "ember-cli-htmlbars": "^1.0.3",
        "ember-cli-htmlbars-inline-precompile": "^0.3.1",
        "ember-cli-inject-live-reload": "^1.4.0",
        "ember-cli-qunit": "^1.4.0",
        "ember-cli-release": "0.2.8",
        "ember-cli-sri": "^2.1.0",
        "ember-cli-uglify": "^1.2.0",
        "ember-data": "^2.4.2",
        "ember-data-factory-guy": "2.1.3",
        "ember-export-application-global": "^1.0.5",
        "ember-load-initializers": "^0.5.1",
        "ember-resolver": "^2.0.3",
        "ember-validations": "2.0.0-alpha.4",
        "loader.js": "^4.0.1"
      }
    }
    
  8. Overwrite tests/helpers/module-for-acceptance.js? - No, your afterEach method should look like the following:

    afterEach() {
      TestHelper.teardown();
    
      if (options.afterEach) {
        options.afterEach.apply(this, arguments);
      }
      destroyApp(this.application);
    }
    
  9. Overwrite tests/helpers/resolver.js? - Yes.

Now, Ember-CLI will do its thing and update your project. When it's done, run the following command:

rm testem.json

Back in v2.4.0, the change from testem.json to testem.js was implemented.

Run your tests to make sure everything checks out. If it does, go ahead and check in your changes to source control.

Upgrading ember-data-factory-guy

Since we've just updated all our dependencies, especially Ember-Data, let's go ahead and upgrade our test helper — ember-data-factory-guy. Open up package.json and remove ember-data-factory-guy, then in your terminal, run the command npm prune. Finally, run ember install ember-data-factory-guy to get the latest.

Deprecation Workflow

When you ran the tests, you probably got a lot of deprecations. These can just fill up our output window, hiding important errors, so let's handle these. There is a deprecation workflow that we can follow. First, we'll install an Ember-CLI add on:

ember install ember-cli-deprecation-workflow

Now, run your tests again in server mode. You can use the shortcut ember t -s to do this. Once they're completed, run the command deprecationWorkflow.flushDeprecations() in the Chrome console. This will dump out the deprecations. Copy these from the output, minus the quotes, to a new file config/deprecation-workflow.js:

window.deprecationWorkflow = window.deprecationWorkflow || {};
window.deprecationWorkflow.config = {
  workflow: [
    { handler: "silence", matchMessage: "`handleFindAll` - has been deprecated.
    Use `mockFindAll` method instead`" },
    { handler: "silence", matchMessage: "Using the injected `container` is
    deprecated. Please use the `getOwner` helper instead to access the
    owner of this object." }
  ]
};

We'll get rid of the first deprecation in a minute, it's an ember-data-factory-guy change that we'll fix in our tests. You can go ahead and delete that line.

If you change the remaining silence to throw, you'll see that these deprecations are coming from ember-validations. There is a fix in place for these, but there hasn't been any new releases. For now, we'll just silence the deprecations to keep our console clean.

We'll now deal with the ember-data-factory-guy cleanup. Do a global find/replace on the term handleFindAll and change it to mockFindAll. That's it, no more deprecation notices.

CRUD Operations

Now that we completed some maintenance on our project, let's get into the working with our CRUD operations. Since it's the easiest and the fact that we're partially done with it, we'll start with the Read operations. Back in the last tutorial, we presented a list of books. That is one version of a Read operation, but we also want to have a book "details" screen as well. Something that shows the full details of the book.

Read: Book Details

Let's start with a test. This is a good place for an acceptance test since we'll be viewing the page through the user's eyes. If you recall, we can generate an acceptance test through Ember-CLI:

ember g acceptance-test book-details

Now go to /tests/acceptance/book-details-test.js. Our first step will be to change the initial test. We don't have, nor want a /book-details URL. Instead, we'll have /books/:id, where :id will be the ID of the book. Our test file will look like this:

test('visiting /books/1', function(assert) {
  visit('/books/1');

  andThen(function() {
    assert.equal(currentURL(), '/books/1');
  });
});

You can use the shortcut ember t -s to run the test server, and assuming you started your test server you'll get a failure: Error: Assertion Failed: The URL '/books/1' did not match any routes in your application. We'll fix that. Open up app/router.js and let's add the route to the Router.map:

Router.map(function() {
  this.route('books');
  this.route('book', { path: '/books/:id'});
});

Now our test passes, but it just checks the current URL. What we really want is to test the elements on the page for the correct information about a book, so let's write another test. We're going to need two new factories. Add a new file for author.js and publisher.js into tests/factories, and we'll see how the author and the publisher factories should look like.

The author:

import FactoryGuy from 'ember-data-factory-guy';

FactoryGuy.define('author', {
  default: {
    name: 'Damien White'
  }
});

The publisher:

import FactoryGuy from 'ember-data-factory-guy';

FactoryGuy.define('publisher', {
  default: {
    name: 'Acme, Inc.'
  }
});

We'll make use of these factories in our book details test:

test('should show all of the book\'s information', function(assert){
  let publisher = make('publisher', {name: 'Acme, Inc.'});
  let author = make('author', {name: 'Damien White'});
  mockFind('book', { id: 1, title: 'Developing Foo', isbn: '0123456789',
                     publisher: publisher, authors: [author] });
  visit('/books/1');
  andThen(function() {
    assert.equal(find('.title').text(), 'Developing Foo');
    assert.equal(find('.isbn').text(), '0123456789');
    assert.equal(find('.publisher').text(), 'Acme, Inc.');
    assert.equal(find('.author').text(), 'Damien White');
  });
});

Now, we have four failures. Let's fix this by using Ember-CLI to generate a route for us:

ember g route book

Our first step in fixing the error is to load our book model in the route. In our previous tutorials, we used the route's model hook. Your app/routes/book.js should look like the following:

import Ember from 'ember';

export default Ember.Route.extend({
  model: function(params) {
    return this.store.findRecord('book', params.id);
  }
});

With that change, we now have two failing tests. The first is our original test where we are checking URL. To fix this, we just have to mock the findRecord call. We'll add one line to the beginning of the first test:

mockFind('book', { id: 1 });

We'll now focus on the app/templates/book.hbs template. We'll add the data points we are looking for in this file:

<div class="container">
  <img src={{model.cover}} alt={{model.title}} />
  <h1 class="title">{{model.title}}</h1>
  <h3 class="publisher">{{model.publisher.name}}</h3>
  <h4 class="isbn">{{model.isbn}}</h4>
  <ul>
    {{#each model.authors as |author|}}
    <li class="author">{{author.name}}</li>
    {{/each}}
  </ul>
</div>

Now, our test is passing, but if you run the application against the API we have, you'll find it isn't working quite right. We need to tweak our API. Since the related data is small, we'll simply include the authors and the publisher along with the book if the user is requesting the show route. Remember, we're in the Rails project now, not the Ember project.

It requires two changes, first to the app/controllers/books_controller.rb:

  # GET /books
  def index
    @books = Book.includes(:publisher, :authors).all
    render json: @books, include: %w(publisher authors)
  end

  # GET /books/1
  def show
    render json: @book, include: %w(publisher authors)
  end

This tells ActiveRecord to include the publisher and the author's details, so that we don't have an N+1 query.
We modified both the index and show actions because we'll change the book_serializer, which will affect both actions. Now, we just need to actually alter the app/serializers/book_serializer.rb to look like the following:

class BookSerializer < ActiveModel::Serializer
  attributes :id, :title, :isbn, :cover
  belongs_to :publisher
  has_many :authors
end

That's all that was needed on the Rails side of things.

We can now tell Ember-Data not to asynchronously load the relationships — app/models/book.js:

  publisher: DS.belongsTo('publisher', { async: false }),
  authors: DS.hasMany('author', { async: false })

Now, with the loading logic out of the way, we'll link the books route with the book detail route that we've just created. This way we'll be able to click on a book in the book list and get all the book's details. This simply added a link-to around our book cover that we are displaying in app/templates/components/book-list.hbs:

{{#each filteredBooks as |book|}}
  <div class="book" data-title="{{book.title}}">
    {{#link-to "book" book}}<img src="{{book.cover}}" height="160" />{{/link-to}}
  </div>
{{/each}}

Notice that we are passing the book model to the link-to helper. By doing this, we won't have to query the API again because the model is already fully filled, thanks to our API's index action. If a user hits the book detail page directly, then it will call the show action of our API.

Create: Add Book

Now that our read operations are out of the way, let's finally give our users the ability to add a book. Again, we'll start with an acceptance test:

ember g acceptance-test book-new

Our first change/test is the route. We want RESTful routes on our front-end, so we'll start by changing the default test that was generated for us.

test('visiting /book/new', function(assert) {
  visit('/books/new');

  andThen(function() {
    assert.equal(currentURL(), '/books/new');
  });
});

The solution is to add the new route to app/router.js:

Router.map(function() {
  this.route('books');
  this.route('new-book', { path: '/books/new' });
  this.route('book', { path: '/books/:id'});
});

Routes are considered "greedy", matching from top to the bottom, so if we put the new-book route below the book route, the book route would be matched first causing us errors.

In our acceptance test, we'll write a test for fully adding a record. This will test all the form elements on the screen, and we'll even mock saving the record using ember-data-factory-guy. Let's look at our new test:

import { mockCreate } from 'ember-data-factory-guy';
...
test('can be created', function(assert){
  mockCreate('book');

  visit('/books/new');

  andThen(function() {
    fillIn('.title', 'Ember is Awesome');
    fillIn('.isbn', '0123456789');
    fillIn('.cover', 'http://placehold.it/417x500');
  });

  andThen(function(){
    click('button[type=submit]');
  });

  andThen(function(){
    assert.equal($.mockjax.mockedAjaxCalls()[0].url, '/books');
    assert.equal(currentURL(), '/books/1');
  });
});

This is very straight-forward, except for the first assertion. Here we're inspecting Mockjax's collection of mocked Ajax calls to ensure that there was a call out, a POST, to /books. The ember-data-factory-guy, uses jquery-mockjax to intercept the calls out to the server. Pay attention to the first line of the test, here we'll use the function mockCreate to have FactoryGuy intercept the call out to the server.

We'll now fix the broken test. Run the following command to generate a new-book route:

ember g route new-book

That created a route, a template, and a testfor us. We'll first focus on the template, which should look as follows:

<div class="container">
  <form>
    <div class="form-group">
      <label>Title</label>
      {{input value=model.title class="form-control title"}}
    </div>
    <div class="form-group">
      <label>ISBN</label>
      {{input value=model.isbn class="form-control isbn"}}
    </div>
    <div class="form-group">
      <label>Cover</label>
      {{input value=model.cover class="form-control cover"}}
    </div>
    <button type="submit" class="btn btn-primary" {{action 'save' model}}>Submit</button>
  </form>
</div>

Finally, we'll need some code in our route file — app/routes/new-book.js:

import Ember from 'ember';

export default Ember.Route.extend({
  model: function() {
    return this.store.createRecord('book');
  },
  actions: {
    save: function(model) {
      model.save()
      .then((book) => {
        this.transitionTo('book', book);
      })
      .catch(function(error) {
        console.log(error);
      });
    }
  }
});

That's all the code required for our test to pass. Though, if you try out the actual site, you'll find that you can't add a book, and you get an error. In our Rails backend, a book belongs_to a publisher. In order to get the form to work we need a drop-down list of publishers. There are many ways to tackle a drop-down in Ember, but we'll use Ember Power Select. This is an Ember-CLI add-on, thus we need to install it:

ember install ember-power-select

After installation, ember-power-select may have included an app.scss file in your styles directory. Since we aren't using Sass in this project, you can safely delete this file.

Now, let's go ahead and add the power-select to our project. In the app/templates/new-book.hbs file, we'll add the following:

    ...
    <div class="form-group">
      <label>Publisher</label>
      {{#power-select
          searchEnabled=false
          selected=model.book.publisher
          options=model.publishers
          onchange=(action (mut model.book.publisher))
          as |publisher|
      }}
        {{publisher.name}}
      {{/power-select}}
    </div>
    ...

You'll notice that we're now going after model.book.publisher instead of just model.publisher. We needed this change because we're going to alter the route to pull in two models when it loads, instead of just one that we have now. We need the book model and the publishers that we can choose from. This change occurs in /app/routes/new-book.js:

export default Ember.Route.extend({
  model: function() {
    return new Ember.RSVP.hash({
      book: this.store.createRecord('book'),
      publishers: this.store.findAll('publisher')
    });
  }
  ...
})

We're using an Ember.RSVP.hash, which will wait to resolve until both promises are fulfilled. This way we'll have publishers we can choose from when we're on the form. Now make sure you alter the rest of the fields in the new-book form to be model.book.<attribute>, otherwise things won't bind correctly. For example:

<div class="form-group">
  <label>Title</label>
  {{input value=model.book.title class="form-control title"}}
</div>

With those changes in place, you now should be able to add a new book.

Update: Updating a Book

Updating a book is very similar to creating a new book. Because of this, we should create a component for our book form. The only difference is going to be in the route. Note that you could also use a partial for this purpose, but components are a better option as they give us more power. Let's create a new component, we'll call it "book-form."

ember g component book-form

We'll take the contents of app/templates/new-book.hbs and and copy it into book-form.hbs. Your file should look like this:

<div class="container">
  <form>
    <div class="form-group">
      <label>Title</label>
      {{input value=book.title class="form-control title"}}
    </div>
    <div class="form-group">
      <label>ISBN</label>
      {{input value=book.isbn class="form-control isbn"}}
    </div>
    <div class="form-group">
      <label>Cover</label>
      {{input value=book.cover class="form-control cover"}}
    </div>
    <div class="form-group">
      <label>Publisher</label>
      {{#power-select
          searchEnabled=false
          selected=book.publisher
          options=publishers
          onchange=(action (mut book.publisher))
          as |publisher|
      }}
        {{publisher.name}}
      {{/power-select}}
    </div>
    <button type="submit" class="btn btn-primary" {{action 'save' book}}>Submit</button>
  </form>
</div>

Notice that the model is just book now, instead of model.book, and publishers will just be a collection on the component. With this in place, we can change /app/templates/new-book.hbs to be:

{{book-form book=model.book publishers=model.publishers}}

The only thing we need to alter now is the save action. We're using the route to handle the save and want to continue to do so. Now that we have a component, we'll need to use a closure action to do the save. Currently, with Ember, we aren't able to bubble a closure action to a route. However, with the handy ember-route-action helper from Dockyard we're able to do what we want. We'll install this like every other Ember add on:

ember install ember-route-action-helper

Now, let's utilize the helper in our app/templates/new-book.hbs code:

{{book-form book=model.book publishers=model.publishers save=(route-action 'save')}}

When routable components land, you should be able to find/replace route-action with action.

With that in place, let's actually update a book. We'll begin with a test. However, if you have been running your tests, you are probably having issues. This is because we introduced the publisher dropdown and we filled it from /publishers in the API. We need to mock this call to /publishers using ember-data-factory-guy. In addition, we'll use ember-power-select's acceptance test helpers. We'll run through these changes rather quickly, so we can move on to updating our books.

Within tests/acceptance/book-new-test.js, we'll first mock our findAll call using ember-data-factory-guy, and change the import at the top of the file to look as follows:

import { mockCreate, mockFindAll } from 'ember-data-factory-guy';

Now, we can make use of mockFindAll in both tests that we have, it's just one line at the beginning of the test:

mockFindAll('publisher', 2);

This one JavaScript line will create a collection of 2 publishers. Again, it's needed for both tests. Note, you could also do this in a beforeEach function if you want to.

Next up, let's add ember-power-select's helper methods, which involves two things. First is changing tests/helpers/start-app.js to add in the helpers per the documentation. You'll notice in the documentation that we need to add two lines, both towards the top of the file outside of the startApp function:

  import registerPowerSelectHelpers from '../../tests/helpers/ember-power-select';

  registerPowerSelectHelpers();

Finally, we're going to use ember-power-select's selectChoose method. Since JSHint doesn't know anything about this method, we'll modify the tests/.jshintrc file, and add the following entry under the predef array:

"selectChoose"

With these changes, we can now use selectChoose to pick a publisher during the second test. This isn't 100% necessary in this test, since we're mocking the create and don't have any validations in place, but it's a good idea to include it and make the test complete.

While we are tweaking things, we'll make a slight change to the publisher to achieve unique names from the factory — tests/factories/publisher.js:

FactoryGuy.define('publisher', {
  sequences: {
    publisherName: function(num) {
      return 'Publisher ' + num;
    }
  },
  default: {
    name: FactoryGuy.generate('publisherName'),
  }
});

Now, we'll use our publisher select. It's just one line in tests/acceptance/book-new-test.js's second test:

selectChoose('.publisher', 'Publisher 2');

One last thing, in the second test, we need to change the $.mockjax.mockedAjaxCalls()[0].url to be $.mockjax.mockedAjaxCalls()[1].url because the first mockedAjaxCalls() URL is /publishers.

All our tests are passing again. It's important to always have the Ember test server running in the background, so you can catch these issues quickly.

Back to updating a book. Add a new acceptance test for updating a book. In the command line, we can do this using Ember-CLI:

ember g acceptance-test book-update

Here is our full acceptance test for updating a book:

import { test } from 'qunit';
import moduleForAcceptance from 'bookcase/tests/helpers/module-for-acceptance';
import { make, mockFind, mockUpdate, mockFindAll } from
'ember-data-factory-guy';

moduleForAcceptance('Acceptance | book update');

test('visiting /book/1/update', function(assert) {
  mockFindAll('publisher', 2);
  mockFind('book', { id: 1 });

  visit('/books/1/update');

  andThen(function() {
    assert.equal(currentURL(), '/books/1/update');
  });
});

test('can be updated', function(assert){
  mockFindAll('publisher', 2);
  let book = make('book', { id: 1 });
  mockFind('book', book);
  mockUpdate(book);

  visit('/books/1/update');

  andThen(function() {
    fillIn('.title', 'Ember is Awesome');
    fillIn('.isbn', '0123456789');
    selectChoose('.publisher', 'Publisher 2');
    fillIn('.cover', 'http://placehold.it/417x500');
  });

  andThen(function(){
    click('button[type=submit]');
  });

  andThen(function(){
    assert.equal($.mockjax.mockedAjaxCalls()[1].url, '/books/1');
    assert.equal(currentURL(), '/books/1');
  });
});

In order to get these tests to pass, we need to add a new route:

ember g route update-book

In app/route.js, we want the update-book route to look like the following:

Router.map(function() {
  ...
  this.route('update-book', { path: '/books/:id/update' });
});

Now, in app/routes/update-book.js, we'll have code very similar to that for our new-book route:

import Ember from 'ember';

export default Ember.Route.extend({
  model: function(params) {
    return new Ember.RSVP.hash({
      book: this.store.findRecord('book', params.id),
      publishers: this.store.findAll('publisher')
    });
  },
  actions: {
    save: function(model) {
      model.save()
      .then((book) => {
        this.transitionTo('book', book);
      })
      .catch(function(error) {
        console.log(error);
      });
    }
  }
});

Finally, in app/templates/update-book.hbs, we'll utilize our book-form component like we did earlier for the new-book template:

{{book-form book=model.book publishers=model.publishers
save=(route-action 'save')}}

With this component we've made our update code.

Delete: Deleting a Book

We've reached the last CRUD operation — delete/destroy. We'll again start with an acceptance test:

ember g acceptance-test book-delete

The contents of the file contain one test:

import { test } from 'qunit';
import moduleForAcceptance from 'bookcase/tests/helpers/module-for-acceptance';
import { make, mockFind, mockDelete, mockFindAll } from
'ember-data-factory-guy';

moduleForAcceptance('Acceptance | book delete');

test('can be deleted', function(assert){
  let book = make('book', { id: 1 });
  mockFind('book', book);
  mockDelete('book', 1);

  mockFindAll('book');

  visit('/books/1');

  andThen(function() {
    click('.btn-danger');
  });

  andThen(function(){
    assert.equal($.mockjax.mockedAjaxCalls()[1].url, '/books/1');
    assert.equal($.mockjax.mockedAjaxCalls()[1].type, 'DELETE');
    assert.equal(currentURL(), '/books');
  });
});

To make our test pass, we'll first add a button to the "show" page, app/templates/book.hbs:

<div class="container">
  ...
  <button class="btn btn-danger" {{action "delete" model}}>Delete</button>
</div>

Then, we'll add an action to route (app/routes/book.js) to actually do the delete:

export default Ember.Route.extend({
  ...
  actions: {
    delete(book) {
      book.destroyRecord()
        .then(() => {
          this.transitionTo('books');
        })
        .catch(function(error) {
          console.log(error);
        });
    }
  }
});

That's all there is to it. Of course, you'll probably want to confirm with the user that they want to delete the record, as right now it just deletes as soon as you click the button.

Conclusion

CRUD is a very important part of most applications and, with this tutorial under your belt, you should be able to test these operations in all your applications. In this tutorial, we saw how we could leverage add ons like ember-data-factory-guy to make our tests simple and easy to understand.

Same as with the other tutorials in the series, this code can also be found on GitHub. The Rails respository has been tagged with Part5End, as has the Ember repo. We hope you'll find this tutorial useful. Feel free to share it and post your comments and questions below.

874b44da3a2397098282c151c5e7f437
Damien White

I am a software architect with over 16 years of experience. I simply love coding! I have a driving passion for computers and software development, and a thirst for knowledge that just cannot be quenched.

on this tutorial so far.
User deleted author {{comment.createdAt}}

Edited on {{comment.updatedAt}}

Cancel

Sign In You must be logged in to comment.