8.3 KiB
title, impact, impactDescription, tags
| title | impact | impactDescription | tags |
|---|---|---|---|
| Use Test Waiters for Async Operations | HIGH | Reliable tests that don't depend on implementation details | 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):
// 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(<template><DataLoader /></template>);
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):
// 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);
}
};
<template>
<div>
<button {{on "click" this.loadData}} data-test-load-button>
Load Data
</button>
{{#if this.isLoading}}
<div data-test-loading>Loading...</div>
{{/if}}
{{#if this.data}}
<div data-test-data>{{this.data}}</div>
{{/if}}
</div>
</template>
}
// 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(<template><DataLoader /></template>);
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:
// 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();
}
};
<template>
<div>
<button {{on "click" this.startPolling}} data-test-start>
Start Polling
</button>
<div data-test-status>{{this.status}}</div>
</div>
</template>
}
Test waiter with Services:
// 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);
}
}
}
// 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:
// 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);
};
<template>
<button {{on "click" this.loadAll}} data-test-load-all>
Load All
</button>
{{#each this.results as |result|}}
<div data-test-result>{{result}}</div>
{{/each}}
</template>
}
Benefits:
- User-focused tests: Tests describe user actions, not implementation
- Resilient to refactoring: Change timing/polling without breaking tests
- No arbitrary timeouts: Tests complete as soon as operations finish
- Automatic waiting:
settled(),click(), etc. wait for all registered operations - 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