Ember 3.0 has once again elevated the software development testing experience. Have you ever heard that song, “Anything you can do I can do better. Anything you can do I can do best.”? If there was a theme song for Ember testing, this would be it. With the hard work from many community members, testing your frontend software applications has become a joy. I’ll avoid going over the new APIs, but rather provide examples to help us understand how Ember’s testing harness works. In the meantime, I also hope to provide some tricks of the trade to add a little spunk to your testing suite. Be sure to read the full API interface if you have yet to familiarize yourself. Moreover, feel free to view the QUnit testing RFC here and the QUnit migration guide here or Mocha migration guide here. The new testing APIs shown are available in ember-cli-qunit >= 4.2 and ember-cli-mocha >= 0.15.0.
async + @ember/test-helpers
async
is baked into how you write your tests with async/await. If you have ever had to test async code in the past, you know that if your tests assume the execution path is sync, you are in a world for hurt. However, using async/await
, testing async code is a breeze. You may need ember-maybe-import-regenerator
to start using async/await
. Here is a simple example.
import { click, find, visit } from '@ember/test-helpers';
test('my async execution path', async function(assert) {
await visit('/async-to-the-rescue');
await click('[data-test-fetch-me]');
assert.ok(find('[data-test-fetched]');
});
What if you need to pause in the middle of an async test to view the state of the DOM? One option is to import pauseTest
and call return pauseTest()
. Another option is to simply use this.pauseTest()
. Both can be used interchangeably.
import { click, find, visit } from '@ember/test-helpers';
test('my async execution path', async function(assert) {
await visit('/async-to-the-rescue');
return this.pauseTest();
await click('[data-test-fetch-me]');
assert.ok(find('[data-test-fetched]');
});
As you can see, Ember enables writing simpler, determinant tests. This simplicity also makes testing ergonomics of more complicated situations much easier.
Testing state during async operations is easily possible. Let’s say you have a button that sets off an async operation that, for 2 seconds, shows “Yay, this worked”. Using await click('...')
, you will notice this helper will check for various pending operations in your application such as hasPendingRequests
, hasPendingWaiters
, hasPendingTimers
, or hasPendingRunLoop
. Try importing import { getSettledState } from '@ember/test-helpers'
and call getSettledState()
to check hash containing these boolean values. Otherwise, you can also require('@ember/test-helpers').getSettledState()
in the console if you are paused in a debugger. If any of these return true, then the click helper will wait until your application is in a settled state before resolving the Promise and proceeding to the next line. What to do? Just remove await
to circumvent @ember/test-helpers
internal Promise based settled
state.
import { click, find, settled, visit } from '@ember/test-helpers';
test('my async execution path', async function(assert) {
await visit('/sync-to-the-rescue');
click('[data-test-fetch-me]');
assert.ok(find('[data-test-fetching]', 'message shows up while waiting for request to finish!');
await settled();
assert.notOk(find('[data-test-fetching]', 'message has disappeared');
});
This is much better than using something like let done = assert.async()
and then calling done()
at the appropriate time in the past.
That was fun! But wait, there is more!
Common Test Setup
Many times throughout our testing suite, we have to perform some common setup across tests. Let say that you need to set the locale on the ember-intl
service. Simply create a file in your helpers
folder and export a plain function.
export function setupIntl(hooks, locale = 'en') {
hooks.beforeEach(function() {
this.owner.lookup('service:intl').setLocale(locale);
});
}
And then in the test, add it after the setupRenderingTest
declaration or similar testing API from ember-qunit
.
setupRenderingTest(hooks);
setupIntl(hooks);
Improved APIs may come in the future to perform common setup logistics; however, at the moment, this is the easiest way.
Testing if element is outside the scope of #ember-testing
Using a few strategies we already talked about, we can now see if an element is outside the scope of the #ember-testing
container. This is important because @ember/test-helpers
considers the root element to be #ember-testing
when interacting with DOM Nodes. If, for example, you were using jQuery before and now find('.my-modal')
is failing to return anything, you can use return this.pauseTest()
and the Chrome Elements panel to view the state of the DOM after render. If the element is outside the Ember testing container, one option is to use document.querySelector
or other related DOM APIs to assert the element is present.
// Integration Test
import { find, render } from '@ember/test-helpers';
test('modal renders on render', async function(assert) {
await render(hbs`{{my-component}}`);
assert.notOk(find('.my-modal'), 'NOT found!');
assert.ok(document.querySelector('.my-modal'), 'found!');
});
Instances in Integration Tests
When integration testing components, I sometimes like to obtain state from a component. Let’s say you are interacting with a third party lib that you setup on didInsertElement
.
// Component
import Component from '@ember/component';
import { set } from '@ember/object';
import MyThirdPartLib from 'npm:my-third-party-lib';
export default Component.extend({
didInsertElement() {
this._super(...arguments);
MyThirdPartyLib.initialize({ element: this.element });
set(this, 'thirdParty', MyThirdPartyLib);
}
});
// Integration Test
import { find, render } from '@ember/test-helpers';
test('my async execution path', async function(assert) {
await render(hbs`{{my-component}}`);
let component = this.owner.lookup('component:my-component');
assert.ok(component.thirdParty, 'thirdParty was set!'); // fail
});
However, if you check the elementId
of component
, you will notice it is a different component than what was rendered! This comes down to the difference between singletons (controllers, routes, services) and non-singletons (components). Digging into Ember’s internals, you will see that this.owner.lookup
will create a new instance of a component as it is not a singleton. As a result, how do I get the instance that was rendered?
import { find, render } from '@ember/test-helpers';
import MyComponent from 'my-app/components/my-component';
test('my async execution path', async function(assert) {
let instance;
this.owner.register('component:my-component', MyComponent.extend({
didInsertElement() {
this._super(...arguments);
instance = this;
}
}));
await render(hbs`{{my-component}}`);
assert.ok(instance.thirdParty, 'thirdParty was set!'); // passed
});
Moreover, what if you need to trigger an Ember action on the component and assert some complicated logic? To ensure the action is wired up correctly, it is best to do it through the actual interface that the action was setup through. So that may be clicking a button or hovering over an element. However, there are cases where it may be impossible to test the action properly. Here is what you can do.
import { find, render } from '@ember/test-helpers';
import MyComponent from 'my-app/components/my-component';
test('my async execution path', async function(assert) {
await render(hbs`{{my-component}}`);
let instance;
this.owner.register('component:my-component', MyComponent.extend({
didInsertElement() {
this._super(...arguments);
instance = this;
}
}));
instance.send('myAction', 'hi', 'there');
assert.ok(instance.get('paramSet'), 'hi there', 'param was set!');
});
Cool! Let’s dig into some more fun.
mouseEnter and mouseLeave Events
How about testing mouse events that are setup on your component? Let’s say you setup a mouseLeave
event on your component.
// Component
import Component from '@ember/component';
export default Component.extend({
mouseLeave() {
set(this, 'flyAwayMsg', 'Fly away birdie!');
}
});
// Test
import { find, render } from '@ember/test-helpers';
test('my component responds to mouseLeave!', async function(assert) {
await render(hbs`{{my-component}}`);
await triggerEvent('.my-component', 'mouseleave');
assert.ok(find('.my-component--message').textContent, 'Fly away birdie!'); // fail
});
@ember/test-helpers
builds and dispatches native events instead of jQuery ones. Since Ember’s event dispatching system uses jQuery to listen to events, jQuery will not recognize the mouseleave
event, but rather watch for the mouseout
event. So to make this work, the proper triggerEvent is mouseout
.
// Component
import Component from '@ember/component';
export default Component.extend({
mouseLeave() {
set(this, 'hoveredMsg', 'Hovered!');
}
});
// Test
import { find, render, triggerEvent } from '@ember/test-helpers';
test('my component responds to mouseLeave!', async function(assert) {
await render(hbs`{{my-component}}`);
await triggerEvent('.my-component', 'mouseout');
assert.ok(find('.my-component--message').textContent, 'Fly away birdie!'); // pass
});
The same applies for mouseenter
and mouseover
. However, be aware that a solution to fix this discrepancy may come in the future and we will amend accordingly.
A few of Ember’s new test framework APIs
getContext
· To get the application instance and ask for registered instances or factories, you can either use this.owner
or getContext
. In the context of a test, I would recommend this.owner
because it’s available on the test scope. But what if you are inside a test helper? Well, test helpers are simply just functions. So any state or scope passed to them is simply a function of it’s arguments. Instead, you can use getContext
.
import Service from '@ember/service';
import { getContext } from '@ember/test-helpers';
let stubService = (name, hash = {}) => {
let stubbedService = Service.extend(hash);
let { owner } = getContext();
owner.register(`service:${name}`, stubbedService)
};
export default stubService;
Eazy peezy.
getApplication
and setApplication
· Ember’s lifecycle during setup and teardown of your tests looks a little different now. The basic logic is that, although your tests run in isolation, none of your tests simply test a single unit. As a result setupTest
, setupRenderingTest
, and setupApplicationTest
conveniently boots your app with all of its dependencies. The exception, of course, is if you are testing a plain function. This is a huge improvement. Instead of registering a service manually in an integration or unit test, you simply can do this.owner.lookup('service:my-service')
. Given some of the hoops that we had to jump through, it may have caused users to mock or stub services when a better option would be to test the system’s actual capabilities.
You may also notice that initializers and instance initializers are run before you can hook into the beforeEach
of your test. As a result, any services that may be registered with Ember’s container in an instance-initializer are already setup and impossible to override by the time you want to register, say a mock service. In order to register a service that is already known by Ember, you need to perform a bit of gymnastics. However, note that this isn’t a fullproof method and may be subject to improvements and additional API methods in the future. See here for some of the hooks we will be calling (note: getApplication
is only available in >= 0.7.20 of ember-test-helpers
).
Let’s walk through deconstructing setupApplicationTest
!
· BEFORE
let counters;
const MockMetrics = Service.extend({
recordEvent(event) {
counters[event]++;
}
});
moduleForAcceptance('Acceptance | my-route', function() {
beforeEach() {
// THIS WILL WORK!
counters = { enter: 0, exit: 0, page: 0, click: 0 };
this.application.register('service:mock-metrics', MockMetrics);
this.application.inject('router', 'metrics', 'service:mock-metrics');
this.application.inject('controller', 'metrics', 'service:mock-metrics');
this.application.inject('component:my-component', 'metrics', 'service:mock-metrics');
};
· AFTER
import { setupApplicationTest } from 'ember-qunit';
let counters;
const MockMetrics = Service.extend({
recordEvent(event) {
counters[event]++;
}
});
module('Acceptance | my-route', function(hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function() {
// THIS WON'T WORK IF YOU OR AN ADDON ALREADY ASKED FOR AN INSTANCE OF YOUR SERVICE!
counters = { enter: 0, exit: 0, page: 0, click: 0 };
this.owner.register('service:mock-metrics', MockMetrics);
this.owner.inject('router', 'metrics', 'service:mock-metrics');
this.owner.inject('controller', 'metrics', 'service:mock-metrics');
this.owner.inject('component:my-component', 'metrics', 'service:mock-metrics');
};
Moving to setupApplicationTest(hooks)
and Ember’s new QUnit testing setup, this has no effect.
So to fix this, we need to emulate what setupApplicationTest
was doing for us.
import Application from '../../app';
import {
setupContext,
setupApplicationContext,
teardownContext,
teardownApplicationContext,
setApplication
} from '@ember/test-helpers';
module('Acceptance | my-route', function(hooks) {
hooks.beforeEach(async function() {
this.owner = Application.create({ autoboot: false });
counters = { enter: 0, exit: 0, page: 0, click: 0 };
this.owner.register('service:mock-metrics', MockMetrics);
this.owner.inject('router', 'metrics', 'service:mock-metrics');
this.owner.inject('controller', 'metrics', 'service:mock-metrics');
this.owner.inject('component:my-component', 'metrics', 'service:mock-metrics');
await setApplication(this.owner);
await setupContext(this);
await setupApplicationContext(this);
});
hooks.afterEach(async function() {
await teardownApplicationContext(this);
await teardownContext(this);
});
This is still not perfect. If you debug running one test, you will notice that before your test runs, Ember has already loaded your acceptance tests into memory. There is one instance of your Application
used between all your tests. In the example above, we set
a new Application
on the global test context. This may set us up for failure in future tests. So, we need to stash the global Application
and reset after your tests have run with the custom Application
.
import Application from '../../app';
import {
setupContext,
setupApplicationContext,
teardownContext,
teardownApplicationContext,
setApplication,
getApplication
} from '@ember/test-helpers';
module('Acceptance | my-route', function(hooks) {
hooks.beforeEach(async function() {
this.oldAppInstance = getApplication();
this.owner = Application.create({ autoboot: false });
counters = { enter: 0, exit: 0, page: 0, click: 0 };
this.owner.register('service:mock-metrics', MockMetrics);
this.owner.inject('router', 'metrics', 'service:mock-metrics');
this.owner.inject('controller', 'metrics', 'service:mock-metrics');
this.owner.inject('component:my-component', 'metrics', 'service:mock-metrics');
await setApplication(this.owner);
await setupContext(this);
await setupApplicationContext(this);
});
hooks.afterEach(async function() {
await teardownApplicationContext(this);
await teardownContext(this);
await setApplication(this.oldAppInstance);
});
Even if these examples above aren’t particularly useful, I hope it showed some of what is happening behind the scenes when your test suite boots. Also, since the above is rather tedious and prone to error, be on the look out for an improved way to customize the owner/container before your tests have run.
Timers
Lastly, do you have functions that run for a specified period of time? Maybe you temporarily set state on a component in order to provide smooth CSS animations. Or perhaps your search debounce waits for 100ms to avoid excessive network requests. Well, in the context of Ember’s testing harness, these are all things that will slow your test suite down. Here is a simple example.
actions: {
onButtonClick(msg) {
set(this, 'emittedMsg', msg);
this._showMsgTimer = run.later(this, () => this._hideMsg(), 2500);
}
}
So what does this action do? It will show a msg for 2500 ms then disappear. You will notice that during your test, to proceed from, say a click
action, Ember will check the settled
state. As of right now, settled
checks hasPendingTimers
, hasRunLoop
, hasPendingRequests
&& hasPendingWaiters
. As a result, you will see your tests paused for 2500 ms. To speed up your test, I’ll show you one way to solve it, albeit not sufficient for this scenario.
// config/environment.js
let ENV = {
APP: {
SHOW_MSG_TIMER = 2500;
}
}
if (environment === 'test') {
ENV.APP.SHOW_MSG_TIMER = 10;
}
And then in your action…
import config from 'my-app/config/environment';
const { SHOW_MSG_TIMER } = config.APP;
actions: {
onButtonClick(msg) {
set(this, 'emittedMsg', msg);
this._showMsgTimer = run.later(this, () => this._hideMsg(), SHOW_MSG_TIMER);
}
}
This works, but isn’t recommended for this type of scenario. What if we need to test that it actually ran for 2500 ms, not 2000 ms? This strategy is useful for timers that have a global impact, say for a loading progress bar timer. To improve on this, we will have to wait for Lisa Backer’s article to show us how!
Wrapping Up
In 2018 (and admittedly before), tests are a first class citizen in the Ember ecosystem. Testing your Ember application is so painless and can provide loads of value to your users and future software developers on your project. Have fun testing!