OGE logoOGE

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.

TestBed vitest Playwright

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

Stable selectors for tests
TargetPreferFallback
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:

Browser APIs jsdom does not provide
Missing in jsdomUsed byWorkaround
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, and expect.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,500 as 1.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',
  );
});