Testing
How the suite tests its own components, and the same recipes for your app: render, query by role and by the stable .oge-* class names, drive with the keyboard, and stub only the browser APIs jsdom lacks.
Unit tests
The Angular packages are tested with vitest and TestBed: a small host component binds the inputs, the fixture is settled (render, wait for signals and effects, render again), and assertions read the DOM. The same pattern works under Karma or Jest.
// orders.spec.ts — vitest (or Jasmine) + TestBed, zoneless
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OgeSelectBox } from '@oge-ui/inputs/select-box';
@Component({
imports: [OgeSelectBox],
template: `<oge-select-box label="City" [items]="cities" [(value)]="city" />`,
})
class Host {
readonly cities = ['Lisbon', 'Oslo', 'Rome'];
readonly city = signal<unknown>(null);
}
/** Render, let signals and effects flush, render again. */
async function settle(fixture: ComponentFixture<unknown>): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
describe('city picker', () => {
beforeEach(() => {
// popups position in requestAnimationFrame — stub it *asynchronously*;
// a synchronous stub re-enters change detection mid-tick (NG0100)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) =>
setTimeout(() => cb(performance.now()), 0) as unknown as number);
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
});
afterEach(() => vi.unstubAllGlobals());
it('is a combobox that starts closed', async () => {
const fixture = TestBed.createComponent(Host);
await settle(fixture);
const input: HTMLInputElement =
fixture.nativeElement.querySelector('oge-select-box input');
expect(input.getAttribute('role')).toBe('combobox');
expect(input.getAttribute('aria-expanded')).toBe('false');
});
});Component harnesses are not shipped yet. There is no @oge-ui/<pkg>/testing entry point today; query the DOM as above. Planned: CDK harnesses for the grid, select box, date box, modal and tabs, plus React Testing Library helpers.
Querying OGE components
| Target | Prefer | Fallback |
|---|---|---|
| Buttons, editors, menus, tabs, trees | getByRole('button' | 'combobox' | 'menuitem' | 'tab' | 'treeitem', { name }) | The host tag plus a class: oge-select-box .oge-input-native |
| Grid rows and cells | getByRole('row') , getByRole('gridcell') , getByRole('columnheader', { name }) | .oge-row , .oge-group-row |
| Dialogs and toasts | getByRole('dialog', { name }) , getByRole('status') | — |
| One instance among several | Scope to its container ( within(…) / locator(…).filter({ hasText }) ) | An id or data-testid you set on the host |
jsdom gaps
The components guard every browser API they call, so a missing one degrades instead of throwing. Stub what your spec actually exercises — the suite does it per spec, not in a global setup file:
| Missing in jsdom | Used by | Workaround |
|---|---|---|
HTMLCanvasElement.getContext | Signature pad, chart and Gantt image export | Spy on it and return null (or a fake context) |
URL.createObjectURL / revokeObjectURL | Upload thumbnails, file downloads | Assign a fake, restore it after the spec |
ResizeObserver | Toolbar and breadcrumb overflow, chart sizing | A no-op class via vi.stubGlobal |
matchMedia | Reduced motion, adaptive popups | A stub answering matches: false |
PointerEvent , elementFromPoint | Drags (kanban, grid column and row moves, sliders) | Dispatch a MouseEvent named pointerdown / pointermove / pointerup with pointerId and pointerType defined on it, on the element under the pointer |
requestAnimationFrame timing | Popup placement (Angular) | Stub it asynchronously ( setTimeout(cb, 0) ), never synchronously |
// only in the specs that need them — the gaps jsdom leaves
// <canvas>: signature pad, chart and Gantt image export
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
// URL.createObjectURL / revokeObjectURL: upload thumbnails, file downloads
URL.createObjectURL = () => 'blob:test';
URL.revokeObjectURL = () => undefined;
// ResizeObserver: toolbars and breadcrumbs that collapse into overflow menus
class FakeResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
// matchMedia: prefers-reduced-motion, adaptive popups
window.matchMedia = (query: string) =>
({ matches: false, media: query, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList;
// PointerEvent: jsdom has no constructor — dispatch a MouseEvent with the pointer type
const down = new MouseEvent('pointerdown', { bubbles: true, button: 0 });
Object.defineProperty(down, 'pointerId', { value: 1 });
Object.defineProperty(down, 'pointerType', { value: 'mouse' });
element.dispatchEvent(down);End-to-end with Playwright
- Poll, don't read once. Components re-render after signals settle; web-first assertions (
toHaveValue,toBeFocused,toContainText) retry, andexpect.poll()covers values that are not a locator state. The suite's CI fails a test that only passes on retry. - Drive the keyboard. Every family has a keyboard path (see the accessibility guide);
locator.press('ArrowDown')on the focused element is closer to a user than clicking options. - Pin the locale and time zone. Number, date and pager text follow the browser locale; a machine in another locale formats
1,500as1.500. - Live regions. Grid and tree list announcements go to one shared region per document,
[data-oge-live-announcer="polite"](and"assertive"for validation errors).
import { expect, test } from '@playwright/test';
// a fixed locale and time zone: number and date formatting follow the browser
test.use({ locale: 'en-US', timezoneId: 'UTC' });
test('select box: arrows and Enter pick a city', async ({ page }) => {
await page.goto('/orders');
const input = page.getByRole('combobox', { name: 'City' });
await input.click();
await input.press('ArrowDown');
await input.press('Enter');
// web-first assertions retry until the component has re-rendered
await expect(input).toHaveValue('Lisbon');
await expect(input).toBeFocused();
});
test('grid: sorting announces itself', async ({ page }) => {
await page.goto('/orders');
await page.getByRole('columnheader', { name: 'Total' }).click();
// poll values that are not a locator state (a computed style, a count)
await expect
.poll(() => page.locator('.oge-row').count())
.toBeGreaterThan(0);
await expect(page.locator('[data-oge-live-announcer="polite"]')).toContainText(
'Sorted by Total',
);
});