The Ember.js testing guide, I made for myself
When I started with Ember.js in mid-2017, I had a hard time with testing. I never got my head around with the syntax. For example, consider the following code snippet.
import { moduleFor, test } from 'ember-qunit';
moduleFor('model:some-thing', 'Unit | some thing', {
unit: true
});
test('should correctly concat foo', function(assert) {
const someThing = this.subject();
someThing.set('foo', 'baz');
assert.equal(someThing.get('computedFoo'), 'computed baz');
});
I didn’t comprehend the purpose of moduleFor
; how this.subject()
referred to the instance being considered for testing, are just to name a few.
The same test in newer syntax looks like so:
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Service | some-thing', function(hooks) {
setupTest(hooks);
test('should correctly concat foo', function(assert) {
const someThing = this.owner.lookup('service:some-thing');
someThing.set('foo', 'baz');
assert.equal(someThing.computedFoo, 'computed baz');
});
});
My productivity in writing Ember.js test code improved remarkably with the newer syntax. Other than this.owner.lookup('service:some-thing')
I could comprehend the code with my existing JavaScript knowledge. References and usages are mostly explicit.
With respect to the newer syntax, I have been making notes for myself and been referring in need. I feel others might find it useful too and thus sharing it publicly.
Table of Contents
- Types of tests
- Anatomy of a test file
- Setups and Teardowns
- Structure of test files
- Overview of the three types of tests
- Unit tests
- Integration tests
- Acceptance tests
- Unit testing Components and Helpers
- Executing tests
- Executing a single test
- Executing tests against the local server
- test-helper.js
- testem.js
- Stubbing
- Stubbing only the needed
- Stubbing an instance completely
- Stubbing only the needed using Sinon.js
- Using ember-sinon-sinoff
- Stubbing API responses
- Playground
- Acknowledgment
Types of tests
There are three types of tests in the ember ecosystem, namely:
unit
integration
acceptance
I rely on ember-cli
, the primary command line tool in Ember.js ecosystem, to scaffold any files in an Ember.js application, tests files are no different. Moreover, ember-cli
automatically generates the corresponding tests files when generating the desired source files.
For example:
$ ember g service some-service
installing service
create app/services/some-service.js
installing service-test
create tests/unit/services/some-service-test.js$ ember g route some-route
installing route
create app/routes/some-route.js
create app/templates/some-route.hbs
updating router
add route some-route
installing route-test
create tests/unit/routes/some-route-test.js$ ember g helper some-helper
installing helper
create app/helpers/some-helper.js
installing helper-test
create tests/integration/helpers/some-helper-test.js$ ember g component some-component
installing component
create app/components/some-component.js
create app/templates/components/some-component.hbs
installing component-test
create tests/integration/components/some-component-test.js
If a functionality needs to be tested by rendering the UI, it is tested via integration tests
and if no rendering is needed then the functionality is tested via a unit test
.
Thus, components
, helpers
, are tested via integration tests
and services
, routes
, et al are tested via unit tests
.
ember-cli
creates the desired type of test file while generating the corresponding source file. For services
, routes
et al, it creates unit tests
and for helpers
, components
, it creates integration tests
by default.
acceptance tests
are not associated with any file but the application
and are not automatically generated by ember-cli
while generating the source code. acceptance tests
are generated explicitly, like so:
$ ember g acceptance-test some-route
installing acceptance-test
create tests/acceptance/some-route-test.js
acceptance tests
test the application from a user’s point of view by performing the actions like visiting a route, interacting with the rendered elements and so on.
Similar to the acceptance test
blueprint, we can also use component-test
, helper-test
, et al blueprints to generate desired tests files.
$ ember g component-test some-component
installing component-test
create tests/integration/components/some-component-test.js$ ember g helper-test some-helper
installing helper-test
create tests/integration/helpers/some-helper-test.js
⚠️ Please note ember-cli
does not generate unit tests
for components
and helpers
by default (when generating the corresponding source code or when generating the test files explicitly). However, we can pass -u
or (--test-type=unit)
option to create unit tests for components
and helpers
.
$ ember g component-test some-component -u
installing component-test
create tests/unit/components/some-component-test.js$ ember g helper-test some-helper -u
installing helper-test
create tests/unit/helpers/some-helper-test.js
📝 I prefer to stick to the defaults and thus never unit test components
and helpers
.
Anatomy of a test file
Setups and Teardowns
ember-qunit
provides the following common setup and teardown out of the box
- setupTest - for unit tests
- setupRenderingTest - for integration tests
- setupApplicationTest - for acceptance tests
📝 We can also create our own setups, to abstract away any repeated setups and teardowns, for example:
// custom-setup-declaration.js
export function setupSomething(hooks) {
hooks.beforeEach(function() {
// perform setups
});
hooks.afterEach(function() {
// perform teardowns
});
}
Structure of test files
The structure of any test file looks like so:
import { module, test } from 'qunit';
/*
* setup* is either of
* setupTest, setupRenderingTest, and setupApplicationTest
*/
import { setup* } from 'ember-qunit';
/* import custom setup if needed */
import { setupSomething } from '<app-context>/path/to/custom-setup-declaration.js';/*
* 'module name' and 'test name' are used to uniqely identify
* a test block
*/
module('module name', options, function(hooks) {
setup*(hooks);
/* custom setup if needed */
setupSomething(hooks); /* custom setup and tear downs */
hooks.before(function() { ... });
hooks.after(function() { ... });
hooks.beforeEach(function() { ... });
hooks.AfterEach(function() { ... });
test('test name', function(assert) {
...
});
});
📝 app-context
is the value of modulePrefix
specified in the application’s environment.js
.
📝 module
is used to group similar tests
together.
A basicunit test
would look like so:
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';module('Unit | Service | some-service', function(hooks) {
setupTest(hooks);test('it exists', function(assert) {
const someService = this.owner.lookup('service:some-service');
assert.ok(someService);
});
});
A basic integration test
would look like so:
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';module('Integration | Helper | some-helper', function(hooks) {
setupRenderingTest(hooks); test('it renders', async function(assert) {
this.set('inputValue', '1234');
await render(hbs`{{some-helper inputValue}}`);
assert.equal(this.element.textContent.trim(), '1234');
});
});
integration tests
uses utilities like render
from @ember/test-helpers
and hbs
from htmlbars-inline-precompile
.
The test functions in integration tests
are marked as async
as we await
on render
which returns a promise
.
A basicacceptance test
would look like so:
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { visit, currentURL } from '@ember/test-helpers';module('Acceptance | some route', function(hooks) {
setupApplicationTest(hooks); test('visiting /some-route', async function(assert) {
await visit('/some-route');
assert.equal(currentURL(), '/some-route');
});
});
acceptance tests
uses utilities like visit
and currentURL
from @ember/test-helpers
.
The test functions in acceptance tests
are also marked as async
as we await
on visit
which returns a promise
.
Overview of the three types of tests
Unit tests
Unit tests focus on testing the functionalities of a single unit/file.
A unit test may be testing the functionality of an instance that depends on the container
, which is Ember’s Dependency Injection system
. For example, a controller
, or a service
, or a route
.
If the desired unit depends on the application container
, then it needs to be looked up using this.owner.lookup()
. this.owner
is the ApplicationInstance
. Please refer the API documentation and Getting an Application Instance from a Factory Instance for details on ApplicationInstance
.
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';module('Unit | Service | some-service', function(hooks) {
setupTest(hooks);test('it exists', function(assert) {
const someService = this.owner.lookup('service:some-service');
assert.ok(someService);
});
});
The argument passed to the this.owner.lookup()
is a :
delimited string, where the first part identifies the type
of instance and the second part identifies the name
of the instance. In the above example type
is a service
and name
is some-service
.
If we were to test a route
named some-service
, we would have looked it up like so:
const someService = this.owner.lookup('service:some-service');
Similarly to test a controller
name some-controller
, we would look it up like so:
const someController = this.owner.lookup('controller:some-controller');
A unit test may also be testing the functionality of an instance that does not depend on the Ember’s Dependency Injection system
, for example, a utility.
$ ember g util some-util
installing util
create app/utils/some-util.js
installing util-test
create tests/unit/utils/some-util-test.js
To test a utility or any functionality that does not depend on Ember’s Dependency Injection system
, we can directly import the functionality and use it like so:
import someUtil from '<app-context>/utils/some-util';
import { module, test } from 'qunit';module('Unit | Utility | some-util', function(hooks) {
test('it works', function(assert) {
assert.ok(someUtil());
});
});
📝 app-context
is the value of modulePrefix
specified in the application’s environment.js
.
Integration tests
Integration tests are used for testing functionalities that need to be rendered, primarily components
and helpers
.
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';module('Integration | Helper | some-helper', function(hooks) {
setupRenderingTest(hooks); test('it renders', async function(assert) {
this.set('inputValue', '1234');
await render(hbs`{{some-helper inputValue}}`);
assert.equal(this.element.textContent.trim(), '1234');
});
});
Using the render
function from @ember/test-helpers
any valid handlebar fragment can be rendered and using DOM Query Helpers and DOM Interaction helpers from @ember/test-helpers
, nodes can be fetched and interacted respectively.
Acceptance tests
Acceptance tests simulate end users behavior. In acceptance tests, the application is always tested by visiting
a desired route
and then interacting with it in the same way that a real user would have.
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { visit, currentURL } from '@ember/test-helpers';module('Acceptance | some route', function(hooks) {
setupApplicationTest(hooks); test('visiting /some-route', async function(assert) {
await visit('/some-route');
assert.equal(currentURL(), '/some-route');
});
});
Using the visit
function from @ember/test-helpers
any valid route can be rendered and using DOM Query Helpers and DOM Interaction helpers from @ember/test-helpers
, the rendered UI can be operated in a similar way as an end user.
Unit testing Components and Helpers
As stated earlier, ember-cli
does not generate unit tests
for components
and helpers
by default when generating the corresponding source code or when generating the test files explicitly. I prefer to stick to the defaults and thus never unit test components
and helpers
.
However, we can pass -u
or (--test-type=unit)
option to create unit tests for components
and helpers
like so.
$ ember g component-test some-component -u
installing component-test
create tests/unit/components/some-component-test.js$ ember g helper-test some-helper -u
installing helper-test
create tests/unit/helpers/some-helper-test.js
In order to unit test a component we can create an instance of the desired component using this.owner.factoryFor('component:some-component').create({})
and perform any assertions.
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';module('Unit | Component | some-component', function(hooks) {
setupTest(hooks);test('some aspect of it', function(assert) {
const someComponent = this.owner.factoryFor('component:some-component').create({ /* desired attributes */}); assert.equal(someComponent.someMethod(), 'desired output', 'it works');
});
});
📝 The factoryFor
method could be used for other factories like services
, routes
, controller
etc.
Executing tests
ember test
command will run all the available test against a Headless Chromium instant.
Executing a single test
To run a single test we can pass the --filter='filter string'
to the ember test
command.
Executing tests against the local server
I personally prefer to run an individual test against the running instance of the local server.
If the local ember application can be accessed at http://host:port/
then the tests can be executed at http://host:port/tests
. We can also pass the desired filter string as a query parameter like so:
http://host:port/tests?filter=filter string
Or we can enter it in the filter textbox displayed at http://host:port/tests
test-helper.js
All the desired environment and prerequisites to run the tests are set up in test-helper.js
.
The default contents of the test-helper.js
looks like so:
import Application from '../app';
import config from '../config/environment';
import { setApplication } from '@ember/test-helpers';
import { start } from 'ember-qunit';
setApplication(Application.create(config.APP));
start();
Configuration options can be passed to start()
in order to enable/disable
certain extended features. For example:
start({
setupTestIsolationValidation: true
});
📝 I plan to write a separate post on the options that can be passed to the start
, method imported from ember-qunit
.
testem.js
ember-cli
uses testem as the default test runner. All the desired configuration for testem
can be found at the project’s testem.js
file. The default configuration for testem.js
is like so:
module.exports = {
test_page: 'tests/index.html?hidepassed',
disable_watching: true,
launch_in_ci: [
'Chrome'
],
launch_in_dev: [
'Chrome'
],
browser_args: {
Chrome: {
ci: [
// --no-sandbox is needed when running Chrome inside a container
process.env.CI ? '--no-sandbox' : null,
'--headless',
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-software-rasterizer',
'--mute-audio',
'--remote-debugging-port=0',
'--window-size=1440,900'
].filter(Boolean)
}
}
};
Please refer the testem documentation for further details.
Stubbing
In unit and integration tests, often the dependencies are needed to be stubbed.
Let’s consider the following example:
// app/components/some-component.jsimport Component from '@ember/component';
import { inject as injectService } from '@ember/service';
export default Component.extend({
someService: injectService('some-service'), actions: {
someMethod() {
this.someService.someMethod();
}
}
});
In the above code someMethod
of some-component.js
invokes the someMethod
method of some-service.js
.
We can either stub the someMethod
of some-service
or stub the some-service
completely.
Stubbing only the needed
All we need is to test, that someMethod
of the some-component
when invoked, in turn, invokes the someMethod
of the some-service
.
Thus we need to only stub the someMethod
of the some-service
.
In order to stub the someMethod
method of some-service.js
, we need to look up the instance of the someService
from the Ember’s Dependency Injection system’s
container
.
const someService = this.owner.lookup(“service:some-service”);
Then cache the someMethod
of someService
.
const someMethod = someService.someMethod;
Replace the someMethod of someService
with the desired stub. This stub will perform the assertion and then update the someService.someMethod
with the original implementation.
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';module('Integration | Component | some-component', function(hooks) {
setupRenderingTest(hooks); test('stubbing someService.someMethod', async function(assert) {
const someService = this.owner.lookup('service:some-service');
const someMethod = someService.someMethod; someService.someMethod = () => {
// assertion
assert.ok('someMethod was called');
// restore the original implementation
someService.someMethod = someMethod;
};
await render(hbs`{{some-component}}`);
await click('[data-test-someMethod]');
});
});
⚠️ If someService.someMethod
is not restored to its original implementation, it may impact any subsequent tests if they are relying on the original implementation of someService.someMethod
.
Stubbing an instance completely
In order to stub the some-service
completely we need to register
the stub and perform the assertion in the desired method of the stub.
const someServiceStub = Service.extend({
someMethod() {
assert.ok('someMethod was called');
}
});
this.owner.register('service:some-service', someServiceStub);
📝 this.owner.register
is isolated to each test block and does not affect other test blocks thus no stub restoration is needed.
import Service from '@ember/service';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';module('Integration | Component | some-component', function(hooks) {
setupRenderingTest(hooks);
test('stubbing whole service', async function(assert) {
const someServiceStub = Service.extend({
someMethod() {
assert.ok('someMethod was called');
}
});
this.owner.register('service:some-service', someServiceStub);
await render(hbs`{{some-component}}`);
await click('[data-test-someMethod]');
});
});
The above tests serve their purpose but do not follow the AAA (Arrange, Act, Assert) pattern. Though AAA (Arrange, Act, Assert) pattern is primarily suggested for unit testing, it can also be used for integration and acceptance testing, sometimes with a little variation like Arrange, Act, Act, Assert
, Arrange, Act, Assert, Act, Assert
and so on.
Sinon.js can be useful to achieve AAA (Arrange, Act, Assert) pattern.
📝 Also, Sinon.js exposes an API to restore the stubs with much lesser code. Thus it is better to stub only the needed when using Sinon.js.
Stubbing only the needed using Sinon.js
In order to use sinon.js
, first, we need to install it:
yarn add sinon
And then we will make sinon
available in the global scope by updating the project’s ember-cli-build.js
like so:
'use strict';const EmberApp = require('ember-cli/lib/broccoli/ember-app');module.exports = function(defaults) {
let app = new EmberApp(defaults, {}); if (process.env.EMBER_ENV !== 'production') {
app.import('node_modules/sinon/pkg/sinon.js');
} return app.toTree();
};
📝 We are importing sinon.js
only for the non-production environment so that we don’t increase the production bundle’s size, by including unnecessary code in it.
The above-discussed test using sinon.js could be written as:
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';module('Integration | Component | some-component', function(hooks) {
const sandbox = sinon.createSandbox();
setupRenderingTest(hooks); hooks.afterEach(function() {
sandbox.restore();
}); test('stubbing someService.someMethod', async function(assert) {
const someService = this.owner.lookup('service:some-service');
const someMethodStub = sandbox.stub(someService, 'someMethod'); await render(hbs`{{some-component}}`);
await click('[data-test-someMethod]');
assert.ok(someMethodStub.called, 'someMethodStub.called');
});
});
The Arrange
in the above test is
const someService = this.owner.lookup('service:some-service');
const someMethodStub = sandbox.stub(someService, 'someMethod');
await render(hbs`{{some-component}}`);
Act
is
await click('[data-test-someMethod]');
and the Assert
is
assert.ok(someMethodStub.called, 'someMethodStub.called');
📝 Please note that we are creating a sandbox
and restoring it after every test.
However, instead of manually creating and restoring sandboxes, we could use ember-sinon-sinoff
which will take care of the creation and restoration of the sandboxes by itself without any extra code and make will make sandbox
available in the test instance via this.sandbox
.
Using ember-sinon-sinoff
Thus using ember-sinon-sinoff
, the previously discussed test would look like so:
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';module('Integration | Component | some-component', function(hooks) {
setupRenderingTest(hooks);
test('stubbing someService.someMethod', async function(assert) {
const someService = this.owner.lookup('service:some-service');
const someMethodStub = this.sandbox.stub(someService, 'someMethod');
await render(hbs`{{some-component}}`);
await click('[data-test-someMethod]');
assert.ok(someMethodStub.called, 'someMethodStub.called');
});
});
Please refer to ember-sinon-sinoff
documentation for further details and integration strategies.
Stubbing API responses
If an acceptance test depends on API calls we need to stub the API responses. To stub API responses, we can use ember-cli-pretender
.
Once ember-cli-pretender
is installed, we can import and use it in the tests. For example, if visiting /some-route
, there is an /someAPI
call, we can stub the /someAPI
call like so:
import { module, test } from 'qunit';
import { visit, currentURL } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';import Pretender from 'pretender';module('Acceptance | pretender', function(hooks) {
setupApplicationTest(hooks);test('visiting /some-route', async function(assert) {
const server = new Pretender(); server.get('/someAPI', () => [
200,
{"Content-Type": "application/json"},
JSON.stringify({ status: 'ok' })
]); await visit('/some-route');
assert.equal(currentURL(), '/some-route');
assert.dom('[data-test-status]').hasText('ok');
});
});
ember-cli-pretender
is great, but if we are using ember-data
to consume API ember-cli-mirage
sounds a better match.
For more information on ember-cli-mirage
please refer to the ember-cli-mirage documentation, it has done a great job in documenting the usage in detail.
Playground
You can check out the companion app for this post at codesandbox.io and the test executions here.
I refer to this documentation whenever I am in need and update it with any new learnings. I hope if you are like me, you will find this writeup useful. Please feel free to leave your feedback with suggestions, concerns or appreciation.
Acknowledgment
I am thankful to my colleagues Walter Shen, Eric Huang and Steve Calvert for always being there to clarify any of my doubts with ember testing and helping me to comprehend the concepts of ember testing better.
I am thankful to Robert Jackson for the championing the simplify-qunit-testing-api RFC, which has improved developer experience in writing Ember.js tests remarkably. I am also thankful to Mike North for the PR that enabled Ember.js support in codesandbox, which has made it delightful to develop the companion code for this write-up.
Last but not least, I am must thank Walter Shen and Eric Huang again for reviewing this post and you for reading it. ☕️