Testing
Test the calendar you built with a fixed clock, typed builders, a render harness, and keyboard and pointer drivers.
@moltenship/calendar-testing tests the calendar your app builds: your copies of the registry
views, your policies and the code that saves changes. It renders your Root with a fixed clock,
time zone and locale, owns the events the way your app's state would, records what the calendar
tells your app, and drives it with the keyboard and the pointer as a person would.
It uses only the public API of @moltenship/calendar and @moltenship/calendar-recurrence, and
depends on no test runner. The examples here use Vitest with jsdom; happy-dom
works too, and pointer tests run in Vitest's browser mode.
Install
pnpm add -D @moltenship/calendar-testing vitest jsdomReact 19 and React DOM 19 are peer dependencies: the harness renders and hydrates with React DOM.
Set up Vitest
Run the tests in a DOM environment:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "jsdom", setupFiles: ["./tests/setup.ts"] },
});Node.js 26 and later have Temporal. On an older runtime, or a browser without it, install the
polyfill in the setup file, as the app does. The package never installs one itself, and its helpers
throw an error that says so when Temporal is missing.
if (!("Temporal" in globalThis)) await import("temporal-polyfill/global");The harness turns on React's IS_REACT_ACT_ENVIRONMENT flag when it first renders, unless your
setup set it already.
A fixed clock and zone
The calendar reads the current time only from Root's now or clock, and the time zone from
timeZone or defaultTimeZone; locale and weekRules are required props. So a test makes it
deterministic through props, without faking timers or patching Temporal.Now.
createTestEnvironment(options?) fixes all of them. Its defaults are Thursday, 8 October 2026 at
noon UTC, US English, weeks from Monday, and left to right.
const environment = createTestEnvironment({ timeZone: "Europe/Berlin" });
environment.now; // 2026-10-08T12:00:00Z, a Temporal.Instant
environment.today; // 2026-10-08, now's date in the zone
environment.at("09:00"); // 09:00 today in Berlin: 2026-10-08T07:00:00Z
environment.at("2026-10-26T09:00"); // a wall time in Berlin, after the clocks go back
environment.rootProps; // { now, defaultTimeZone, locale, weekRules, direction, defaultAnchorDate }at reads a time alone on the environment's own date, today, however far the calendar has moved.
| Option | Default |
|---|---|
now | "2026-10-08T12:00:00Z"; text without a zone is a wall time on timeZone |
timeZone | "UTC" |
locale | "en-US" |
weekRules | { firstDay: 1, minimalDays: 4, weekend: [6, 7] } |
direction | "ltr" |
anchorDate | today |
To show a later time, call the harness's setNow: the calendar re-renders with it at once. To let
time pass as an app's clock does, pass createTestClock(start) as the harness's clock option, or
Root's clock in your own render, and move it with set or advance. The calendar samples a
clock once a minute, so move fake timers with it. A clock reads text without a zone as UTC.
Builders
Builders make records in the canonical shape createCanonicalEventAdapter reads. Every field has
a default, so a test names only what it's about. Text times without a zone are wall times on the
event's timeZone, UTC by default, and timeZone becomes the event's sourceTimeZone.
| Builder | Makes |
|---|---|
buildEvent(input?) | A timed event: an hour at 09:00 on the default date. start with end or duration |
buildAllDayEvent(input?) | An all-day event: start with end (exclusive) or days |
buildResource(input?) | A CalendarResource, with timeZone, parentId and availability |
buildRecurringEvent(input) | A timed series for @moltenship/calendar-recurrence: rule as RRULE text, exceptionDates |
withRecurrence(event, input) | Makes any event, all-day ones too, the master of a series |
buildAvailability(input?) | Working hours: days, hours such as "09:00-17:00", exceptions; 9 to 5 on weekdays |
An event's id defaults to its title and its title to its ID, so buildEvent({ title: "Standup" })
is the event "Standup".
Render a calendar
Create your calendar as the app does, then render it with renderCalendar. Clean up after each
test.
import {
createCalendar,
createCanonicalEventAdapter,
type CalendarEvent,
} from "@moltenship/calendar";
import {
buildEvent,
cleanup,
createTestEnvironment,
hydrateCalendar,
renderCalendar,
} from "@moltenship/calendar-testing";
import { calendarAnnouncements } from "@/components/calendar/announcements";
import { createWeekView } from "@/components/calendar/time-grid-view/time-grid-view";
type Meeting = CalendarEvent<null>;
const Calendar = createCalendar<Meeting>()({
eventAdapter: createCanonicalEventAdapter<null>(),
views: [createWeekView<Meeting>()],
});
// Unmounts every calendar a test rendered.
afterEach(cleanup);afterEach, expect and test come from vitest.
renderCalendar(Calendar, options) renders Root with the environment's props inside act, and
<Calendar.ActiveView /> inside it. It owns the events: a change your calendar saves is applied to
them, so the next render shows it, as your app's state would.
const environment = createTestEnvironment({ timeZone: "Europe/Berlin" });
const standup = buildEvent({ title: "Standup", start: environment.at("09:00") });
test("a saved move reaches onEventChange and the events", async () => {
const calendar = await renderCalendar(Calendar, {
environment,
events: [standup],
messages: calendarAnnouncements,
});
await calendar.propose({
cause: "move",
eventId: "Standup",
proposed: {
kind: "timed",
start: environment.at("10:00"),
end: environment.at("10:30"),
},
});
expect(calendar.changes.map((change) => change.cause)).toEqual(["move"]);
expect(calendar.results[0]?.result.status).toBe("committed");
expect(String(calendar.events()[0]?.start)).toBe("2026-10-08T08:00:00Z");
});| Option | Effect |
|---|---|
events | The events the calendar starts with |
view | The view it opens on; the first registered one by default |
environment | createTestEnvironment() by default |
clock | A clock to read the time from instead of the environment's fixed now |
resources | Root's resources |
onEventChange | Called with each change before the harness applies it. Return a promise to keep the change pending; throw or reject to roll it back |
onEventChangeResult | Called with each result after the harness records it |
applyChange | Applies a saved change to your events. The default, applyCanonicalChange, needs canonical events; pass your own for your app's records |
createEvent | The record a created event gets, from the change and a free new-<n> ID; the proposed placement goes over it. { id, title: "New event", data: null } by default |
readOnly | Renders Root without onEventChange |
messages | Renders <Calendar.Announcer messages={messages} /> |
rootProps | Any other Root prop, such as policies or availability |
children | What Root renders instead of <Calendar.ActiveView /> |
wrapper | A component around the calendar, such as a provider |
strictMode | Renders in StrictMode |
container | Where to render; a new element in document.body by default, which unmounting removes |
identifierPrefix | useId's prefix |
The harness it returns:
| Member | Contents |
|---|---|
container | The element the calendar rendered into |
changes | Every change onEventChange received, in order |
results | Every onEventChangeResult call: { result, detail } |
diagnostics | What onDiagnosticsChange last reported |
events() | The events now, with every saved change applied |
state(), meta() | useCalendarState() and useCalendarMeta(), read from inside Root |
pendingChanges() | useCalendarPendingChanges(): changes still validating, saving or awaiting events |
actions, act(callback) | The calendar's actions, and a way to call them inside act |
propose(proposal) | proposeEventChange inside act; resolves with its result once it settles |
setEvents, setNow, rerender | Change the events, the fixed now, or the options that apply after the first render |
unmount() | Unmounts it; cleanup() unmounts every one |
setNow takes exact text, a wall time on the environment's zone, or a time such as "10:00" on
the calendar's current date. rerender changes the events, the handlers, children, messages,
resources, readOnly and rootProps; the view, the environment, the wrapper and the container
set up the first render. Change the view or date through act, as a person would:
await calendar.act((actions) => actions.setView("day")). Change the locale, week rules or
direction through rootProps: await calendar.rerender({ rootProps: { locale: "de-DE" } }).
Name the harness type with CalendarHarnessFor<typeof Calendar>.
Saves that take time
When onEventChange returns a promise, the calendar shows the change as pending until it settles.
Hold the promise in the test to check what the calendar shows meanwhile.
test("a slow save stays pending, then settles", async () => {
let finish = () => {};
const calendar = await renderCalendar(Calendar, {
environment,
events: [standup],
onEventChange: () => new Promise<void>((resolve) => (finish = resolve)),
});
// Propose through act without waiting for the save, which the test holds.
let saving: ReturnType<typeof calendar.actions.proposeEventChange> | undefined;
await calendar.act((actions) => {
saving = actions.proposeEventChange({
cause: "move",
eventId: "Standup",
proposed: { kind: "timed", start: environment.at("11:00"), end: environment.at("12:00") },
});
});
expect([...calendar.pendingChanges().values()].map(({ phase }) => phase)).toEqual(["saving"]);
await calendar.act(() => finish());
expect((await saving)?.status).toBe("committed");
expect(calendar.pendingChanges().size).toBe(0);
});Find what the calendar shows
Queries find events through the calendar's own events, by ID, title or start, so they don't
depend on the text a view renders or its language. Cells are found by the attributes every
registry view carries for the pointer layer: data-event-id on events, data-date on day cells
and time columns, data-all-day on a time grid's all-day cells, data-axis="inline" on a
timeline's time strips, and data-resource-id on a resource's column or row. A view of your own
that follows the same contract works the same way: a
data-date element without data-all-day counts as a time column, and in a time grid, events sit
inside their column.
test("queries find events by title, and cells by date", async () => {
const calendar = await renderCalendar(Calendar, { environment, events: [standup] });
const event = calendar.getEvent("Standup");
expect(calendar.getTimeColumn("2026-10-08").contains(event)).toBe(true);
expect(calendar.queryEvent("Lunch")).toBeNull();
// A slot exists only while it has focus, so the keyboard driver focuses it.
await calendar.keyboard.focusCell({ date: "2026-10-08", time: "14:00" });
expect(document.activeElement?.getAttribute("aria-label")).toBe("Thursday, October 8 at 2:00 PM");
});| Query | Returns |
|---|---|
getEvent(event) | The first element showing an event |
getAllEvents(event) | Every element showing it: one per part the view draws, such as a time grid's day, a month's week row, a resource's column |
queryEvent(event) | The first element, or null when the view doesn't show it |
getDayCell(date, { resourceId? }) | A time grid's all-day cell, or else a month's day |
getTimeColumn(date, { resourceId? }) | The column of a date's hours, or its strip along a timeline row |
getResource(resource) | The element a resource's column or row carries its ID on; resource is an ID, null for the unassigned row, or { title } |
placementOf(event) | Where it shows now, pending changes included, as text: range, wallRange, isPending |
event is an ID or a title as text (an ID wins), or { id }, { title } or { start }. A
start is a date for an all-day event, a date and time for a floating one, or a time for a timed
one, exact or on the calendar's wall clock. The get queries throw a CalendarQueryError that
lists the calendar's events when they find nothing, and every query throws when it finds several
events. placementOf gives text, such as "2026-10-09T08:30:00Z/2026-10-09T09:30:00Z", which
reads plainly in a failing assertion; wallRange gives the same ends on the display zone's clock,
to the minute.
Keyboard
calendar.keyboard presses keys on the focused element inside act, as a person would, so it
works in jsdom, happy-dom and a browser alike, and through every check your views make. Focus
goes through the calendar's own focus, as arrow keys move it, so a slot, which renders only while
it has focus, can take it too.
test("Space, arrow keys and Enter move the standup a day on and half an hour later", async () => {
const calendar = await renderCalendar(Calendar, {
environment,
events: [standup],
messages: calendarAnnouncements,
});
// Focus the event, Space, two slots down and a day across, then Enter.
await calendar.keyboard.moveEvent("Standup", { across: 1, down: 2 });
await calendar.settled();
expect(calendar.placementOf("Standup").wallRange).toBe("2026-10-09T09:30/2026-10-09T10:30");
expect(calendar.lastAnnouncement()).toBe("Moved Standup to Friday, October 9, 9:30 – 10:30 AM.");
expect(calendar.results[0]?.detail.input).toBe("keyboard");
});| Method | Keys |
|---|---|
focusEvent(event, { date?, resourceId? }) | Focuses the event's part on date, by default the day it starts or the first day shown, in resourceId |
focusCell({ date, time?, resourceId? }) | Focuses a day, or a slot with time |
press(key, modifiers?), arrows({ up, down, left, right }) | Any key on whatever has focus, or arrow keys by count |
step({ across, down }) | Arrow keys by direction |
grab(), grabEdge() | Space picks up the focused event or starts a create; Shift+Space picks up its end, and again its start |
drop(), cancel() | Enter and Escape |
moveEvent(event, { across?, down?, date?, resourceId? }) | Focus, Space, the steps, Enter |
resizeEvent(event, { edge?, across?, down?, date?, resourceId? }) | Focus, Shift+Space (twice for the start), the steps, Enter |
createEvent(cell, steps?) | Focus the cell, Space, the steps, Enter |
The focus methods throw when the view doesn't show what they name, and leave the calendar's focus
as it was. A step across goes toward the end of the line: Arrow Right in a left-to-right
calendar, Arrow Left in a right-to-left one, from Root's direction. A step down is Arrow
Down; negative steps go back and up. What a step is depends on the view:
| View | across | down |
|---|---|---|
| Week, day | A day | A slot, 15 minutes by default |
| Month | A day | A week |
| Resource columns | A column: the next resource, then the next day | A slot |
| Resource timeline | A slot, or a day over days | The next resource |
Wait for saves
Drivers return once their keys or moves are handled, not when the change is saved. settled()
waits until no change is pending: every proposal validated, saved, and in the events. It gives up
after five seconds of real time, and says which changes are still pending. It doesn't advance fake
timers, so a save that waits on one needs them advanced first.
announcements() lists what the Announcer said, oldest first, a message said twice included,
and lastAnnouncement() what it says now, with Intl's thin and no-break spaces made plain.
Render one with the messages option, or your own in children: only the one mounted last
speaks, and the harness follows whichever does. normalizeText(text) makes your own text plain
the same way.
Pointer and touch
calendar.pointer drags with Pointer Events at measured points, as a mouse, a pen or a finger
would. It needs a page that's laid out and real timers, so it runs in a browser:
import { defineConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
});test("a mouse moves the standup to Friday at 11:00", async () => {
const calendar = await renderCalendar(Calendar, {
// Give the calendar a height, as the app's layout does.
children: (
<Calendar.Frame className="flex h-dvh flex-col">
<Calendar.Viewport className="min-h-0 flex-1">
<Calendar.ActiveView />
</Calendar.Viewport>
</Calendar.Frame>
),
events: [standup],
});
await calendar.pointer.moveEvent("Standup", { date: "2026-10-09", time: "11:00" });
await calendar.settled();
expect(calendar.placementOf("Standup").range).toBe("2026-10-09T11:00:00Z/2026-10-09T12:00:00Z");
});| Method | Drag |
|---|---|
moveEvent(event, { date, time?, resourceId? }) | By the event's middle, so it starts at time on date; without time, by whole days, keeping its time. It stays in its resource unless resourceId names another |
resizeEvent(event, { date, time?, edge? }) | Its end (or start) handle to time, or to date in a view that resizes by days, as a timeline over days does |
createEvent(from, to) | Across empty slots or days, both included |
drag(from, to) | From one element or point to another |
Each takes { pointerType }: "mouse" by default, "pen", or "touch", which holds still
until the long press starts a drag. The press and the drop must be on screen: the drivers scroll
the event into view, and throw when its middle is covered or when the drop point isn't on its cell,
so make the calendar tall enough. Where they let go is measured again after each frame, so a view
that auto-scrolls under the pointer still gets the drop where it was meant. The repository runs
these drivers on its views in Chromium on every pull request, and in Firefox and WebKit, WebKit on
temporal-polyfill, in the full gate.
What jsdom can't do
jsdom and happy-dom don't lay pages out: every element is an empty box at the top left, and nothing scrolls. So in them:
- the pointer drivers throw a
PointerLayoutErrorthat says to use a browser; - views measure nothing, so anything that depends on sizes, such as virtualized resource columns past the first screen, auto-scroll at an edge, or which part of a scrolling view is visible, isn't there to test;
- CSS doesn't apply: hidden, clipped and focus-visible states are classes, not what's shown.
Keyboard drivers, queries, the harness, saves and announcements work in all three.
Fixtures
Fixtures are ready datasets for contract tests: tests that every view, or your custom one, should
pass on the same data. Each is a function that builds new records, with the environment,
resources and Root props it's meant for, so spreading it into the harness's options renders it.
Merge rootProps when you pass your own. Their events are canonical, with data: { group }, so
type your calendar with FixtureEvent and FixtureResource.
import { dstWeekFixture, type FixtureEvent } from "@moltenship/calendar-testing";
const FixtureCalendar = createCalendar<FixtureEvent>()({
eventAdapter: createCanonicalEventAdapter<{ readonly group: string }>(),
views: [createWeekView<FixtureEvent>()],
});
test("a move a day on keeps an event's wall time across the clocks going back", async () => {
// New York's week of Sunday 1 November 2026, with a 09:00 standup every day.
const calendar = await renderCalendar(FixtureCalendar, { ...dstWeekFixture(), view: "week" });
// `d6` is Saturday's standup; a day on is Sunday, after the change.
await calendar.keyboard.moveEvent("d6", { across: 1 });
await calendar.settled();
expect(calendar.placementOf("d6").wallRange).toBe("2026-11-01T09:00/2026-11-01T09:30");
});| Fixture | Data |
|---|---|
denseDayFixture() | Thursday 8 October 2026 in UTC: 30 timed events, from back-to-back half hours that overlap nothing to five at once at 13:00 and a staircase, two across midnight, and 3 all-day events |
dstWeekFixture({ change? }) | New York's week to Sunday 1 November 2026, when the clocks go back, or with change: "spring" to 8 March, when they go forward: a 09:00 standup a day, an event in the changing hour, one across the change |
resourceDayFixture() | A team in three zones, one with hours of its own, and rooms under a parent that opens expanded; events on one resource, on several, and on none; working hours from 9 to 6 |
multiZoneSeriesFixture() | Series in Berlin, Los Angeles and Tokyo from 19 October 2026, across Berlin's and Los Angeles' DST changes a week apart |
Each fixture's JSDoc lists its events and resources by ID, title and time. Those, and the counts, are part of the API: they change only in a major version, so tests can name them.
multiZoneSeriesFixture gives series masters, series, not events: a calendar shows a series
once it's expanded into occurrences, as your app does with @moltenship/calendar-recurrence.
import {
createCanonicalRecurrenceAdapter,
createOccurrenceAdapter,
createOccurrenceExpander,
type RecurrenceOccurrence,
} from "@moltenship/calendar-recurrence";
import { multiZoneSeriesFixture, type FixtureSeriesEvent } from "@moltenship/calendar-testing";
type Occurrence = RecurrenceOccurrence<FixtureSeriesEvent, string>;
const SeriesCalendar = createCalendar<Occurrence>()({
eventAdapter: createOccurrenceAdapter(createCanonicalEventAdapter<{ readonly group: string }>()),
views: [createWeekView<Occurrence>()],
});
test("Berlin's standup is an hour later in UTC once its clocks go back; Los Angeles' isn't yet", async () => {
const { environment, series } = multiZoneSeriesFixture();
const week = {
end: Temporal.PlainDate.from("2026-11-02"),
start: Temporal.PlainDate.from("2026-10-26"),
};
const { occurrences } = createOccurrenceExpander(
createCanonicalRecurrenceAdapter<{ readonly group: string }>(),
).expand(series, { range: week, timeZone: environment.timeZone });
const calendar = await renderCalendar(SeriesCalendar, {
environment,
events: occurrences,
// Occurrences are views of their series: save changes through the recurrence edits instead.
readOnly: true,
rootProps: { defaultAnchorDate: week.start },
view: "week",
});
expect(calendar.placementOf({ start: "2026-10-26T08:00:00Z" }).title).toBe("Standup (Berlin)");
expect(calendar.placementOf({ start: "2026-10-27T17:00:00Z" }).title).toBe(
"Review (Los Angeles)",
);
});Server rendering and hydration
hydrateCalendar takes the same options. It renders the calendar with renderToString, puts the
markup in a container and hydrates it, then returns the same harness, with the markup as
serverMarkup. When the client renders something else, it throws a HydrationMismatchError with
React's reasons: a recoverable error, or a warning such as an attribute that differs, which React
only logs. It watches the console for those while hydration runs, so test with React's development
build, which logs them. A component that throws on the client fails the test with its error, and
nothing stays mounted. A fixed now renders today's date on the server too, so a calendar with
one hydrates with nothing to patch.
test("the week renders on the server and hydrates without a mismatch", async () => {
// Throws a HydrationMismatchError when the client renders something else.
const calendar = await hydrateCalendar(Calendar, { environment, events: [standup] });
expect(calendar.serverMarkup).toContain("Standup");
});The server render runs in the test's DOM environment, so code that checks for window takes its
browser branch there too. To test a real server boundary, render with react-dom/server in a
Node environment and hydrate in another, as the repository's SSR fixture does.
Last updated on