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
<nav>
<Button @onClick={{action handleClick}} @testLabel='troubleshoot-audio'>Click me</Button>
</nav>
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:
owner.register
would fail silently. This actually happened in a related scenario using the ember-metrics
service.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:
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`,
},
])
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!
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:
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:
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
};
//...
}
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!