--- title: Use Test Waiters for Async Operations impact: HIGH impactDescription: Reliable tests that don't depend on implementation details tags: testing, async, test-waiters, waitFor, settled --- ## Use Test Waiters for Async Operations Instrument async code with test waiters instead of using `waitFor()` or `waitUntil()` in tests. Test waiters abstract async implementation details so tests focus on user behavior rather than timing. **Why Test Waiters Matter:** Test waiters allow `settled()` and other test helpers to automatically wait for your async operations. This means: - Tests don't need to know about implementation details (timeouts, polling intervals, etc.) - Tests are written from a user's perspective ("click button, see result") - Code refactoring doesn't break tests - Tests are more reliable and less flaky **Incorrect (testing implementation details):** ```glimmer-js // tests/integration/components/data-loader-test.js import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { render, click, waitFor } from '@ember/test-helpers'; import DataLoader from 'my-app/components/data-loader'; module('Integration | Component | data-loader', function (hooks) { setupRenderingTest(hooks); test('it loads data', async function (assert) { await render(); await click('[data-test-load-button]'); // BAD: Test knows about implementation details // If the component changes from polling every 100ms to 200ms, test breaks await waitFor('[data-test-data]', { timeout: 5000 }); assert.dom('[data-test-data]').hasText('Loaded data'); }); }); ``` **Correct (using test waiters):** ```glimmer-js // app/components/data-loader.gjs import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { registerDestructor } from '@ember/destroyable'; import { buildWaiter } from '@ember/test-waiters'; const waiter = buildWaiter('data-loader'); export class DataLoader extends Component { @tracked data = null; @tracked isLoading = false; loadData = async () => { // Register the async operation with test waiter const token = waiter.beginAsync(); try { this.isLoading = true; // Simulate async data loading const response = await fetch('/api/data'); this.data = await response.json(); } finally { this.isLoading = false; // Always end the async operation, even on error waiter.endAsync(token); } }; } ``` ```glimmer-js // tests/integration/components/data-loader-test.js import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { render, click, settled } from '@ember/test-helpers'; import DataLoader from 'my-app/components/data-loader'; module('Integration | Component | data-loader', function (hooks) { setupRenderingTest(hooks); test('it loads data', async function (assert) { await render(); await click('[data-test-load-button]'); // GOOD: settled() automatically waits for test waiters // No knowledge of timing needed - tests from user's perspective await settled(); assert.dom('[data-test-data]').hasText('Loaded data'); }); }); ``` **Test waiter with cleanup:** ```glimmer-js // app/components/polling-widget.gjs import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { registerDestructor } from '@ember/destroyable'; import { buildWaiter } from '@ember/test-waiters'; const waiter = buildWaiter('polling-widget'); export class PollingWidget extends Component { @tracked status = 'idle'; intervalId = null; token = null; constructor(owner, args) { super(owner, args); registerDestructor(this, () => { this.stopPolling(); }); } startPolling = () => { // Register async operation this.token = waiter.beginAsync(); this.intervalId = setInterval(() => { this.checkStatus(); }, 1000); }; stopPolling = () => { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } // End async operation on cleanup if (this.token) { waiter.endAsync(this.token); this.token = null; } }; checkStatus = async () => { const response = await fetch('/api/status'); this.status = await response.text(); if (this.status === 'complete') { this.stopPolling(); } }; } ``` **Test waiter with Services:** ```glimmer-js // app/services/data-sync.js import Service from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { buildWaiter } from '@ember/test-waiters'; const waiter = buildWaiter('data-sync-service'); export class DataSyncService extends Service { @tracked isSyncing = false; async sync() { const token = waiter.beginAsync(); try { this.isSyncing = true; const response = await fetch('/api/sync', { method: 'POST' }); const result = await response.json(); return result; } finally { this.isSyncing = false; waiter.endAsync(token); } } } ``` ```glimmer-js // tests/unit/services/data-sync-test.js import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { settled } from '@ember/test-helpers'; module('Unit | Service | data-sync', function (hooks) { setupTest(hooks); test('it syncs data', async function (assert) { const service = this.owner.lookup('service:data-sync'); // Start async operation const syncPromise = service.sync(); // No need for manual waiting - settled() handles it await settled(); const result = await syncPromise; assert.ok(result, 'Sync completed successfully'); }); }); ``` **Multiple concurrent operations:** ```glimmer-js // app/components/parallel-loader.gjs import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { buildWaiter } from '@ember/test-waiters'; const waiter = buildWaiter('parallel-loader'); export class ParallelLoader extends Component { @tracked results = []; loadAll = async () => { const urls = ['/api/data1', '/api/data2', '/api/data3']; // Each request gets its own token const requests = urls.map(async (url) => { const token = waiter.beginAsync(); try { const response = await fetch(url); return await response.json(); } finally { waiter.endAsync(token); } }); this.results = await Promise.all(requests); }; } ``` **Benefits:** 1. **User-focused tests**: Tests describe user actions, not implementation 2. **Resilient to refactoring**: Change timing/polling without breaking tests 3. **No arbitrary timeouts**: Tests complete as soon as operations finish 4. **Automatic waiting**: `settled()`, `click()`, etc. wait for all registered operations 5. **Better debugging**: Test waiters show pending operations when tests hang **When to use test waiters:** - Network requests (fetch, XHR) - Timers and intervals (setTimeout, setInterval) - Animations and transitions - Polling operations - Any async operation that affects rendered output **When NOT needed:** - ember-concurrency already registers test waiters automatically - Promises that complete before render (data preparation in constructors) - Operations that don't affect the DOM or component state **Key principle:** If your code does something async that users care about, register it with a test waiter. Tests should never use `waitFor()` or `waitUntil()` - those are code smells indicating missing test waiters. Reference: [@ember/test-waiters](https://github.com/emberjs/ember-test-waiters)