9.1 KiB
title, impact, impactDescription, tags
| title | impact | impactDescription | tags |
|---|---|---|---|
| Provide DOM-Abstracted Test Utilities for Library Components | MEDIUM | Stabilizes consumer tests against internal DOM refactors | 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):
// 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>
}
// 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):
// 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);
}
// 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>
}
// 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
// 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
/**
* @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
// ✅ 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
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
// 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
- ember-test-selectors - Addon for stripping test selectors from production
- Page Objects Pattern - Related testing abstraction pattern