| name | ember-testing |
| description | Testing Ember apps — QUnit + @ember/test-helpers, the unit/integration/application split, ember-test-selectors, ember-cli-page-object, ember-cli-mirage, accessibility checks, and async settledness. Use when writing or fixing tests, designing a test strategy, or debugging flaky/non-settled tests. |
| type | reference |
Ember Testing
Ember has the strongest test story of any major frontend framework. Use it.
The three test types
| Type | Helper | Boots... | Use for |
|---|
| Unit | setupTest(hooks) | Container only — no DOM, no router. | Services, models, utilities. |
| Rendering (a.k.a. integration) | setupRenderingTest(hooks) | Container + a render context (render(hbs\...`)`). | Components, helpers, modifiers. |
| Application (a.k.a. acceptance) | setupApplicationTest(hooks) | Full app — router boots, services real, DOM live. | User flows, route-level behavior. |
Default to rendering tests for components — they're fast and exercise the real template/component contract. Save application tests for end-to-end flows.
Anatomy of a test file
import { module, test } from 'qunit';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { render, click, fillIn, settled } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
test('renders the user name', async function (assert) {
this.user = { name: 'Ada Lovelace', email: 'ada@example.com' };
await render(hbs`<UserCard @user={{this.user}} />`);
assert.dom('[data-test-name]').hasText('Ada Lovelace');
assert.dom('[data-test-email]').hasText('ada@example.com');
});
});
tests/helpers.ts re-exports the helpers from ember-qunit plus any project-specific setup (Mirage, intl, etc.).
@ember/test-helpers — the standard library
| Helper | What it does |
|---|
render(hbs\...`)` | Render a template and return when settled. Rendering tests only. |
visit(url) | Visit a URL through the router. Application tests only. |
currentURL() / currentRouteName() | Read router state. |
click(selector) | Click and wait for settled. |
fillIn(selector, value) | Set value + dispatch input/change. |
typeIn(selector, value) | Type one char at a time (for keypress handlers). |
select(selector, value, multiple?) | <select>. |
triggerEvent(selector, eventType, options?) | Custom events. |
triggerKeyEvent(selector, eventType, key) | Keyboard. |
find(selector) / findAll(selector) | DOM lookup. |
settled() | Wait until: no pending promises, no scheduled runs, no pending requests, no test waiters. |
waitFor(selector, { timeout, count }) | Wait until selector is present. |
waitUntil(predicate) | Wait until predicate returns truthy. |
Always await user-action helpers. They handle settling for you. Don't add manual setTimeouts.
Selectors — use data-test-* via ember-test-selectors
Hard-coded class selectors break when designers refactor CSS. Use stable test selectors:
<button data-test-save type="button" {{on "click" this.save}}>Save</button>
Install ember-test-selectors and the data-test-* attributes are auto-stripped from production builds. You get clean test selectors with zero runtime cost.
await click('[data-test-save]');
assert.dom('[data-test-save]').isDisabled();
qunit-dom (the assert.dom(...) API) is bundled with ember-qunit. Use it for DOM assertions — hasText, hasClass, hasAttribute, isDisabled, isVisible, etc.
Page objects — ember-cli-page-object
For non-trivial components and pages, encapsulate selectors in a page object so test bodies read like behavior, not DOM lookups:
import { create, clickable, text, isPresent } from 'ember-cli-page-object';
export default create({
scope: '[data-test-user-card]',
name: text('[data-test-name]'),
email: text('[data-test-email]'),
hasEditButton: isPresent('[data-test-edit]'),
edit: clickable('[data-test-edit]'),
});
import page from '../pages/user-card';
test('clicking edit opens the editor', async function (assert) {
this.user = { name: 'Ada' };
await render(hbs`<UserCard @user={{this.user}} />`);
assert.strictEqual(page.name, 'Ada');
await page.edit();
assert.dom('[data-test-editor]').exists();
});
The payoff is huge for application tests with multi-screen flows. Sticky rule: a page object never asserts — it only describes the page surface. Assertions live in the test.
Mocking the API — ember-cli-mirage
ember-cli-mirage gives you an in-browser fake server with a database, factories, scenarios, and route handlers. Use it for development and tests.
export default function () {
this.namespace = '/api';
this.get('/posts');
this.get('/posts/:id');
this.post('/posts');
this.patch('/posts/:id');
this.del('/posts/:id');
this.get('/posts/:id/comments', (schema, request) => {
const post = schema.posts.find(request.params.id);
return post.comments;
});
}
import { Factory } from 'miragejs';
export default Factory.extend({
title(i: number) { return `Post ${i}`; },
body() { return 'lorem ipsum'; },
publishedAt: () => new Date(),
});
In tests:
import { setupMirage } from 'ember-cli-mirage/test-support';
import page from 'my-app/tests/pages/posts';
module('Application | posts', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
test('lists posts', async function (assert) {
this.server.createList('post', 3);
await page.visit();
assert.strictEqual(page.posts.length, 3, '3 post rows are shown on the page');
});
});
Mirage models the JSON:API conventions Ember Data expects, so most setups Just Work without serializer overrides.
Testing services — unit tests with setupTest
import { module, test } from 'qunit';
import { setupTest } from 'my-app/tests/helpers';
module('Unit | Service | cart', function (hooks) {
setupTest(hooks);
test('subtotal sums item prices', function (assert) {
const cart = this.owner.lookup('service:cart') as CartService;
cart.add({ id: '1', price: 10, quantity: 2 });
cart.add({ id: '2', price: 5, quantity: 1 });
assert.strictEqual(cart.subtotal, 25);
});
});
To stub a dependency, register a fake before lookup:
class FakeApi extends Service {
get = sinon.stub().resolves([]);
}
hooks.beforeEach(function () {
this.owner.register('service:api', FakeApi);
});
Testing components that depend on services
Same pattern — register a fake service in the test:
test('shows error toast on failed save', async function (assert) {
class FakeNotifications extends Service {
notify = sinon.spy();
}
this.owner.register('service:notifications', FakeNotifications);
const notifications = this.owner.lookup('service:notifications') as FakeNotifications;
await render(hbs`<SaveButton />`);
await click('[data-test-save]');
assert.true(notifications.notify.calledWith('Could not save', 'error'));
});
Testing routes
For a route's model/beforeModel/afterModel logic, write an application test that visits the URL and asserts on what rendered or where the user was redirected:
test('unauthenticated visit to /dashboard redirects to /login', async function (assert) {
await page.visit();
assert.strictEqual(currentURL(), '/login');
});
Don't unit-test routes — they're glue that's only meaningful when the router runs them.
Async + settled — the only flake source you should ever face
await render(...), await click(...), etc. all return only after Ember considers the system settled:
- All scheduled runs are flushed.
- All pending promises resolved.
- No pending AJAX (via test waiters).
- No outstanding
requestAnimationFrame.
If you have flaky tests, the cause is almost always work that isn't registered with a test waiter. Fixes:
- Use
ember-concurrency tasks — they integrate with test waiters automatically.
- Wrap raw timers with
@ember/test-waiters:
import { waitForPromise } from '@ember/test-waiters';
@action async loadStuff() {
await waitForPromise(somePromise);
}
- Or simply
await the work in your component action.
For tricky cases use await waitFor('[data-test-loaded]') or await waitUntil(() => something).
Accessibility tests — ember-a11y-testing
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import page from 'my-app/tests/pages/home';
test('home page is accessible', async function (assert) {
await page.visit();
await a11yAudit();
assert.ok(true, 'no a11y errors');
});
ember-a11y-testing runs axe-core and fails the test on serious violations. Integrate it into representative pages and key components.
Snapshot / visual regression
Avoid full-DOM snapshots — they're noisy. For visual checks, use Percy (@percy/ember) on a small set of representative pages, or screenshot-diff via Playwright in a separate suite.
Test data — keep it minimal and named
this.user = { id: 1, name: 'a', email: 'a@b.c', isAdmin: true, joinedAt: new Date() };
this.user = { id: 1, name: 'Ada', isAdmin: true };
For Mirage, keep factories minimal and use traits (Factory.extend({ traits: { admin: trait({ role: 'admin' }) } })) for variants.
Acceptance test example
<!-- app/templates/application.hbs -->
<HeadlessForm data-test-form as |form|>
<form.Field @name='name' as |field|>
<div data-test-form-name>
<field.Label>Name</field.Label>
<field.Input data-test-form-name-input />
<field.Errors data-test-form-name-errors />
</div>
</form.Field>
<button type='submit' disabled={{form.isInvalid}} data-test-form-submit>Submit</button>
</HeadlessForm>
import {
create,
visitable,
fillable,
clickable,
property,
isPresent,
text,
} from 'ember-cli-page-object';
export default create({
visit: visitable('/'),
form: {
scope: '[data-test-form]',
name: {
fill: fillable('[data-test-form-name-input]'),
errors: {
scope: '[data-test-form-name-errors]',
text: text(),
isPresent: isPresent(),
},
},
submit: {
scope: '[data-test-form-submit]',
isDisabled: property('disabled'),
click: clickable(),
},
},
});
module('Acceptance | application', function (hooks) {
setupApplicationTest(hooks, {});
test('form works correctly', async function (assert) {
assert.expect(6);
await page.visit();
assert.strictEqual(
currentURL(),
'/',
'root page is opened',
);
await snapshot('/application: edit form is shown');
await page.form.name.fill('');
await page.form.submit.click();
assert.ok(
page.form.name.errors.isPresent,
'form name input error section is shown',
);
assert.strictEqual(
page.form.name.errors.text,
'Name is required',
'correct validation error message is shown',
);
assert.ok(
page.form.submit.isDisabled,
'submit button is marked as disabled when form is invalid',
);
await page.form.name.fill('lorem');
assert.notOk(
page.form.submit.isDisabled,
'submit button is enabled when form is valid',
);
window.server.patch(
'/users/:id',
function ({ users }, { requestBody }) {
const {
data: { id, attributes },
} = JSON.parse(requestBody);
assert.strictEqual(
attributes.name,
name,
'patch request is made for the user with new name',
);
const attrs = this.normalizedRequestAttrs('user');
return users.find(id).update(attrs);
},
);
await page.form.submit.click();
});
});
Anti-patterns
| Anti-pattern | Fix |
|---|
| Hard-coding class selectors that change with CSS | data-test-* via ember-test-selectors. |
await new Promise(r => setTimeout(r, 100)) | await settled() or await waitFor(...). |
| Mocking the entire store in unit tests | Use setupTest and the real store; stub fetches via Mirage. |
| Application tests that fill in many forms | Split into focused rendering tests; keep applications for the happy paths. |
One mega module with shared let user; | Each test sets up its own state — DAMP > DRY in tests. |
assert.equal(x, true) | assert.true(x) / assert.dom('...').isChecked() etc. — clearer failures. |
| Recommended to create ember page objects for complex pages/components | |
Verification
See also
ember-ecosystem-addons — Mirage, page-object, test-selectors, a11y-testing, ember-concurrency.
ember-octane-fundamentals — @tracked reactivity (impacts what your test sees).
ember-routing-and-models — application-test redirects and route hooks.