ember: testing services

2020-10-14

 | 

~5 min read

 | 

806 words

Today, I built a small feature in Ember. In my ongoing effort to improve with testing, I was about ready to ask for a review when I realized I hadn’t written my tests yet (TDD isn’t quite natural for me … yet). This is when things got interesting. Let’s set the stage and then dig into how to test services with Ember.

The component I added was a Button with an onClick action that invoked services previously been registered with the application.1

app/components/nav/template.hbs
<nav>
  <Button @onClick={{action handleClick}} @testLabel='troubleshoot-audio'>Click me</Button>
</nav>
app/components/nav/component.js
import Component from "@ember/component"
import { tagName } from "@ember-decorators/component"
import { inject as service } from "@ember/service"
import { action } from "@ember/object"
import AudioService from "app/services/audio"

@tagName("")
export default class Sidebar extends Component {
  @service audio: AudioService

  @action
  handleClick() {
    this.audio.play()
  }
}

Clicking the button definitely has an effect, but there’s no ”physical” manifestation of it, i.e. there are no changes to the DOM. So, how do I test it?

The first approach I took was by registering the service:2

import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import Service from '@ember/service';
import hbs from 'htmlbars-inline-precompile';
import { click, find, findAll } from '@ember/test-helpers';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
module('sidebar', function(hooks) {
  setupRenderingTest(hooks);

  hooks.beforeEach(function(assert) {
    // @ts-ignore
    this.owner.register(
      'service:audio',
      class AudioStub extends Service {
        play() {
          assert.ok(true);
        }
      }
      );
  });

  test('clicking "Test Audio" results in an onClick event', async function(assert) {
    assert.expect(2); // on click, a service is called

    await this.render(
      hbs`{{sidebar}}`
    );
    const testAudio = find('[data-test-button="troubleshoot-audio"]') as HTMLElement;
    assert.dom(testAudio).exists('test audio exists');
    await click(testAudio);
  });
});

This worked! But, it’s fairly brittle in a few dimensions:

  1. If something else were to register a service before the test, the owner.register would fail silently. This actually happened in a related scenario using the ember-metrics service.
  2. The assert.expect can break if any new assertions are added to the test.

Our team has shifted away from this owner.register pattern in favor of Sinon for exactly these reasons. It turns out stubbing the functions (or spying, or faking) is easy to implement (once you know the API) and can add a lot more flexibility in what you test.

Let’s refactor the test to use sinon.stub() now:

tests/component/nav.js
  import { module, test } from 'qunit';
  import { setupRenderingTest } from 'ember-qunit';
  import Service from '@ember/service';
  import hbs from 'htmlbars-inline-precompile';
  import { click, find, findAll } from '@ember/test-helpers';
+  import { TestContext } from 'ember-test-helpers';
  import a11yAudit from 'ember-a11y-testing/test-support/audit';
+  import sinon from 'sinon';
  module('sidebar', function(hooks) {
    setupRenderingTest(hooks);

-      hooks.beforeEach(function(assert) {
+      hooks.beforeEach(function(this: TestContext) {
-      // @ts-ignore
-      this.owner.register(
-        'service:audio',
-        class AudioStub extends Service {
-          play() {
-            assert.ok(true);
-          }
-        }
-        );
+        this.set('play', sinon.stub(this.owner.lookup('service:audio'), 'play'));
+        this.set('trackEvent', sinon.stub(this.owner.lookup('service:metrics'), 'trackEvent'));
    });

    test('clicking "Test Audio" results in an onClick event', async function(assert) {
-       assert.expect(2); // on click, a service is called

      await this.render(
        hbs`{{sidebar}}`
      );
      const testAudio = find('[data-test-button="troubleshoot-audio"]') as HTMLElement;
      assert.dom(testAudio).exists('test audio exists');
      await click(testAudio);
+       assert.equal(this.get('play').callCount, 1);
    });
  });

Because play takes no arguments, and doesn’t return any values, we really can only check that it was called (and cover the actual function in a separate unit test for the service itself).

Sinon offers rich access to the function with a relatively simple API. For example, imagine if play took multiple arguments and we wanted to ensure that it was called with the appropriate context. We could do that with the following assertion:

assert.deepEqual(this.get("play").firstCall.args, [
  "AudioFile",
  {
    loop: `true`,
  },
])

Conclusion

As with most programming challenges, there are multiple ways to achieve the desired result. Today I found two for testing services within an Ember app. Ultimately I like the resiliency and simplicity offered by Sinon and will keep it as an arrow in my testing quiver going forward!

Footnotes

  • 1 I had an “aha” moment today for Ember when I wrote this code. I was trying initially to understand why I defined the handleClick on the Sidebar component and not the Button. It’s when I translated the code to React that things started clicking:

    Sidebar.jsx
    import { Button } from "components"
    
    function Sidebar() {
      const handleClick = () => {
        // do stuff
      }
      return (
        <nav>
          <Button onClick={handleClick}>Click me</Button>
        </nav>
      )
    }

    Written like this, it makes sense to me that the handleClick is defined at the Sidebar level. The Button is simply receiving the function. It doesn’t care what it is and doesn’t need to know. This is composition in action!

  • 2 The Button has an attribute that defines data-test-button, making it easily accessible within tests:

    components/button
    import Component from '@ember/component';
    
      @tagName('button')
      export default class Button extends Component {
      testLabel?: string;
    
      @attribute('data-test-button')
      @computed('testLabel')
      get testId(): string {
          return this.testLabel ?? this.elementId
      };
    
      //...
    }

Related Posts
  • sinon-stub-classes


  • Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!