Files
marco/.agents/skills/ember-best-practices/rules/testing-test-waiters.md

310 lines
8.3 KiB
Markdown

---
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(<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):**
```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);
}
};
<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>
}
```
```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(<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:**
```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();
}
};
<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:**
```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);
};
<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:**
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)