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
- Globally overriding the store via service injection in test setup
- Globally stubbing the API called by findAll
- Not testing it at all
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();
});