Testing async DOM functions in Ember.js

18 December 2023

"Just use the platform" - they say, referring to not reinventing the wheel for a browser feature in our favorite JavaScript framework if the browser already provides it.

And they are right! If a browser spec is implemented by the browsers we care about, it makes perfect sense to use it. We have to consider a few other things, though, one of which is whether the feature can be properly tested.

In this post, we'll see a specific example of using a browser feature, and making it testable in Ember.js.

Showing an avatar – or the initials

An avatar component is a recurring element in web applications. We want to show a user's face and name together, in a specific layout (most often, next to each other). Such a component should take a name and a URL to the image:

1<Avatar @name="Marquis de Carabas" @url="/photos/carabas.png" />

Simple enough, but what happens if the image can't be loaded? We could rely on the browser showing the "broken image" symbol but I think in this case we can do better than just "using the platform": we can show the user's initials instead:

Image without fallback Image with initials fallback

Right, but how do we know that the image cannot be loaded? Turns out there is an onError callback on img tags which is only called in this exact case. Based on that, the implmentation of our Avatar component can look something like this:

 1// avatar.js
 2export default class AvatarComponent extends Component {
 3  @tracked isShowingInitials = false;
 4
 5  get initials() {
 6    const [first, ...rest] = this.args.name.split(/\s+/);
 7    const last = rest.pop();
 8    return [first, last].map((name) => name[0]).join('');
 9  }
10
11  @action
12  showInitials() {
13    this.isShowingInitials = true;
14  }
15}

The onError callback can be hooked up to the showInitials action. We only want to show the initials if the image is not available:

 1{{!-- avatar.hbs --}} 
 2<div class="avatar">
 3  {{#if this.isShowingInitials}}
 4    <div class="initials">{{this.initials}}</div>
 5  {{else}}
 6    <img
 7      src={{@url}}
 8      onError={{this.showInitials}}
 9      alt={{concat @name "'s avatar"}}
10    />
11  {{/if}}
12  <div class="name">{{@name}}</div>
13</div>

Everything looks good but we want to add a test to make sure this stellar feature doesn't break in the future.

With the hopefulness of a junior developer, we write a simple test for the failure case. We already saw it works in the real app, so it should be as simple as centering an element vertically in a container:

 1// avatar-test.js
 2module('Integration | Component | Avatar', function (hooks) {
 3  setupRenderingTest(hooks);
 4
 5  test('It falls back to initials when the image cannot be loaded', async function (assert) {
 6    await render(
 7      hbs`<Avatar @name="Marquis de Carabas" @url="/images/non-existent.webp" />`,
 8    );
 9
10    assert.dom('.initials').hasText('MC');
11  });
12});

Reality beckons: all is not so simple in the world of software, and our test fails.

Failing test

However, if we insert an await this.pauseTest() after the component is rendered, we see that the initials are correctly shown. Furthermore, if we release the breaks by typing resumeTest() on the console, the test passes!

What is this sorcery?

What do test helpers wait for?

One of the most educational moments in my Ember.js learning journey was to peek into what Ember's test helpers wait for before moving on to the next test line to execute. The foundation of those helpers is called wait and most test helpers return a call to that function. It tells us what the framework waits for before moving on. Go take a look yourself, it's a very short file and very well written.

You'll see that test helpers will wait for the router to finish transitioning, pending ajax requests, scheduled timers and the runloop, and something called waiters.

None of these cover the case of the onError callback so the assert.dom assertion runs before the initials were rendered and we get a failing test.

The solution (or at least one solution) for this is to integrate the onError DOM element callback into Ember's testing mechanism by defining a custom test waiter.

Adding a custom test waiter

If we define a waiter for our component, Ember's testing mechanism will wait for it to complete before moving on which will make the test pass.

We start by creating the waiter in the module's scope:

 1// avatar.js
 2import Component from '@glimmer/component';
 3import { action } from '@ember/object';
 4import { tracked } from '@glimmer/tracking';
 5import { buildWaiter } from '@ember/test-waiters';
 6
 7const waiter = buildWaiter('avatar');
 8
 9export default class AvatarComponent extends Component {
10  // (...)
11}

The waiter has two functions: beginAsync should be called to get a token and start the waiting:

 1// avatar.js
 2export default class AvatarComponent extends Component {
 3  @tracked isShowingInitials = false;
 4
 5  constructor() {
 6    super(...arguments);
 7    this.waiterToken = waiter.beginAsync();
 8  }
 9  // (...)
10}

, and endAsync should be called when the waiter completes, passing it the token we received earlier:

 1// avatar.js
 2export default class AvatarComponent extends Component {
 3  // (...)
 4  @action
 5  showInitials() {
 6    this.isShowingInitials = true;
 7    waiter.endAsync(this.waiterToken);
 8  }
 9}

Once we do that, the waiter will now make Ember's testing framework wait it finishes, and only then move on to check the assertion. Our test is now reliably green.

Covering the happy path

We might think it's time to call it a day and have some fun but then we'd miss the important non-corner case. What if the image is available at the provided URL and can be loaded? Well, let's write a test for it:

 1// avatar-test.js
 2module('Integration | Component | Avatar', function (hooks) {
 3  setupRenderingTest(hooks);
 4
 5  // (...)
 6  test('It renders the image when the image can be loaded', async function (assert) {
 7    await render(
 8      hbs`<Avatar @name="Chelsea Hagon" @url="/images/chelsea.avif" />`,
 9    );
10
11    assert.dom('img').exists('The image is displayed');
12    assert.dom('.initials').doesNotExist();
13  });
14});

Assuming that the image is available at /images/chelsea.avif (it will be if we copy it to the /public/images folder in our app), this test should pass. Instead, it hangs – it never runs to completion.

We forgot (or maybe you didn't ;) ) that endAsync is only called from showInitials when the image cannot be loaded. When it can be, the waiter just waits forever and the test never moves to the next line, the assertion.

The img element also has an onLoad callback which is triggered when the image has been loaded, so we can use that for calling endAsync on the happy path:

 1<div class="avatar">
 2  {{#if this.isShowingInitials}}
 3    <div class="initials">{{this.initials}}</div>
 4  {{else}}
 5    <img
 6      src={{@url}}
 7      onLoad={{this.completeWaiter}}
 8      onError={{this.showInitials}}
 9      alt={{concat @name "'s avatar"}}
10    />
11  {{/if}}
12  <div class="name">{{@name}}</div>
13</div>

The completeWaiter action is only there to make sure we complete the async operation:

1// avatar.js
2export default class AvatarComponent extends Component {
3  // (...)
4  @action
5  completeWaiter() {
6    waiter.endAsync(this.waiterToken);
7  }
8}

Our second test now also passes.

There is a small change which I think is worth doing – moving the image used for testing in a folder that clearly shows its purpose. One called testing is clear enough :) Otherwise, someone might delete the image when they see it's not used anywhere in the app. If we do that, we have to update the image path in the test:

 1// avatar-test.js
 2module('Integration | Component | Avatar', function (hooks) {
 3  setupRenderingTest(hooks);
 4
 5  // (...)
 6  test('It renders the image when the image can be loaded', async function (assert) {
 7    await render(
 8      hbs`<Avatar @name="Chelsea Hagon" @url="/images/testing/chelsea.avif" />`,
 9    );
10
11    assert.dom('img').exists('The image is displayed');
12    assert.dom('.initials').doesNotExist();
13  });
14});

Adding code to make something testable

At this point you could justifiedly point out that we're only adding code, all the code related to the custom waiter, to be able to test our avatar component. Isn't it wrong to make the code more complex for the purpose of testability? I think the value that having our component be tested and guarded against regression makes up for the fact that the code is slightly more complex. That said, it'd be great not having to add any code to be able to do that. Can we?

Does settled settle it?

As I was going through the code for a second time for this post, I pulled out the joker that on occasions helps tests pass that otherwise would have failed for the same reason as explained in this post: when Ember's testing framework doesn't wait for an async event to complete before executing the next testing instruction. Lo and behold: it did work this time, too!

Inserting a call to settled() (imported from @ember/test-helpers) makes the test pass reliably:

 1module('Integration | Component | avatar', function (hooks) {
 2  setupRenderingTest(hooks);
 3  test('It falls back to initials when the image cannot be loaded', async function (assert) {
 4    await render(
 5      hbs`<Avatar @name="Marquis de Carabas" @url="/images/non-existent.webp" />`,
 6    );
 7    await settled();
 8    assert.dom('.initials').hasText('MC');
 9  });
10});

But... it shouldn't. settled doesn't add anything that should make Ember wait for the callback to complete, so the things I explained in the "What do test helpers wait for?" section still hold. Actually, we even get a squiggly (a lint error) in our editor because our code now violates the ember/no-settled-after-test-helper rule:

1> Do not call `settled()` right after a test helper that already calls it internally.

And indeed, the render call does call settled under the hood.

So I'm not sure why it works and it seems like it shouldn't and maybe I just got lucky all the times I ran the test?

I'll try to find out but in the meantime, I suggest adding a test waiter. It's a very useful tool in one's Ember.js arsenal and you can use it in several contexts to let Ember's testing know about an added async operation.

Share on Twitter