Nursing Code

Testing in Ember.js when using ember-concurrency tasks


Ember concurrency is an amazing library that helps us to peform asynchronous work in a way that adds some very useful behaviour.

I won't go into detail about the benefits of ember-concurrency because their docs already do a great job, but two significant improvements over plain Promises are the ability to cancel a piece of work and derived state.

Derived state is the ability to describe the state of a task, such as task.isRunning. This gives us clean ways of displaying loading states without having to manually set flags before and after kicking of an asynchronous request.

All of this is wonderful, but I've encountered a number of situations where testing has become a challenge.

How to test with ember-concurrency?

One place where this can get difficult is if we don't have good seams to provide data in a test scenario.

Placing the task directly on a component makes things difficult, but sometimes it's the correct thing to do.

A contrived example

export default class BookList extends Component {
  @service store;

  @task
  *getBooks() {
    yield this.store.findAll('book');
  }
}

with a template

  <table>
    <thead></thead>
    <tbody>
      {{#each this.getBooks.lastSuccessful.value as | book | }}
        {{! do stuff with books}}
      {{else}}
        {{#if this.getBooks.isRunning}}
          <div class="loading-spinner"></div>
        {{else if this.getBooks.lastSuccessful}}
          <div class="empty">No items found</div>
        {{/if}}
      {{/each}}
    </tbody>
  <table>

So we have a component that will show a loading spinner and then render a list of books, or a message that none were found.

The problem here, from a test perspective is we don't have a good seam to provide the data to the component.

Common ways of dealing with that issue include

I would argue that the better way is to handle a missing abstraction.

  <table>
    <thead></thead>
    <Books::TableBody @bookTask={{this.getBooks}}>
  <table>

The new component

<tbody>
  {{#each @booksTask.lastSuccessful.value as | book | }}
    {{! do stuff with books}}
  {{else}}
    {{#if @booksTask.isRunning}}
      <div class="loading-spinner"></div>
    {{else if @booksTask.lastSuccessful}}
      <div class="empty">No items found</div>
    {{/if}}
  {{/each}}
</tbody>

Now we can easily test our different states simply by passing a Task or an object that matches the shape of a task.

This makes it very simple to add a test to ensure that our loading spinner gets displayed.

Example

  test('it shows a loading spinner when loading', async function (assert) {
    this.getBooks = { isRunning: true };

    await render(hbs`<Books::TableBody @bookTask={{this.getBooks}} />`);

    assert.dom('div.loading').exists();
  });

What if we want to test that the loading spinner disappears and the data is shown once the promise resolves?

Or how about testing that a list is rendered in the simple success case?

We could update bookTask to have a value and then check the list is rendered, but at this point, I'd argue we're faking too much of the API of ember-concurrency.

So how about we feed an actual task we control to the component?

A fake task helper

Caveat There may be a better way of doing this, but I've found this to be a helpful way to get my hands on a task that I can customise and pass around in tests.

It's a little messy, but because it uses the same approach as we'd use in a normal class, we ensure we're not over stubbing.

import { task } from 'ember-concurrency';

export function fakeTask(callback = function (_any) {}) {
  class FakeTask {
    @task
    async fakeTask() {
      return callback(...arguments);
    }
  }
  const taskClass = new FakeTask();

  return taskClass.fakeTask;
}

With that fake task helper in place we can now test that our implementation matches the behaviour of an ember-concurrency task.

Simple rendering

  test('it renders a list', async function (assert) {
    this.getBooks = fakeTask(() => [
      { name: 'A Tale of Two Cities' },
      { name: 'Great Expectations' },
      { name: 'Oliver Twist' },
    ]);

    this.getBooks.perform();

    await render(hbs`<Books::TableBody @bookTask={{this.getBooks}} />`);

    assert.dom('tr').exists({ count: 3 });
  });

Combine loading spinner and success path

We can also take a reference to a Promise resolve function, check for a loading spinner, then subsequently resolve the promise and check the list is rendered.

test('Can model spinner and resolution with fakeTask', async function (assert) {
  let resolver;

  this.getBooks = fakeTask(() => {
    // Return a Promise with a handle to the resolve function
    // so we can manually call it later.
    return new Promise((resolve) => {
      resolver = resolve;
    });
  });

  this.getBooks.perform();

  await render(hbs`<Books::TableBody @bookTask={{this.getBooks}} />`);

  assert.dom('div.loading').exists();

  resolver([
    { name: 'A Tale of Two Cities' },
    { name: 'Great Expectations' },
  ]);

  await waitFor('tr');

  assert.dom('div.loading').doesNotExist();
  assert.dom('tr').exists({ count: 2 });
});

Unhappy path

Finally we can also check that an error message gets shown if the promise is rejected.

test('it shows an error message when the task fails', async function (assert) {
  this.getBooks = fakeTask(() => {
    return Promise.reject();
  });

  // Catching error here because otherwise it causes a global failure in the test suite
  this.getBooks.perform().catch();

  await render(hbs`<Books::TableBody @bookTask={{this.getBooks}} />`);

  assert.dom('div.error').exists();
});