Skip to main content
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.
Create a new renderer for each test to ensure test isolation. This prevents state from leaking between tests and makes your tests more reliable and maintainable.
import { createRenderer } from '@hubspot/ui-extensions/testing';
import { Button, ButtonRow } from '@hubspot/ui-extensions';

test('renders the correct primary button', () => {
  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().
test('finds button by test ID', () => {
  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().
test('finds buttons using various matchers', () => {
  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.
test('finds all buttons', () => {
  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().
test('finds direct children', () => {
  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).
test('finds all 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)
test('uses non-throwing find variants', () => {
  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.
test('finds button by props', () => {
  const { render, find } = createRenderer('crm.record.tab');
  render(<Button variant="primary">Click me!</Button>);

  const button = find(Button, { variant: 'primary' });
});
  • Predicate function: match elements using a custom function.
test('finds button by predicate', () => {
  const { render, find } = createRenderer('crm.record.tab');
  render(<Button variant="primary">Click me!</Button>);

  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:
test('accesses element properties', () => {
  const { render, find } = createRenderer('crm.record.tab');
  render(<Button variant="primary">Click me!</Button>);

  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>
  );
}

test('triggers click event', () => {
  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>;
}

test('waits for async updates', async () => {
  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:
test('waits with custom timeout', async () => {
  const { render, find, waitFor } = createRenderer('crm.record.tab');
  render(<AsyncCounter />);

  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:
test('works with fragment props', () => {
  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:
test('debugs 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:
test('converts nodes to string', () => {
  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:
test('accesses root node', () => {
  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';

test('checks if node is element', () => {
  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';

test('checks if node is text', () => {
  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';

test('checks if node is root', () => {
  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';

test('checks if node is fragment', () => {
  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:
  1. As a standalone exported function: useful for checking nodes when iterating over child nodes
  2. 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';

test('uses isMatch standalone function', () => {
  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:
test('uses isMatch node 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.
Last modified on January 28, 2026