The UI extensions SDK provides utilities for testing your UI extensions, including rendering components, querying rendered output, mocking, triggering event handlers, waiting for asynchronous updates, and debugging the rendered component tree.
To use these utilities, it’s recommended to use Vitest, but you can use any test runner you prefer. The code snippets on this page assume that Vitest is being used and reference Vitest globals such as describe() and expect().
Basic usage
The primary entry point is the createRenderer() function. This function requires an extension point location (e.g., 'crm.record.tab'), and returns a renderer object with a render() method and utilities for querying and interacting with the rendered output. Mocks for supported React hooks and the Extension Point API are automatically provided based on the specified extension point location.
import { createRenderer } from '@hubspot/ui-extensions/testing';
import { Button, ButtonRow } from '@hubspot/ui-extensions';
const { render, find } = createRenderer('crm.record.tab');
render(
<>
<ButtonRow>
<Button variant="primary">Click me!</Button>
</ButtonRow>
</>
);
const button = find(Button);
expect(button.props.variant).toEqual('primary');
expect(button.text).toEqual('Click me!');
The extension point location (e.g., 'crm.record.tab') is required because it determines the shape of the context, actions, and runServerlessFunction mocks that are automatically provided to your components during testing. Valid extension point locations include:
'crm.record.tab'
'crm.record.sidebar'
'crm.preview'
'helpdesk.sidebar'
'settings'
'home'
Querying components
findByTestId
findByTestId(component, testId)
Finds a component by its testId prop. This is the recommended way to precisely find rendered components. This method throws an error if no match is found.
The non-throwing variant is maybeFindByTestId().
const { render, findByTestId } = createRenderer('crm.record.tab');
render(
<>
<Button variant="primary" testId="submit-button">
Submit
</Button>
<Button variant="secondary" testId="cancel-button">
Cancel
</Button>
</>
);
// Find by test ID - fast and unambiguous
const submitButton = findByTestId(Button, 'submit-button');
expect(submitButton.props.variant).toEqual('primary');
It’s recommended to use test IDs because they provide a stable, explicit way to identify components in your tests. Unlike matchers based on props or text content, test IDs won’t break when you change the component’s appearance or behavior. This makes your tests more maintainable and less brittle.
find
find(component, matcher?)
Finds the first descendant element that matches the given component and an optional matcher. Throws an error if no match is found.
The non-throwing variant is maybeFind().
const { render, find } = createRenderer('crm.record.tab');
render(
<>
<ButtonRow>
<Button variant="secondary">Button 1</Button>
<Button variant="primary">Button 2</Button>
</ButtonRow>
</>
);
// Find any button
const button = find(Button);
// Find a button with specific props
find(Button, { variant: 'primary' })
// Find a button using a predicate function
find(
Button,
(node) => node.props.variant === 'primary'
)
// Find a parent element and then query within it
const buttonRow = find(ButtonRow);
buttonRow.find(Button); // Find the first Button nested in ButtonRow
findAll
findAll(component, matcher?)
Finds all descendant elements that match the given component and an optional matcher. Returns an array of all matching elements, or an empty array if no matches are found.
const { render, findAll } = createRenderer('crm.record.tab');
render(
<>
<Button variant="secondary">Button 1</Button>
<Button variant="primary">Button 2</Button>
</>
);
const buttons = findAll(Button);
expect(buttons).toHaveLength(2);
expect(buttons[0].props.variant).toEqual('secondary');
expect(buttons[1].props.variant).toEqual('primary');
findChild
findChild(component, matcher?)
Finds the first direct child element that matches the given component and an optional matcher. Only searches immediate children, not descendants. Throws an error if no match is found.
The non-throwing variant is maybeFindChild().
const { render, find, findChild } = createRenderer('crm.record.tab');
render(
<>
<ButtonRow>
<Button variant="primary">Click me!</Button>
</ButtonRow>
</>
);
// Find ButtonRow as a direct child of root
const buttonRow = findChild(ButtonRow);
// Find Button as a direct child of ButtonRow
const button = buttonRow.findChild(Button);
expect(button.props.variant).toEqual('primary');
findAllChildren
findAllChildren(component, matcher?)
Finds all direct child elements that match the given component and an optional matcher. Only searches immediate children, not descendants. This combines the behavior of findAll() (returning all matches) with findChild() (searching only direct children).
const { render, find, findAllChildren } = createRenderer('crm.record.tab');
render(
<>
<ButtonRow>
<Button variant="secondary">Button 1</Button>
<Button variant="primary">Button 2</Button>
</ButtonRow>
<Alert title="My Alert" />
<Alert title="Another Alert" />
</>
);
// Find all direct children of root that are Alert components
const alerts = findAllChildren(Alert);
expect(alerts).toHaveLength(2);
expect(alerts[0].props.title).toEqual('My Alert');
expect(alerts[1].props.title).toEqual('Another Alert');
// Find all direct children of ButtonRow that are Button components
const buttonRow = find(ButtonRow);
const buttons = buttonRow.findAllChildren(Button);
expect(buttons).toHaveLength(2);
expect(buttons[0].props.variant).toEqual('secondary');
expect(buttons[1].props.variant).toEqual('primary');
maybeFind (non-throwing variants)
The following methods are non-throwing variants that return null if no match is found:
maybeFind(component, matcher?)
maybeFindChild(component, matcher?)
maybeFindByTestId(component, testId)
const { render, maybeFind, maybeFindByTestId } = createRenderer('crm.record.tab');
render(<Alert title="My Alert" testId="my-alert" />);
const button = maybeFind(Button); // Returns null instead of throwing
expect(button).toBeNull();
const missingButton = maybeFindByTestId(Button, 'submit-button'); // Returns null
expect(missingButton).toBeNull();
Element matchers
All query methods accept an optional parameter that can be either of the following:
- Props object: match elements with specific prop values.
const button = find(Button, { variant: 'primary' });
- Predicate function: match elements using a custom function.
const button = find(Button, (node) => {
return node.props.variant === 'primary' && node.text?.includes('Click');
});
Interacting with components
Accessing element properties
Rendered elements expose several properties:
const button = find(Button);
// Access props (with fragment props converted to RenderedFragmentNode)
button.props.variant; // 'primary'
// Access text content
button.text; // 'Click me!'
// Access component name
button.name; // 'Button'
// Access child nodes
button.childNodes; // Array of child nodes
// Node type
button.nodeType; // RenderedNodeType.Element
Triggering events
Use the trigger() method to invoke event handlers:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
};
return (
<Button variant="primary" onClick={handleClick}>
Clicked {count} times
</Button>
);
}
const { render, find } = createRenderer('crm.record.tab');
render(<Counter />);
expect(find(Button).text).toEqual('Clicked 0 times');
// Trigger the onClick event
find(Button).trigger('onClick');
expect(find(Button).text).toEqual('Clicked 1 times');
Async testing
Waiting for async updates
Use waitFor() to wait for asynchronous updates to the rendered output:
function AsyncCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
setCount((count) => count + 1);
}, 10);
}, []);
return <Button variant="primary">Count: {count}</Button>;
}
const { render, find, waitFor } = createRenderer('crm.record.tab');
render(<AsyncCounter />);
expect(find(Button).text).toEqual('Count: 0');
// Wait for the count to update
await waitFor(() => {
expect(find(Button).text).toEqual('Count: 1');
});
Timeout configuration
By default, waitFor() waits up to 1000ms. You can customize the timeout:
await waitFor(
() => {
expect(find(Button).text).toEqual('Count: 1');
},
{ timeoutInMs: 5000 }
);
If the condition is not met within the timeout, a WaitForTimeoutError is thrown.
Mocking
The testing SDK provides comprehensive mocking support for UI extensions to avoid making real API calls and being dependent on HubSpot data. The testing utilities make it easy to control the behavior of provided React hooks, extension point actions, and more.
When you create a renderer with createRenderer(extensionPointLocation), the testing SDK automatically creates appropriate mocks for the Extension Point API based on the location you specify. Default mock implementations with spies are provided for supported hooks such as useCrmProperties() and useAssociations() and Extension Point API actions such as actions.addAlert().
See the Mocking guide for detailed documentation on mocking, including:
- Automatic mocks based on extension point location
- Mocking
context, actions, and runServerlessFunction
- Available hook mocks (
useCrmProperties(), useAssociations())
- Advanced mock features (spy properties, resetting, etc.)
Working with fragments
Some components allow React nodes to be passed in as part of component props, and these props are treated as “fragment props.” Fragment props are automatically converted to RenderedFragmentNode objects:
const { render, find } = createRenderer('crm.record.tab');
render(
<Button
variant="primary"
overlay={
<List>
<Text>Item 1</Text>
<Text>Item 2</Text>
</List>
}
>
Click me!
</Button>
);
const button = find(Button);
// overlay prop is now a RenderedFragmentNode
const overlayFragment = button.props.overlay;
const listInOverlay = overlayFragment.find(List);
const texts = listInOverlay.findAll(Text);
expect(texts).toHaveLength(2);
Debugging
debugLog
Log a string representation of the rendered output:
const { render, debugLog, find } = createRenderer('crm.record.tab');
render(
<ButtonRow>
<Button variant="primary">Click me!</Button>
</ButtonRow>
);
// Debug the entire tree
debugLog('MY COMPONENT');
/*
Console output:
============
MY COMPONENT
<ButtonRow>
<Button variant="primary">
"Click me!"
</Button>
</ButtonRow>
============
*/
// Debug a specific element
find(Button).debugLog('MY BUTTON');
/*
Console output:
=========
MY BUTTON
<Button variant="primary">
"Click me!"
</Button>
=========
*/
toString
Get a string representation of a node:
const { render, getRootNode, find } = createRenderer('crm.record.tab');
render(
<ButtonRow>
<Button variant="primary">Click me!</Button>
</ButtonRow>
);
// Convert root to string
const rootStr = getRootNode().toString();
/*
rootStr:
`<ButtonRow>
<Button variant="primary">
"Click me!"
</Button>
</ButtonRow>`
*/
// Convert element to string
const buttonStr = find(Button).toString();
/*
buttonStr:
`<Button variant="primary">
"Click me!"
</Button>`
*/
getRootNode
Access the root node directly:
const { render, getRootNode } = createRenderer('crm.record.tab');
render(<Button variant="primary">Click me!</Button>);
const root = getRootNode();
root.childNodes; // Access all child nodes
root.text; // Access all text content
Type guards
The testing SDK provides type guard functions to help narrow TypeScript types when working with rendered nodes. These are useful when you need to perform type-specific operations on nodes.
isRenderedElementNode
Check if a node is a rendered element node:
import { createRenderer, isRenderedElementNode } from '@hubspot/ui-extensions/testing';
const { render, getRootNode } = createRenderer('crm.record.tab');
render(<Button variant="primary">Click me!</Button>);
const { childNodes } = getRootNode();
const firstChild = childNodes[0];
if (isRenderedElementNode(firstChild)) {
// TypeScript now knows firstChild is a RenderedElementNode
console.log(firstChild.name); // 'Button'
console.log(firstChild.props); // { variant: 'primary' }
}
isRenderedTextNode
Check if a node is a rendered text node:
import { createRenderer, isRenderedTextNode } from '@hubspot/ui-extensions/testing';
const { render, find } = createRenderer('crm.record.tab');
render(<Button>Click me!</Button>);
const button = find(Button);
const firstChild = button.childNodes[0];
if (isRenderedTextNode(firstChild)) {
// TypeScript now knows firstChild is a RenderedTextNode
console.log(firstChild.text); // 'Click me!'
}
isRenderedRootNode
Check if a node is a rendered root node:
import { createRenderer, isRenderedRootNode } from '@hubspot/ui-extensions/testing';
const { render, getRootNode } = createRenderer('crm.record.tab');
render(<Button variant="primary">Click me!</Button>);
const root = getRootNode();
if (isRenderedRootNode(root)) {
// TypeScript now knows root is a RenderedRootNode
console.log(root.nodeType); // RenderedNodeType.Root
}
isRenderedFragmentNode
Check if a node is a rendered fragment node:
import { createRenderer, isRenderedFragmentNode } from '@hubspot/ui-extensions/testing';
const { render, find } = createRenderer('crm.record.tab');
render(
<Button
overlay={
<>
<Text>Item 1</Text>
<Text>Item 2</Text>
</>
}
>
Click me!
</Button>
);
const button = find(Button);
const overlay = button.props.overlay;
if (isRenderedFragmentNode(overlay)) {
// TypeScript now knows overlay is a RenderedFragmentNode
const texts = overlay.findAll(Text);
console.log(texts.length); // 2
}
isMatch
Check if a node matches a specific component and optional matcher criteria. This is particularly useful when iterating over child nodes where you need to check the type and properties of each node.
The isMatch function is available in two ways:
- As a standalone exported function: useful for checking nodes when iterating over child nodes
- As a method on all rendered nodes: convenient for chaining from an already-found node
Standalone function
import { createRenderer, isMatch } from '@hubspot/ui-extensions/testing';
const { render, getRootNode } = createRenderer('crm.record.tab');
render(
<>
<Button variant="primary">Click me!</Button>
<Alert title="My Alert" />
Hello
</>
);
const { childNodes } = getRootNode();
const firstChild = childNodes[0];
// Check if it's a Button
if (isMatch(firstChild, Button)) {
// TypeScript now knows firstChild is a RenderedElementNode<ButtonProps>
console.log(firstChild.props.variant); // 'primary'
}
// Check if it's a Button with specific props
if (isMatch(firstChild, Button, { variant: 'primary' })) {
console.log('Found a primary button!');
}
// Check if it's a Button using a predicate function
if (isMatch(firstChild, Button, (node) => node.props.variant === 'primary')) {
console.log('Found a primary button!');
}
Node method
All rendered nodes (including RenderedRootNode, RenderedFragmentNode, RenderedElementNode, and RenderedTextNode) have an isMatch method:
const { render, find, getRootNode } = createRenderer('crm.record.tab');
render(
<>
<Button variant="primary">Click me!</Button>
<Alert title="My Alert" />
</>
);
// Check if a node matches using the method
const button = find(Button);
if (button.isMatch(Button, { variant: 'primary' })) {
console.log('This is a primary button!');
}
// Useful when iterating over child nodes
const root = getRootNode();
root.childNodes.forEach((child) => {
if (child.isMatch(Button, { variant: 'primary' })) {
console.log('Found a primary button!');
} else if (child.isMatch(Alert)) {
console.log('Found an alert!');
}
});
Error handling
The testing SDK throws specific errors for different scenarios:
InvalidComponentsError: when invalid component types are detected in the rendered output.
WaitForTimeoutError: when a waitFor() check doesn’t pass within the timeout period.
MissingEventFunctionError: when triggering an event that doesn’t have a handler.
InvalidEventFunctionError: when triggering an event whose handler is not a function.
ComponentNotFoundByTestIdError: when a component with the specified testId is not found in the rendered output.
ComponentMismatchedByTestIdError: when a testId exists but the component type doesn’t match the expected component.
DuplicateTestIdError: when attempting to render multiple components with the same testId.