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

10 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Use Modern Testing Patterns HIGH Better test coverage and maintainability 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):

// 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):

// 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:

// 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:

// 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>
}
// 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):

// 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:

// 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:

// 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