Add ember-best-practices skill
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
---
|
||||
title: Provide DOM-Abstracted Test Utilities for Library Components
|
||||
impact: MEDIUM
|
||||
impactDescription: Stabilizes consumer tests against internal DOM refactors
|
||||
tags: testing, test-support, libraries, dom-abstraction, maintainability
|
||||
---
|
||||
|
||||
## Provide DOM-Abstracted Test Utilities for Library Components
|
||||
|
||||
**Impact: Medium** - Critical for library maintainability and consumer testing experience, especially important for team-based projects
|
||||
|
||||
## Problem
|
||||
|
||||
When building reusable components or libraries, consumers should not need to know implementation details or interact directly with the component's DOM. DOM structure should be considered **private** unless the author of the tests is the **owner** of the code being tested.
|
||||
|
||||
Without abstracted test utilities:
|
||||
|
||||
- Component refactoring breaks consumer tests
|
||||
- Tests are tightly coupled to implementation details
|
||||
- Teams waste time updating tests when internals change
|
||||
- Testing becomes fragile and maintenance-heavy
|
||||
|
||||
## Solution
|
||||
|
||||
**Library authors should provide test utilities that fully abstract the DOM.** These utilities expose a public API for testing that remains stable even when internal implementation changes.
|
||||
|
||||
**Incorrect (exposing DOM to consumers):**
|
||||
|
||||
```glimmer-js
|
||||
// my-library/src/components/data-grid.gjs
|
||||
export class DataGrid extends Component {
|
||||
<template>
|
||||
<div class="data-grid">
|
||||
<div class="data-grid__header">
|
||||
<button class="sort-button" data-column="name">Name</button>
|
||||
</div>
|
||||
<div class="data-grid__body">
|
||||
{{#each @rows as |row|}}
|
||||
<div class="data-grid__row">{{row.name}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Consumer's test - tightly coupled to DOM
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { DataGrid } from 'my-library';
|
||||
|
||||
test('sorting works', async function (assert) {
|
||||
await render(<template><DataGrid @rows={{this.rows}} /></template>);
|
||||
|
||||
// Fragile: breaks if class names or structure change
|
||||
await click('.data-grid__header .sort-button[data-column="name"]');
|
||||
|
||||
assert.dom('.data-grid__row:first-child').hasText('Alice');
|
||||
});
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
- Consumer knows about `.data-grid__header`, `.sort-button`, `[data-column]`
|
||||
- Refactoring component structure breaks consumer tests
|
||||
- No clear public API for testing
|
||||
|
||||
**Correct (providing DOM-abstracted test utilities):**
|
||||
|
||||
```glimmer-js
|
||||
// my-library/src/test-support/data-grid.js
|
||||
import { click, findAll } from '@ember/test-helpers';
|
||||
|
||||
/**
|
||||
* Test utility for DataGrid component
|
||||
* Provides stable API regardless of internal DOM structure
|
||||
*/
|
||||
export class DataGridTestHelper {
|
||||
constructor(containerElement) {
|
||||
this.container = containerElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by column name
|
||||
* @param {string} columnName - Column to sort by
|
||||
*/
|
||||
async sortBy(columnName) {
|
||||
// Implementation detail hidden from consumer
|
||||
const button = this.container.querySelector(`[data-test-sort="${columnName}"]`);
|
||||
if (!button) {
|
||||
throw new Error(`Column "${columnName}" not found`);
|
||||
}
|
||||
await click(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all row data
|
||||
* @returns {Array<string>} Row text content
|
||||
*/
|
||||
getRows() {
|
||||
return findAll('[data-test-row]', this.container).map((el) => el.textContent.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get row by index
|
||||
* @param {number} index - Zero-based row index
|
||||
* @returns {string} Row text content
|
||||
*/
|
||||
getRow(index) {
|
||||
const rows = this.getRows();
|
||||
return rows[index];
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function for easier usage
|
||||
export function getDataGrid(container = document) {
|
||||
const gridElement = container.querySelector('[data-test-data-grid]');
|
||||
if (!gridElement) {
|
||||
throw new Error('DataGrid component not found');
|
||||
}
|
||||
return new DataGridTestHelper(gridElement);
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// my-library/src/components/data-grid.gjs
|
||||
// Component updated with test hooks (data-test-*)
|
||||
export class DataGrid extends Component {
|
||||
<template>
|
||||
<div data-test-data-grid class="data-grid">
|
||||
<div class="data-grid__header">
|
||||
{{#each @columns as |column|}}
|
||||
<button data-test-sort={{column.name}}>
|
||||
{{column.label}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="data-grid__body">
|
||||
{{#each @rows as |row|}}
|
||||
<div data-test-row class="data-grid__row">{{row.name}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Consumer's test - abstracted from DOM
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { DataGrid } from 'my-library';
|
||||
import { getDataGrid } from 'my-library/test-support';
|
||||
|
||||
test('sorting works', async function (assert) {
|
||||
await render(<template><DataGrid @rows={{this.rows}} @columns={{this.columns}} /></template>);
|
||||
|
||||
const grid = getDataGrid();
|
||||
|
||||
// Clean API: no DOM knowledge required
|
||||
await grid.sortBy('name');
|
||||
|
||||
assert.strictEqual(grid.getRow(0), 'Alice');
|
||||
assert.deepEqual(grid.getRows(), ['Alice', 'Bob', 'Charlie']);
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Component internals can change without breaking consumer tests
|
||||
- Clear, documented testing API
|
||||
- Consumer tests are declarative and readable
|
||||
- Library maintains API stability contract
|
||||
|
||||
## When This Matters Most
|
||||
|
||||
### Team-Based Projects (Critical)
|
||||
|
||||
On projects with teams, DOM abstraction prevents:
|
||||
|
||||
- Merge conflicts from test changes
|
||||
- Cross-team coordination overhead
|
||||
- Broken tests from uncoordinated refactoring
|
||||
- Knowledge silos about component internals
|
||||
|
||||
### Solo Projects (Less Critical)
|
||||
|
||||
For solo projects, the benefit is smaller but still valuable:
|
||||
|
||||
- Easier refactoring without test maintenance
|
||||
- Better separation of concerns
|
||||
- Professional API design practice
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use `data-test-*` Attributes
|
||||
|
||||
```glimmer-js
|
||||
// Stable test hooks that won't conflict with styling
|
||||
<button data-test-submit>Submit</button>
|
||||
<div data-test-error-message>{{@errorMessage}}</div>
|
||||
```
|
||||
|
||||
### 2. Document the Test API
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* @class FormTestHelper
|
||||
* @description Test utility for Form component
|
||||
*
|
||||
* @example
|
||||
* const form = getForm();
|
||||
* await form.fillIn('email', 'user@example.com');
|
||||
* await form.submit();
|
||||
* assert.strictEqual(form.getError(), 'Invalid email');
|
||||
*/
|
||||
```
|
||||
|
||||
### 3. Provide Semantic Methods
|
||||
|
||||
```javascript
|
||||
// ✅ Semantic and declarative
|
||||
await modal.close();
|
||||
await form.fillIn('email', 'test@example.com');
|
||||
assert.true(dropdown.isOpen());
|
||||
|
||||
// ❌ Exposes implementation
|
||||
await click('.modal-close-button');
|
||||
await fillIn('.form-field[name="email"]', 'test@example.com');
|
||||
assert.dom('.dropdown.is-open').exists();
|
||||
```
|
||||
|
||||
### 4. Handle Edge Cases
|
||||
|
||||
```javascript
|
||||
export class FormTestHelper {
|
||||
async fillIn(fieldName, value) {
|
||||
const field = this.container.querySelector(`[data-test-field="${fieldName}"]`);
|
||||
if (!field) {
|
||||
throw new Error(
|
||||
`Field "${fieldName}" not found. Available fields: ${this.getFieldNames().join(', ')}`,
|
||||
);
|
||||
}
|
||||
await fillIn(field, value);
|
||||
}
|
||||
|
||||
getFieldNames() {
|
||||
return Array.from(this.container.querySelectorAll('[data-test-field]')).map(
|
||||
(el) => el.dataset.testField,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Complete Test Utility
|
||||
|
||||
```javascript
|
||||
// addon/test-support/modal.js
|
||||
import { click, find, waitUntil } from '@ember/test-helpers';
|
||||
|
||||
export class ModalTestHelper {
|
||||
constructor(container = document) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
get element() {
|
||||
return find('[data-test-modal]', this.container);
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return this.element !== null;
|
||||
}
|
||||
|
||||
async waitForOpen() {
|
||||
await waitUntil(() => this.isOpen(), { timeout: 1000 });
|
||||
}
|
||||
|
||||
async waitForClose() {
|
||||
await waitUntil(() => !this.isOpen(), { timeout: 1000 });
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
const titleEl = find('[data-test-modal-title]', this.element);
|
||||
return titleEl ? titleEl.textContent.trim() : null;
|
||||
}
|
||||
|
||||
getBody() {
|
||||
const bodyEl = find('[data-test-modal-body]', this.element);
|
||||
return bodyEl ? bodyEl.textContent.trim() : null;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.isOpen()) {
|
||||
throw new Error('Cannot close modal: modal is not open');
|
||||
}
|
||||
await click('[data-test-modal-close]', this.element);
|
||||
}
|
||||
|
||||
async clickButton(buttonText) {
|
||||
const buttons = findAll('[data-test-modal-button]', this.element);
|
||||
const button = buttons.find((btn) => btn.textContent.trim() === buttonText);
|
||||
if (!button) {
|
||||
const available = buttons.map((b) => b.textContent.trim()).join(', ');
|
||||
throw new Error(`Button "${buttonText}" not found. Available: ${available}`);
|
||||
}
|
||||
await click(button);
|
||||
}
|
||||
}
|
||||
|
||||
export function getModal(container) {
|
||||
return new ModalTestHelper(container);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Impact
|
||||
|
||||
**Before:** ~30-50% of test maintenance time spent updating selectors
|
||||
**After:** Minimal test maintenance when refactoring components
|
||||
|
||||
## Related Patterns
|
||||
|
||||
- **component-avoid-classes-in-examples.md** - Avoid exposing implementation details
|
||||
- **testing-modern-patterns.md** - Modern testing approaches
|
||||
- **testing-render-patterns.md** - Component testing patterns
|
||||
|
||||
## References
|
||||
|
||||
- [Testing Best Practices - ember-learn](https://guides.emberjs.com/release/testing/)
|
||||
- [ember-test-selectors](https://github.com/mainmatter/ember-test-selectors) - Addon for stripping test selectors from production
|
||||
- [Page Objects Pattern](https://martinfowler.com/bliki/PageObject.html) - Related testing abstraction pattern
|
||||
Reference in New Issue
Block a user