Site editor

My Ember.js TDD workflow


by Ryan Toronto

July 25, 2016

My Ember.js TDD workflow

One of the hardest things about developing new features for single page applications is dealing with transient state. Transient state is state that lives solely on the client, and is lost when the page is refreshed.

Imagine you're working on a search feature in the admin section of your web app. This feature lets you

  1. Enter an email
  2. See a list of matching users
  3. Select multiple users, and then
  4. Upgrade their accounts

As you're developing this feature, you end up repeating these steps each time the page reloads. Since Ember apps live reload each time we make a code change, you end up repeating these steps quite a bit. If you think about it, a lot of development time is spent manually interacting with the browser.

In the last year I've worked on a handful of Ember.js applications ranging in size from a few users to several thousands. I've found that using a good TDD workflow eliminates these painful and repetitive interactions that can really eat into development time. In this post I'll outline how I use TDD with Ember.js, and how it's changed my development experience for the better.

Ember CLI, QUnit, and Testem

Let's walk through building the admin user search feature described above.

The first thing I'll do is create an acceptance test using an Ember CLI generator: ember g acceptance-test user-searching. Then, I'll start writing a test with fake functions.

// tests/acceptance/user-searching-test.js

test("I should be able to search users and upgrade their accounts", function(assert) {
  visitAdminDashboard();
  
  searchForUser("@acme.com");
  
  selectUser("bob");
  selectUser("alice");
  
  clickUpgradeAccounts();
  
  andThen(() =>{
    assert.ok(didUpgradeAccount("bob"));
    assert.ok(didUpgradeAccount("alice"));
  });
});

This test will fail because none of these functions exist, but that's ok. We're just trying to describe the user behavior in the clearest way possible.

Ember CLI Mirage

The next thing I'll start to think about is how the back end needs to respond in order for this feature to work. The tests won't use a real back end  -- they'll use stubbed data  -- but this is the best time to think about how data will flow from the back end to the front end.

For this task there's no better choice than Ember CLI Mirage, which allows you to quickly stub JSON:API endpoints. I'll start by creating a user endpoint and stubbing out a user response.

// mirage/config.js

export default function() {
  this.get('/users');
}

I'll then add the following fake users to my test:

// tests/acceptance/user-searching-test.js

test("I should be able to search users and upgrade their accounts", function(assert) {
  server.create('user', { email: 'bob@acme.com' });
  server.create('user', { email: 'alice@acme.com' });
  
  visitAdminDashboard();

  // ... rest of test ...

});

Now when my test makes a request for '/users?filter[email]=acme.com', its response will be a JSON:API document with the two users that were created in the test.

When using Mirage, I may write a custom route handler if the backend has complicated logic or transforms. But I always start with the default handlers, and expand them only when needed.

Ember CLI Page Object

In order to get the test passing I'll begin turning those fake test functions I wrote earlier into real code. I've found Ember CLI Page Object to be the best library for this, as it lets me encapsulate all the ways users can interact with my UI inside of page objects.

Page objects are an abstraction that allow you to separate HTML and CSS from user interactions. With page objects, none of my tests will reference CSS classes, so they become more resilient to UI changes. It's important that my page objects read as if I were telling a real user how to interact with the application.

// pages/user-search.js

import PageObject, {
  clickable,
  fillable,
  visitable
} from 'my-app/tests/page-object';

export default PageObject.create({

  visitAdminDashboard: visitable('/admin'),

  searchFor: fillable('.User-search'),
  
  users: {
    itemScope: '.User-search-results .User',

    clickUpgradeAccount: clickable('.Upgrade-account')
  }

});

The visitable, fillable, and clickable helpers make it easy for the test to interact with the given elements. I'll now update my test to use the page object:

// tests/acceptance/user-searching-test.js

import page from '../pages/user-search';

test("I should be able to search users and upgrade their accounts", function(assert) {
  server.create('user', { email: 'bob@acme.com' });
  server.create('user', { email: 'alice@acme.com' });
  
  page
    .visitAdminDashboard()
    .searchFor("@acme.com");
  
  andThen(() => {
    page.users(0).clickUpgradeAccount();
    page.users(1).clickUpgradeAccount();
  });
  
  andThen(() => {
    assert.ok(page.contains("Bob's account was upgraded."));
    assert.ok(page.contains("Alice's account was upgraded"));
  });
});

At this point I'm ready to start implementing the feature on the admin dashboard. Looking at our test and page object, there are three requirements that must be met:

1 .The search box must have the class .User-search

  1. Whenever text is entered into the search box, it must send off a request to /users?filter[email]=text and then display the results in the .User-search-results container.
  2. Each user must have a button with class .Upgrade-account. When clicked, it will upgrade the users account and display a success message.

This is straightforward to do with Ember Data and the {{input}} helper. I'll omit these code snippets here, but you can watch me build an app in detail using page objects in my EmberMap series on Acceptance Testing.

pauseTest()

Sometimes, the raw test runner output isn't enough to fully develop a feature. After all, I'm developing a UI  --  I need to see how it looks, and I want to interact with it at least a few times on my own.

To do this, I'll use the pauseTest helper, which will freeze my test and let me interact with the application in its current state. It's an indispensable tool while working on design, styling, or interactions.

// tests/acceptance/user-searching-test.js

import page from '../pages/user-search';

test("I should be able to search users and upgrade their accounts", function(assert) {
  server.create('user', { email: 'bob@acme.com' });
  server.create('user', { email: 'alice@acme.com' });
  
  page
    .visitAdminDashboard()
    .searchFor("@acme.com");
  
  // freeze the tests here.
  return pauseTest();
  
  andThen(() => {
    page.users(0).clickUpgradeAccount();
    page.users(1).clickUpgradeAccount();
  });
  
  andThen(() => {
    assert.ok(page.contains("Bob's account was upgraded."));
    assert.ok(page.contains("Alice's account was upgraded"));
  });
});

I'll also use development mode for QUnit, which gives me a full-screen container. Here's what it looks like when pauseTest and development mode are used together:

More tests

My single "search for a user by email" test is passing and I've got a page object that describes how to interact with the search feature. Next, I'll duplicate this test, but instead of searching by email I'll try searching by name:

// tests/acceptance/user-searching-test.js

test("I should be able to search for mary and upgrade her account", function(assert) {
  server.create('user', { email: 'mary@acme.com' })
  
  page
    .visitAdminDashboard()
    .searchFor("mary");
  
  andThen(() => {
    page.users(0).clickUpgradeAccount();
  });
  
  andThen(() => {
    assert.ok(page.contains("Mary's account was upgraded"));
  });
});

This test should pass right away, since all the heavy lifting is done by Mirage and our page object. Pretty incredible! It sounds obvious, but when tests are this easy to write, I find that I write more of them, which leaves me feeling more confident in my application's behavior.

Automating transient state

When I talk about TDD, I'm quick to mention the benefits that come from knowing your application is working correctly. However, as I spend more time working on client-side applications, I'm finding that being able to automatically set up my application in various states, whether working on functionality or design, is a huge timesaver.

So, next time you find yourself repeating a series of user interactions over and over again just to test or design a feature, try using this technique. I guarantee you'll be amazed by how much time you save.

Questions?

us, or ask in #media on Discord