@uniflowed/react-testing
type
EventInit
export type EventInit = { readonly [string]: mixed };
What a caller wants the event to carry.
An indexer, because which properties are meaningful is decided by the event's *interface* and the interface is decided by the name — { key: "Escape" } for a keydown, { clientX: 40 } for a pointermove — and the name is a string a caller computes. There is no type that says "the initialisers of whichever interface name maps to", so this says what is true: names in, and what each one means is the DOM's business.
function
dispatch
export function dispatch(target: EventTarget, name: string, init?: EventInit): boolean { ... }
Dispatch one event, inside act.
Returns whether the event ran to completion — false when a handler called preventDefault, which is what dispatchEvent reports and what a test asserting "the form did not submit" needs.
type
Firer
export type Firer = (target: EventTarget, init?: EventInit) => boolean;
One event name's firer: the event that name stands for, at this target.
type
FireEvent
export type FireEvent = {
(target: EventTarget, name: string, init?: EventInit): boolean,
// The clipboard.
readonly copy: Firer,
readonly cut: Firer,
readonly paste: Firer,
// An input method editor composing a character.
readonly compositionEnd: Firer,
readonly compositionStart: Firer,
readonly compositionUpdate: Firer,
// Keys.
readonly keyDown: Firer,
readonly keyPress: Firer,
readonly keyUp: Firer,
// Focus. `focus` and `blur` are paired with the bubbling forms React listens for; see `ALSO_BUBBLES`.
readonly blur: Firer,
readonly focus: Firer,
readonly focusIn: Firer,
readonly focusOut: Firer,
// Forms.
readonly beforeInput: Firer,
readonly change: Firer,
readonly input: Firer,
readonly invalid: Firer,
readonly reset: Firer,
readonly submit: Firer,
// The mouse.
readonly auxClick: Firer,
readonly click: Firer,
readonly contextMenu: Firer,
readonly dblClick: Firer,
readonly mouseDown: Firer,
readonly mouseEnter: Firer,
readonly mouseLeave: Firer,
readonly mouseMove: Firer,
readonly mouseOut: Firer,
readonly mouseOver: Firer,
readonly mouseUp: Firer,
// Dragging.
readonly drag: Firer,
readonly dragEnd: Firer,
readonly dragEnter: Firer,
readonly dragLeave: Firer,
readonly dragOver: Firer,
readonly dragStart: Firer,
readonly drop: Firer,
// Pointers, which is what a component that works under both a mouse and a finger listens for.
readonly gotPointerCapture: Firer,
readonly lostPointerCapture: Firer,
readonly pointerCancel: Firer,
readonly pointerDown: Firer,
readonly pointerEnter: Firer,
readonly pointerLeave: Firer,
readonly pointerMove: Firer,
readonly pointerOut: Firer,
readonly pointerOver: Firer,
readonly pointerUp: Firer,
// Touch.
readonly touchCancel: Firer,
readonly touchEnd: Firer,
readonly touchMove: Firer,
readonly touchStart: Firer,
// Scrolling and the wheel.
readonly scroll: Firer,
readonly scrollEnd: Firer,
readonly wheel: Firer,
// Selection.
readonly select: Firer,
readonly selectionChange: Firer,
// Media.
readonly abort: Firer,
readonly canPlay: Firer,
readonly canPlayThrough: Firer,
readonly durationChange: Firer,
readonly emptied: Firer,
readonly encrypted: Firer,
readonly ended: Firer,
readonly loadStart: Firer,
readonly loadedData: Firer,
readonly loadedMetadata: Firer,
readonly pause: Firer,
readonly play: Firer,
readonly playing: Firer,
readonly progress: Firer,
readonly rateChange: Firer,
readonly seeked: Firer,
readonly seeking: Firer,
readonly stalled: Firer,
readonly suspend: Firer,
readonly timeUpdate: Firer,
readonly volumeChange: Firer,
readonly waiting: Firer,
// Loading a resource.
readonly error: Firer,
readonly load: Firer,
// Animations and transitions.
readonly animationCancel: Firer,
readonly animationEnd: Firer,
readonly animationIteration: Firer,
readonly animationStart: Firer,
readonly transitionCancel: Firer,
readonly transitionEnd: Firer,
readonly transitionRun: Firer,
readonly transitionStart: Firer,
// A dialog and a disclosure.
readonly cancel: Firer,
readonly close: Firer,
readonly toggle: Firer,
// The window and the document.
readonly beforeUnload: Firer,
readonly hashChange: Firer,
readonly message: Firer,
readonly messageError: Firer,
readonly offline: Firer,
readonly online: Firer,
readonly pageHide: Firer,
readonly pageShow: Firer,
readonly popState: Firer,
readonly readyStateChange: Firer,
readonly resize: Firer,
readonly storage: Firer,
readonly unload: Firer,
readonly visibilityChange: Firer,
...
};
fireEvent.click(element), and one entry per event name.
# Why the names are written out
This was a Proxy over a function, answering to any property at all and dispatching whatever it was asked for lowercased. That is the shortest thing to write and it cannot be typed, so the published type was any: fireEvent.clcik(button) was not a misspelling anybody's checker would find, and neither was passing something that is not an EventTarget. A hole like that in the package whose purpose is testing *typed* components is the same argument ubugeeei-prod/uf#381 made about the queries, which is why screen's thirty-six names are written out too.
Two shapes were tried against the proxy and neither works, for reasons no spelling fixes. An indexer — readonly [string]: Firer beside the call signature — Flow declines, and is right to: the value is a function, a function has name, length, call, apply and bind, and the trap handed those back as themselves because property in base was true for them. None of the five is a DOM event, so the lie was unreachable from a real call, but a type is not something to be right about on average. A written-out table over the proxy failed earlier still — "functions without statics are not compatible with objects" — because a Proxy over a function *is* a function, and Flow will not treat one carrying no statics as an object with properties, whatever those properties are.
So the value changed rather than the annotation. The names below are real properties on a real function, attached inside a builder the way @uniflowed/test's describe attaches its modifiers, and the type is the same list written down: Flow has no template literal types, so fireEvent's hundred-odd names have to be listed for the type to exist at all. The list is the one React and Testing Library publish, so a suite being ported already has these spellings.
# What that costs
fireEvent.somethingNobodyListedYet(el) stops working, and a table stops answering to the hundred-and-sixth name the way a proxy never did. The escape hatch is the call signature this has always had: fireEvent(target, "somethingnobodylistedyet", init) takes a computed name and always did — it is what the proxy called into — and dispatch is exported for the same reason. So nothing became impossible; one spelling of it became a name a reader can look up.
The table also takes the camel-cased spelling only. The proxy lowercased whatever it was handed, so fireEvent.keydown and fireEvent.KeyDown worked as well as fireEvent.keyDown; the first of those is the DOM's own name and the loss is real, though nothing in this repository or in a suite written against Testing Library uses it.
The type is inexact, and has to be: a function carries name, length, call, apply and bind, and an exact object type refuses one for exactly that reason. Inexactness costs nothing that matters here — Flow still reports a read of a property the type does not list, which is what makes fireEvent.clcik an error.
variable
fireEvent
export const fireEvent: FireEvent = firing();
Dispatch one event, by name. See [FireEvent].
variable
userEvent
export const userEvent = {
/** Press and release, with the events a real click produces, in order. */
async click(element: Element, init?: EventInit): Promise<void> {
if (isDisabled(element)) {
return;
}
dispatch(element, "pointerdown", init);
dispatch(element, "mousedown", init);
focus(element);
dispatch(element, "pointerup", init);
dispatch(element, "mouseup", init);
dispatch(element, "click", init);
await settle();
},
/** Two clicks and a dblclick. */
async dblClick(element: Element): Promise<void> {
await userEvent.click(element);
await userEvent.click(element);
dispatch(element, "dblclick");
await settle();
},
/**
* Type into a control, one character at a time.
*
* Per character rather than setting the value once, because a component
* that reacts to each keystroke — a search box that filters, a field that
* rejects a character — behaves differently, and the difference is the thing
* usually being tested.
*/
async type(element: Element, text: string): Promise<void> {
focus(element);
for (const character of text) {
const { key, code, text: printable } = describeKey(character);
dispatch(element, "keydown", { key, code });
// Nothing is typed into an element that shows no value; see `keyboard`
// for what that used to do instead.
const current = displayValue(element);
if (printable != null && printable !== "\n" && current != null) {
setValue(element, `${current}${printable}`);
dispatch(element, "input", { data: printable });
}
dispatch(element, "keyup", { key, code });
}
await settle();
},
/** Empty a control, the way selecting everything and deleting would. */
async clear(element: Element): Promise<void> {
focus(element);
setValue(element, "");
dispatch(element, "input", {});
await settle();
},
/** Press keys at whatever has focus. Named keys go in braces: `{Enter}`. */
async keyboard(sequence: string): Promise<void> {
const target = documentOf().activeElement ?? bodyOf();
for (const token of parseKeys(sequence)) {
const { key, code, text } = describeKey(token);
dispatch(target, "keydown", { key, code });
// `displayValue` rather than `target.value !== undefined`.
//
// The two agree about every control a person can type into, and differ
// about `<button>`, `<option>`, `<progress>` and the rest of the
// elements that have a `value` property without showing one: pressing
// Space at a focused button used to write `button.value = " "` and
// dispatch an `input` event at it, which no browser does — Space on a
// button is a click. Nothing in this repository's suite depended on it,
// and `ui.test.js` presses Space at a switch on the way past.
const current = displayValue(target);
if (text != null && text !== "\n" && current != null) {
setValue(target, `${current}${text}`);
dispatch(target, "input", { data: text });
}
dispatch(target, "keyup", { key, code });
}
await settle();
},
/** Move focus the way the Tab key does. */
async tab(options?: {| readonly shift?: boolean |}): Promise<void> {
const order = tabbable();
if (order.length === 0) {
return;
}
const active = documentOf().activeElement;
// `indexOf` needs an element; a document with nothing focused is the same
// "not in the order" that `indexOf` answers `-1` to, said in front.
const at = active instanceof HTMLElement ? order.indexOf(active) : -1;
const shift = options?.shift ?? false;
const next =
at < 0
? shift
? order[order.length - 1]
: order[0]
: order[(at + (shift ? -1 : 1) + order.length) % order.length];
focus(next);
await settle();
},
/** Choose options in a select. */
async selectOptions(element: Element, values: string | $ReadOnlyArray<string>): Promise<void> {
const wanted = typeof values === "string" ? [values] : values;
// Only a `select` has options; anything else has none, which is what
// `select.options ?? []` used to say. The events are dispatched either
// way, because a component listening for `change` on something that is not
// a select is a component under test and not this function's business.
if (element instanceof HTMLSelectElement) {
for (const option of Array.from(element.options)) {
option.selected = wanted.includes(option.value);
}
}
dispatch(element, "input");
dispatch(element, "change");
await settle();
},
/** Move focus away, which is what makes a blur-validated field validate. */
async tabAway(element: Element): Promise<void> {
dispatch(element, "blur");
if (element instanceof HTMLElement) {
element.blur();
}
await settle();
},
};
What a person did, rather than what the DOM emitted.
Every method is async because that is what makes a test written with it correct as it grows: the moment an interaction leads to something awaited — a fetch, a transition, a lazily loaded panel — a synchronous helper would return before the result existed, and the test would need a sleep. Awaiting from the start means adding that behaviour later changes nothing.
They take Element because that is what a query returns. The pieces only HTML elements can do — focus, blur and value mutation — narrow at the point where they are needed.
type
Matcher
export type Matcher = string | RegExp | ((content: string, element: Element) => boolean);
What a query will accept as a description of the thing to find.
type
MatcherOptions
export type MatcherOptions = {|
/** `false` matches a substring, case-insensitively. Defaults to `true`. */
readonly exact?: boolean,
|};
How exactly a string matcher has to match.
function
normalize
export function normalize(text: string): string { ... }
Collapse whitespace the way a browser does when it lays text out.
A test asks for "Save changes"; the markup may hold a newline and eleven spaces between the two words because that is how the JSX was indented. The reader sees one space, so the query matches one space.
function
roleOf
export function roleOf(element: Element): string | null { ... }
This element's role: what it says, or what its tag implies.
function
accessibleName
export function accessibleName(element: Element): string { ... }
The name a screen reader would announce.
The element aria-labelledby points at, then aria-label, then a label element, then the element's own text if its role is named by its content, then title, then nothing. Not the whole specification — that is a document of its own — but the order that decides almost every real case.
# Why aria-labelledby is first
It used to be second, which is the order a reader guesses and the opposite of the one accname specifies: an element carrying both is named by what aria-labelledby points at, and aria-label is the fallback for when it points at nothing. So
<button aria-label="Close" aria-labelledby="title">…</button>
was announced by every browser as whatever #title says and found by this query as "Close". Nothing in this repository writes both at once, which is why it never bit — a component that did would have had a passing test and a reader hearing something else.
"Points at nothing" is two cases and both fall through: an id that resolves to no element, and one that resolves to an element with no text. The second is what makes the reordering safe rather than merely correct — without it a label pointing at an empty span would name the button the empty string and never reach the aria-label underneath it.
# Why the last resort is a role and not the text
textOf(element) used to be the fallback for anything, and most elements are not named by their contents. A <table> with no <caption> was therefore called every cell in it — getByRole("table", { name: "People" }) asking whether the name was "People Name Born Ada Lovelace 1815 …" — and so were <figure>, <fieldset>, <section> and every other container whose name ARIA says comes from its author. Only the roles in NAME_FROM_CONTENT are named by what is inside them; for the rest HTML-AAM's next step is the title attribute and the one after that is no name at all, which is what this returns. A query for { name: "" } finds such an element and a query for its contents does not, which is the pair the fix is for.
type
RenderResult
export type RenderResult = {|
/** The element the tree was mounted into. */
readonly container: Element,
/** The document body, which is where a portal ends up. */
readonly baseElement: Element,
/** Render different elements into the same container. */
readonly rerender: (ui: React.Node) => void,
/** Take the tree down and remove the container. */
readonly unmount: () => void,
/** The container's markup, for a failure message. */
readonly asFragment: () => string,
|};
What render hands back.
function
render
export function render(ui: React.Node, options?: {| readonly container?: Element |}): RenderResult { ... }
Render ui into a fresh container in the document body.
Anything still mounted from an earlier render is taken down first. screen queries the whole document, so a tree left over from the previous test would make "there is exactly one Save button" false for reasons that have nothing to do with the test being read.
function
cleanup
export function cleanup(): void { ... }
Unmount everything this module has mounted.
function
act
export function actively<T>(body: () => T): T { ... }
Run body, letting React flush everything it queues.
Exported because a test that changes state outside an event — a timer firing, a promise settling — has to tell React that the change happened, and this is how.
function
waitFor
export function waitFor<T>(
body: () => T | Promise<T>,
options?: {| readonly timeout?: number, readonly interval?: number |},
): Promise<T> { ... }
Wait until body stops throwing, or give up.
Polling rather than observing mutations, because what a test waits for is usually not a DOM change at all — it is a promise resolving, a fetch settling, a timer firing — and a mutation observer sees none of those.
type
Queries
export type Queries = {|
readonly getByText: (matcher: Matcher, options?: MatcherOptions) => Element,
readonly getAllByText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
readonly queryByText: (matcher: Matcher, options?: MatcherOptions) => Element | null,
readonly queryAllByText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
readonly findByText: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
readonly findAllByText: (matcher: Matcher, options?: MatcherOptions) => Promise<Array<Element>>,
readonly getByRole: (role: string, options?: RoleOptions) => Element,
readonly getAllByRole: (role: string, options?: RoleOptions) => Array<Element>,
readonly queryByRole: (role: string, options?: RoleOptions) => Element | null,
readonly queryAllByRole: (role: string, options?: RoleOptions) => Array<Element>,
readonly findByRole: (role: string, options?: RoleOptions) => Promise<Element>,
readonly findAllByRole: (role: string, options?: RoleOptions) => Promise<Array<Element>>,
readonly getByLabelText: (matcher: Matcher, options?: MatcherOptions) => Element,
readonly getAllByLabelText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
readonly queryByLabelText: (matcher: Matcher, options?: MatcherOptions) => Element | null,
readonly queryAllByLabelText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
readonly findByLabelText: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
readonly findAllByLabelText: (
matcher: Matcher,
options?: MatcherOptions,
) => Promise<Array<Element>>,
readonly getByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Element,
readonly getAllByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
readonly queryByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Element | null,
readonly queryAllByPlaceholderText: (
matcher: Matcher,
options?: MatcherOptions,
) => Array<Element>,
readonly findByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
readonly findAllByPlaceholderText: (
matcher: Matcher,
options?: MatcherOptions,
) => Promise<Array<Element>>,
readonly getByTestId: (matcher: Matcher, options?: MatcherOptions) => Element,
readonly getAllByTestId: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
readonly queryByTestId: (matcher: Matcher, options?: MatcherOptions) => Element | null,
readonly queryAllByTestId: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
readonly findByTestId: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
readonly findAllByTestId: (matcher: Matcher, options?: MatcherOptions) => Promise<Array<Element>>,
readonly getByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Element,
readonly getAllByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
readonly queryByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Element | null,
readonly queryAllByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
readonly findByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
readonly findAllByDisplayValue: (
matcher: Matcher,
options?: MatcherOptions,
) => Promise<Array<Element>>,
|};
The queries available on screen and on within(element).
Read down one column and the four questions of the module comment are the four return types: getBy… is an Element because it throws rather than hand back nothing, queryBy… is Element | null because its whole purpose is asking about absence, and the findBy… pair are promises because they wait.
variable
screen
export const screen: Queries = queriesFor(() => bodyOf());
Queries over the whole document.
The document rather than the rendered container, because a dialog, a tooltip and a toast are rendered into a portal outside it — and a test that could not see them would be unable to assert on the components most likely to have a bug.
function
within
export function within(element: Element): Queries { ... }
The same queries, restricted to one element's subtree.