Files
marco/.agents/skills/ember-best-practices/rules/testing-modern-patterns.md

341 lines
10 KiB
Markdown

---
title: Use Modern Testing Patterns
impact: HIGH
impactDescription: Better test coverage and maintainability
tags: testing, qunit, test-helpers, integration-tests
---
## Use Modern Testing Patterns
Use modern Ember testing patterns with `@ember/test-helpers` and `qunit-dom` for better test coverage and maintainability.
**Incorrect (old testing patterns):**
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, find, click } from '@ember/test-helpers';
import UserCard from 'my-app/components/user-card';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(<template><UserCard /></template>);
// Using find() instead of qunit-dom
assert.ok(find('.user-card'));
});
});
```
**Correct (modern testing patterns):**
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import { setupIntl } from 'ember-intl/test-support';
import UserCard from 'my-app/components/user-card';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks);
test('it renders user information', async function (assert) {
const user = {
name: 'John Doe',
email: 'john@example.com',
avatarUrl: '/avatar.jpg',
};
await render(<template><UserCard @user={{user}} /></template>);
// qunit-dom assertions
assert.dom('[data-test-user-name]').hasText('John Doe');
assert.dom('[data-test-user-email]').hasText('john@example.com');
assert
.dom('[data-test-user-avatar]')
.hasAttribute('src', '/avatar.jpg')
.hasAttribute('alt', 'John Doe');
});
test('it handles edit action', async function (assert) {
assert.expect(1);
const user = { name: 'John Doe', email: 'john@example.com' };
const handleEdit = (editedUser) => {
assert.deepEqual(editedUser, user, 'Edit handler called with user');
};
await render(<template><UserCard @user={{user}} @onEdit={{handleEdit}} /></template>);
await click('[data-test-edit-button]');
});
});
```
**Component testing with reactive state:**
```glimmer-ts
// tests/integration/components/search-box-test.ts
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn } from '@ember/test-helpers';
import { trackedObject } from '@ember/reactive/collections';
import SearchBox from 'my-app/components/search-box';
module('Integration | Component | search-box', function (hooks) {
setupRenderingTest(hooks);
test('it performs search', async function (assert) {
// Use trackedObject for reactive state in tests
const state = trackedObject({
results: [] as string[],
});
const handleSearch = (query: string) => {
state.results = [`Result for ${query}`];
};
await render(
<template>
<SearchBox @onSearch={{handleSearch}} />
<ul data-test-results>
{{#each state.results as |result|}}
<li>{{result}}</li>
{{/each}}
</ul>
</template>,
);
await fillIn('[data-test-search-input]', 'ember');
// State updates reactively - no waitFor needed when using test-waiters
assert.dom('[data-test-results] li').hasText('Result for ember');
});
});
```
**Testing with ember-concurrency tasks:**
```glimmer-js
// app/components/async-button.js
import Component from '@glimmer/component';
import { task } from 'ember-concurrency';
export default class AsyncButtonComponent extends Component {
@task
*saveTask() {
yield this.args.onSave();
}
<template>
<button
type="button"
disabled={{this.saveTask.isRunning}}
{{on "click" (perform this.saveTask)}}
data-test-button
>
{{#if this.saveTask.isRunning}}
<span data-test-loading-spinner>Saving...</span>
{{else}}
{{yield}}
{{/if}}
</button>
</template>
}
```
```glimmer-js
// tests/integration/components/async-button-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import AsyncButton from 'my-app/components/async-button';
module('Integration | Component | async-button', function (hooks) {
setupRenderingTest(hooks);
test('it shows loading state during task execution', async function (assert) {
let resolveTask;
const onSave = () => {
return new Promise((resolve) => {
resolveTask = resolve;
});
};
await render(
<template>
<AsyncButton @onSave={{onSave}}>
Save
</AsyncButton>
</template>,
);
// Trigger the task
await click('[data-test-button]');
// ember-concurrency automatically registers test waiters
// The button will be disabled while the task runs
assert.dom('[data-test-button]').hasAttribute('disabled');
assert.dom('[data-test-loading-spinner]').hasText('Saving...');
// Resolve the task
resolveTask();
// No need to call settled() - ember-concurrency's test waiters handle this
assert.dom('[data-test-button]').doesNotHaveAttribute('disabled');
assert.dom('[data-test-loading-spinner]').doesNotExist();
assert.dom('[data-test-button]').hasText('Save');
});
});
```
**When to use test-waiters with ember-concurrency:**
- **ember-concurrency auto-registers test waiters** - You don't need to manually register test waiters for ember-concurrency tasks. The library automatically waits for tasks to complete before test helpers like `click()`, `fillIn()`, etc. resolve.
- **You still need test-waiters when:**
- Using raw Promises outside of ember-concurrency tasks
- Working with third-party async operations that don't integrate with Ember's test waiter system
- Creating custom async behavior that needs to pause test execution
- **You DON'T need additional test-waiters when:**
- Using ember-concurrency tasks (already handled)
- Using Ember Data operations (already handled)
- Using `settled()` from `@ember/test-helpers` (already coordinates with test waiters)
- **Note**: `waitFor()` and `waitUntil()` from `@ember/test-helpers` are code smells - if you need them, it indicates missing test-waiters in your code. Instrument your async operations with test-waiters instead.
**Route testing with MSW (Mock Service Worker):**
```javascript
// tests/acceptance/posts-test.js
import { module, test } from 'qunit';
import { visit, currentURL, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { http, HttpResponse } from 'msw';
import { setupMSW } from 'my-app/tests/helpers/msw';
module('Acceptance | posts', function (hooks) {
setupApplicationTest(hooks);
const { server } = setupMSW(hooks);
test('visiting /posts', async function (assert) {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({
data: [
{ id: '1', type: 'post', attributes: { title: 'Post 1' } },
{ id: '2', type: 'post', attributes: { title: 'Post 2' } },
{ id: '3', type: 'post', attributes: { title: 'Post 3' } },
],
});
}),
);
await visit('/posts');
assert.strictEqual(currentURL(), '/posts');
assert.dom('[data-test-post-item]').exists({ count: 3 });
});
test('clicking a post navigates to detail', async function (assert) {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({
data: [{ id: '1', type: 'post', attributes: { title: 'Test Post', slug: 'test-post' } }],
});
}),
http.get('/api/posts/test-post', () => {
return HttpResponse.json({
data: { id: '1', type: 'post', attributes: { title: 'Test Post', slug: 'test-post' } },
});
}),
);
await visit('/posts');
await click('[data-test-post-item]:first-child');
assert.strictEqual(currentURL(), '/posts/test-post');
assert.dom('[data-test-post-title]').hasText('Test Post');
});
});
```
**Note:** Use MSW (Mock Service Worker) for API mocking instead of Mirage. MSW provides better conventions and doesn't lead developers astray. See `testing-msw-setup.md` for detailed setup instructions.
**Accessibility testing:**
```glimmer-js
// tests/integration/components/modal-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import Modal from 'my-app/components/modal';
module('Integration | Component | modal', function (hooks) {
setupRenderingTest(hooks);
test('it passes accessibility audit', async function (assert) {
await render(
<template>
<Modal @isOpen={{true}} @title="Test Modal">
<p>Modal content</p>
</Modal>
</template>,
);
await a11yAudit();
assert.ok(true, 'no a11y violations');
});
test('it traps focus', async function (assert) {
await render(
<template>
<Modal @isOpen={{true}}>
<button data-test-first>First</button>
<button data-test-last>Last</button>
</Modal>
</template>,
);
assert.dom('[data-test-first]').isFocused();
// Tab should stay within modal
await click('[data-test-last]');
assert.dom('[data-test-last]').isFocused();
});
});
```
**Testing with data-test attributes:**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
class UserProfile extends Component {
<template>
<div class="user-profile" data-test-user-profile>
<img src={{@user.avatar}} alt={{@user.name}} data-test-avatar />
<h2 data-test-name>{{@user.name}}</h2>
<p data-test-email>{{@user.email}}</p>
{{#if @onEdit}}
<button {{on "click" (fn @onEdit @user)}} data-test-edit-button>
Edit
</button>
{{/if}}
</div>
</template>
}
```
Modern testing patterns with `@ember/test-helpers`, `qunit-dom`, and data-test attributes provide better test reliability, readability, and maintainability.
Reference: [Ember Testing](https://guides.emberjs.com/release/testing/)