Mocked Tasks for Ember Concurrency Rendering Tests

Async can be difficult. Async testing is improving every day, but still comes with its hardships.

I remember the days when deciding to adopt ember-concurrency came with the caveat that you'd be unable to write as many tests. It simply... didn't always work as you'd hope. The tradeoff was your app simply behaved better with fewer console errors, doubled up requests, and inconsistent manually tracked isLoading flags.

Where are we today?

We now have better tooling all around and I'm very happy that ember-concurrency is a core tool of many apps. Writing tests for the most part simply just... work as you'd hope they would!

But, what about testing the asynchronous bits itself? What if you want to test that a loading spinner appears, or that it goes away a table of data loads in its place?

Enter Mocked Tasks

Ember Map has a wonderful series on rendering tests which inspired this article. Specifically, part two sparked the base of this coding helper.

// tests/helpers/concurrency.js
import EmberObject from '@ember/object';
import { task } from 'ember-concurrency';

export default class TaskHelper {
  constructor() {
    let promise = new Promise(resolve => {
      this.finishTask = resolve;
    });

    this.task = EmberObject.extend({
      task: task(function* () {
        return yield promise;
      }),
    }).create().task;
  }
}

Here we have a test helper we can import to have fine tuned control over how and when our tests resolve async tasks.

Imagine we have a component <MyComponent> and that component has a task that fetches data from an api loadingTask: task(function* () { ....

Within our tests we can overwrite that loadingTask with a mocked task from our concurrency helper.

// tests/integration/my-component-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import TaskHelper from 'my-ember-app/tests/helpers/concurrency';

module('Integration | Component | my-component', function(hooks) {
  setupRenderingTest(hooks);

  test('Renders loader on render', async function(assert) {
    let mockedTask = new TaskHelper();
    this.set('mockedTask', mockedTask.task);
    this.set('testAction', () => {});

    await render(hbs`<MyComponent @loadingTask={{mockedTask}} />`);

    assert.dom('#loadingspinner').exists();
  });
});

The above test replaces our loadingTask with a task that looks to a promise that we never resolve. For the purposes of our test, that's all we need to do!

We can go a step further and resolve our promise, and therefore our task, to assert that our loading spinner goes away and data loads.

import { render, waitFor } from '@ember/test-helpers';
...

test('Removes loading spinner, renders data', async function(assert) {
  let mockedTask = new TaskHelper();
  this.set('mockedTask', mockedTask.task);
  let data = { foo: 'bar' };
  this.set('data', null); // Start with no data

  await render(hbs`<MyComponent
    @loadingTask={{mockedTask}}
    @data={{data}}
  />`);

  mockedTask.finishTask(data);
  this.set('data', data); // Set the data after we declare loading done above
  await waitFor('#loadingspinner'), { count: 0 });

  assert.dom('#loadingspinner').doesNotExist();
  assert.dom('#datacontainer').hasText('bar');
});

Where do we go from here?

This helper is just the beginning of an idea that could be easily extended. There are likely improvements where the data returned from finishTask can be returned more intelligently into an assignment or even to set up the data itself.

We could even handle reject cases for failure testing with some minor adjustments.

// tests/helpers/concurrency.js
...
let promise = new Promise((resolve, reject) => {
  this.finishTask = resolve;
  this.rejectTask = reject;
});

// tests/integration/my-component-test.js
test('Handles failures', async function(assert) {
  ...
  mockedTask.rejectTask(new Error('Uhoh!'))

  assert.dom('#error').hasText('Ouchies!');
});

Throw some async test helpers into your app while writing your next async component and let's see what we can come up with!