timepicker-ui
4.5.0GitHub
timepicker-ui
Guides / Testing

Testing

Find the picker's controls by role and name, and check the value the input ends up with.

Test the picker from the user's side: click the input to open it, then reach the controls by role and accessible name. Assert on the value the input shows at the end. Testing Library's guiding principle is that a test earns more trust the closer it stays to how people use the page. A selector built on a tp-ui-* class tests the markup rather than what the user sees, so keep classes for the few parts that have no role.

Roles and names

#

The table shows what a 12-hour picker exposes with the default English labels. Every name comes from the labels option, so a test for a translated picker uses your strings.

PartRoleDefault nameSet with
PickerdialogSelect time. Enter Time in the keyboard viewlabels.clockTitle, labels.keyboardTitle
Hour fieldspinbuttonHourlabels.hour
Minute fieldspinbuttonMinutelabels.minute
Second field, with clock.showSecondsspinbuttonSecondlabels.second
AM and PMbutton with aria-pressedAM, PMlabels.am, labels.pm
Clock facesliderSame as the active field: Hour, Minute or Secondlabels.hour, labels.minute, labels.second
OK and CancelbuttonOK, Cancellabels.ok, labels.cancel
Clear, with ui.clearButtonbuttonClearlabels.clear
View switch, with ui.viewSwitchbuttonSwitch to keyboard input, Switch to clocklabels.switchToKeyboard, labels.switchToClock
Groups around the fields, AM/PM and the clock facegroupTime, Period, Clocklabels.timeFields, labels.period, labels.clock
Range tabs (range plugin)tablist with two tab elementsRange selection. Tabs: From, Tolabels.range, labels.rangeFrom, labels.rangeTo
Timezone selector (timezone plugin)combobox with a listbox of option elementsTimezonelabels.timezone
Wheel columns (wheel plugin)listbox of option elementsHour and Minute, plus Period in compact-wheel. Options: Hour 02, Minute 30labels.hour, labels.minute, labels.period
Invalid input message, next to the inputalertText: Invalid time formatlabels.invalidTimeFormat

The hour, minute and second fields are <input type="number">. On the clock face view they are read-only unless ui.editable is true, so typing into them changes nothing. In the keyboard view (ui.initialView: 'keyboard') they accept typing. These keys work on the fields in both views:

  • Up and Down arrows: one step. They wrap around at the ends.
  • Home and End: the first and the last value.
  • Page Up and Page Down: 3 hours, or 5 steps on minutes and seconds.

The clock face takes the same keys for the active field. Enter or Space on it moves to the next field.

Unit tests (Jest and Testing Library)

#

The picker needs a DOM. In Jest's default node environment the constructor throws TimepickerUI requires browser environment, so run these tests in jsdom:

index.sh
npm install --save-dev jest-environment-jsdom @testing-library/dom @testing-library/user-event @testing-library/jest-dom
jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['@testing-library/jest-dom'],
// Only needed when the code under test imports timepicker-ui CSS
moduleNameMapper: { '\\.css$': '<rootDir>/test/style-stub.js' },
};

test/style-stub.js holds one line, module.exports = {};. The examples are TypeScript, so Jest also needs the transform your project already uses, such as ts-jest or babel-jest. In Vitest, set environment: 'jsdom' and use vi.fn() where the example uses jest.fn().

time-field.test.ts
import { screen, within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { TimepickerUI, type TimepickerOptions } from 'timepicker-ui';
function renderPicker(options: TimepickerOptions = {}) {
document.body.innerHTML =
'<label for="start">Start time</label><input id="start" type="text" />';
const input = screen.getByLabelText('Start time');
const picker = new TimepickerUI(input, {
...options,
ui: { animation: false, ...options.ui },
});
picker.create();
return { input, picker };
}
afterEach(() => {
TimepickerUI.destroyAll();
document.body.innerHTML = '';
});
test('picks 2:30 PM with the keyboard', async () => {
const user = userEvent.setup();
const onConfirm = jest.fn();
const { input } = renderPicker({ callbacks: { onConfirm } });
await user.click(input);
const dialog = screen.getByRole('dialog', { name: 'Select time' });
await user.click(within(dialog).getByRole('spinbutton', { name: 'Hour' }));
await user.keyboard('{Home}{ArrowUp}'); // 01, then 02
await user.click(within(dialog).getByRole('spinbutton', { name: 'Minute' }));
await user.keyboard('{Home}' + '{PageUp}'.repeat(6)); // 00, then 05, 10 ... 30
const pm = within(dialog).getByRole('button', { name: 'PM' });
await user.click(pm);
expect(pm).toHaveAttribute('aria-pressed', 'true');
await user.click(within(dialog).getByRole('button', { name: 'OK' }));
expect(input).toHaveValue('02:30 PM');
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ hour: '02', minutes: '30', type: 'PM' }),
);
});
test('setValue() converts a 24-hour time', () => {
const { input, picker } = renderPicker();
picker.setValue('14:30');
expect(input).toHaveValue('02:30 PM');
expect(picker.getValue().time).toBe('02:30 PM');
});

The first test opens the picker, sets 2:30 PM with the keys and confirms with OK. It checks the input and the confirm payload. The second test calls setValue(), which converts a 24-hour time for a 12-hour picker.

jsdom has no layout

Every element in jsdom has a size of zero, so a click on the clock face cannot be turned into an hour or a minute. Set the time with the keys or with setValue().

Turn off animations in jsdom

renderPicker() passes ui: { animation: false }. With animations on, the switch from the hour face to the minute face animates the clock hand and reads its angle with DOMMatrix. jsdom does not have DOMMatrix, so the animation frame throws ReferenceError: DOMMatrix is not defined. With animations off, the picker skips that step and removes the modal without waiting for the close transition.

  • setValue() does not emit confirm. Test callbacks.onConfirm through the OK button.
  • In a 12-hour picker, setValue('14:30') writes 02:30 PM, the format OK writes. setValue('2:30 PM') is already in 12-hour format, and the input gets the string as you passed it: 2:30 PM.
  • TimepickerUI.destroyAll() destroys every picker with its modal and listeners. It leaves the div.tp-ui wrapper that the picker put around the input, so the example also empties document.body.

End-to-end tests (Playwright)

#

In a browser the clock face has a size, so clicks on it work. The keys still make the steadier test, because they do not depend on the viewport. The example follows the Playwright best practices: locators by role, and assertions that wait.

time-field.spec.ts
import { test, expect } from '@playwright/test';
test('picks 2:30 PM', async ({ page }) => {
await page.goto('/booking');
const input = page.getByLabel('Start time');
await input.click();
const dialog = page.getByRole('dialog', { name: 'Select time' });
await expect(dialog).toBeVisible();
const hour = dialog.getByRole('spinbutton', { name: 'Hour' });
await hour.press('Home');
await hour.press('ArrowUp');
const minute = dialog.getByRole('spinbutton', { name: 'Minute' });
await minute.press('Home');
for (let step = 0; step < 6; step++) {
await minute.press('PageUp');
}
await dialog.getByRole('button', { name: 'PM' }).click();
await dialog.getByRole('button', { name: 'OK' }).click();
await expect(dialog).toBeHidden();
await expect(input).toHaveValue('02:30 PM');
});

The page under test has <label for="start">Start time</label> for the input. 02:30 PM is the default 12-hour format: a two-digit hour and minutes, a space, then AM or PM. With clock.type: '24h' the same time is 14:30.

fill() waits for an editable element, so on the read-only fields it times out. Use press(), or turn on ui.editable in the page under test. Editable fields pad and clamp the value when they lose focus, so fill('2') on the hour field becomes 02.

When a class is the only option

#

A few parts have no role, or a name that changes while the test runs. Use a tp-ui-* class for these, scoped to the dialog locator. Keep such locators in one helper, so a markup change means one edit. CSS Classes lists the classes.

  • Numbers on the clock face. They are aria-hidden, so role queries skip them. In a browser you can click one through .tp-ui-value-tips, or .tp-ui-value-tips-24h on the inner ring of a 24-hour clock. A disabled number also has .tp-ui-tips-disabled.
  • The selected timezone. The combobox takes its name from labels.timezone, and the option list sits inside it, so its text contains every timezone. Read the selected one from .tp-ui-timezone-selected. Option names include the current UTC offset, for example Paris (+01:00), and the offset changes with daylight saving time. Match options by city with a regular expression.
  • The time on a range tab. Find the tabs with getByRole('tab', { name: /^From/ }) and { name: /^To/ }. Once a tab shows a time, the time becomes part of its name, so match the start of the name. For the time alone, use .tp-ui-range-from-time or .tp-ui-range-to-time.
locators.ts
const dialog = page.getByRole('dialog', { name: 'Select time' });
// Clock face numbers are aria-hidden: click the 3 by class
await dialog.locator('.tp-ui-value-tips', { hasText: /^3$/ }).click();
// Timezone plugin: pick an option by city, read the selection by class
await dialog.getByRole('combobox', { name: 'Timezone' }).click();
await dialog.getByRole('option', { name: /^Paris / }).click();
await expect(dialog.locator('.tp-ui-timezone-selected')).toHaveText(/^Paris /);
// Range plugin, after a From time is set: the tab by role, its time by class
const fromTab = dialog.getByRole('tab', { name: /^From/ });
await expect(fromTab.locator('.tp-ui-range-from-time')).not.toHaveText('--:--');
Esc