Ark UI Testing Guide: Machine-Based Components and State Assertions

Ark UI Testing Guide: Machine-Based Components and State Assertions

Ark UI (the headless component layer behind Chakra UI v3) is built on Zag.js — a finite state machine framework. Unlike Radix or Headless UI, Ark UI components have explicit state machines that drive behavior. This architecture makes them highly predictable, but testing requires understanding how state transitions manifest in the DOM.

Why State Machines Matter for Testing

Ark UI's machine-based approach means:

  • Components transition between explicit states: idle, focused, open, selecting, disabled
  • Each state has defined allowed transitions — invalid interactions are simply ignored
  • State is exposed via data-state, data-focus, data-open, data-highlighted attributes
  • Tests can assert on these attributes as the ground truth for component state

This is different from other libraries where you might check if a dropdown is "open" by looking for its content — Ark UI tells you directly via data-state="open".

Setup

npm install @ark-ui/react
npm install --save-dev @testing-library/react @testing-library/user-event vitest @vitest/coverage-v8
// vitest.setup.ts
import "@testing-library/jest-dom";

// Ark UI uses various browser APIs
global.ResizeObserver = class ResizeObserver {
  observe() {}
  unobserve() {}
  disconnect() {}
};

global.PointerEvent = class PointerEvent extends MouseEvent {
  constructor(type: string, init?: PointerEventInit) {
    super(type, init);
  }
};

HTMLElement.prototype.scrollIntoView = () => {};
HTMLElement.prototype.hasPointerCapture = () => false;
HTMLElement.prototype.setPointerCapture = () => {};
HTMLElement.prototype.releasePointerCapture = () => {};

Testing Select Component

Ark UI's Select is fully keyboard navigable with explicit state transitions:

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Select, createListCollection } from "@ark-ui/react";

const collection = createListCollection({
  items: [
    { label: "React", value: "react" },
    { label: "Vue", value: "vue" },
    { label: "Svelte", value: "svelte" },
    { label: "Angular (deprecated)", value: "angular", disabled: true },
  ],
});

function FrameworkSelect({ onValueChange }: { onValueChange: (value: string[]) => void }) {
  return (
    <Select.Root collection={collection} onValueChange={(e) => onValueChange(e.value)}>
      <Select.Label>Framework</Select.Label>
      <Select.Control>
        <Select.Trigger aria-label="Select framework">
          <Select.ValueText placeholder="Choose framework" />
        </Select.Trigger>
      </Select.Control>
      <Select.Positioner>
        <Select.Content>
          {collection.items.map((item) => (
            <Select.Item key={item.value} item={item}>
              <Select.ItemText>{item.label}</Select.ItemText>
            </Select.Item>
          ))}
        </Select.Content>
      </Select.Positioner>
    </Select.Root>
  );
}

it("opens on trigger click and shows options", async () => {
  const user = userEvent.setup();
  render(<FrameworkSelect onValueChange={vi.fn()} />);

  const trigger = screen.getByRole("combobox", { name: "Select framework" });
  expect(trigger).toHaveAttribute("aria-expanded", "false");

  await user.click(trigger);

  await waitFor(() => {
    expect(trigger).toHaveAttribute("aria-expanded", "true");
    expect(screen.getByRole("option", { name: "React" })).toBeInTheDocument();
  });
});

it("selects item and calls onValueChange", async () => {
  const user = userEvent.setup();
  const handleChange = vi.fn();
  render(<FrameworkSelect onValueChange={handleChange} />);

  await user.click(screen.getByRole("combobox"));
  await user.click(screen.getByRole("option", { name: "Vue" }));

  expect(handleChange).toHaveBeenCalledWith(["vue"]);
});

it("reflects machine state in data attributes", async () => {
  const user = userEvent.setup();
  render(<FrameworkSelect onValueChange={vi.fn()} />);

  const trigger = screen.getByRole("combobox");

  // Before opening — state is idle
  expect(trigger).toHaveAttribute("data-state", "closed");

  await user.click(trigger);

  // After opening — state is open
  await waitFor(() => {
    expect(trigger).toHaveAttribute("data-state", "open");
  });
});

it("marks disabled items with aria-disabled", async () => {
  const user = userEvent.setup();
  render(<FrameworkSelect onValueChange={vi.fn()} />);

  await user.click(screen.getByRole("combobox"));

  await waitFor(() => {
    const disabledOption = screen.getByRole("option", { name: "Angular (deprecated)" });
    expect(disabledOption).toHaveAttribute("aria-disabled", "true");
  });
});

it("navigates with keyboard", async () => {
  const user = userEvent.setup();
  const handleChange = vi.fn();
  render(<FrameworkSelect onValueChange={handleChange} />);

  const trigger = screen.getByRole("combobox");
  await user.click(trigger);
  
  await user.keyboard("{ArrowDown}"); // React (first)
  await user.keyboard("{ArrowDown}"); // Vue
  await user.keyboard("{Enter}");

  expect(handleChange).toHaveBeenCalledWith(["vue"]);
});

Testing Combobox with Machine State

Ark UI's Combobox has distinct states: idle, focused, suggesting, interacting:

import { Combobox, createListCollection } from "@ark-ui/react";

const items = [
  { label: "TypeScript", value: "ts" },
  { label: "JavaScript", value: "js" },
  { label: "Python", value: "py" },
  { label: "Go", value: "go" },
];

function LanguageCombobox({ onChange }: { onChange: (value: string[]) => void }) {
  const [inputValue, setInputValue] = useState("");

  const filtered = items.filter((item) =>
    item.label.toLowerCase().includes(inputValue.toLowerCase())
  );

  const collection = createListCollection({ items: filtered });

  return (
    <Combobox.Root
      collection={collection}
      onValueChange={(e) => onChange(e.value)}
      onInputValueChange={(e) => setInputValue(e.inputValue)}
    >
      <Combobox.Label>Language</Combobox.Label>
      <Combobox.Control>
        <Combobox.Input aria-label="Search language" />
        <Combobox.Trigger aria-label="Toggle language list" />
      </Combobox.Control>
      <Combobox.Positioner>
        <Combobox.Content>
          {filtered.length === 0 ? (
            <p>No results</p>
          ) : (
            filtered.map((item) => (
              <Combobox.Item key={item.value} item={item}>
                <Combobox.ItemText>{item.label}</Combobox.ItemText>
              </Combobox.Item>
            ))
          )}
        </Combobox.Content>
      </Combobox.Positioner>
    </Combobox.Root>
  );
}

it("filters options on input", async () => {
  const user = userEvent.setup();
  render(<LanguageCombobox onChange={vi.fn()} />);

  await user.type(screen.getByRole("combobox", { name: "Search language" }), "py");

  await waitFor(() => {
    expect(screen.getByRole("option", { name: "Python" })).toBeInTheDocument();
    expect(screen.queryByRole("option", { name: "TypeScript" })).not.toBeInTheDocument();
  });
});

it("shows no results message when filter has no matches", async () => {
  const user = userEvent.setup();
  render(<LanguageCombobox onChange={vi.fn()} />);

  await user.type(screen.getByRole("combobox", { name: "Search language" }), "COBOL");

  await waitFor(() => {
    expect(screen.getByText("No results")).toBeInTheDocument();
  });
});

it("selects option and calls onChange", async () => {
  const user = userEvent.setup();
  const handleChange = vi.fn();
  render(<LanguageCombobox onChange={handleChange} />);

  await user.click(screen.getByRole("button", { name: "Toggle language list" }));
  await user.click(screen.getByRole("option", { name: "Go" }));

  expect(handleChange).toHaveBeenCalledWith(["go"]);
});

Testing Slider

Ark UI's Slider uses keyboard events for value changes. Test both mouse drag and keyboard:

import { Slider } from "@ark-ui/react";

function VolumeSlider({ onChange }: { onChange: (value: number[]) => void }) {
  return (
    <Slider.Root
      min={0}
      max={100}
      defaultValue={[50]}
      onValueChange={(e) => onChange(e.value)}
    >
      <Slider.Label>Volume</Slider.Label>
      <Slider.Control>
        <Slider.Track>
          <Slider.Range />
        </Slider.Track>
        <Slider.Thumb index={0} aria-label="Volume slider" />
      </Slider.Control>
      <Slider.ValueText />
    </Slider.Root>
  );
}

it("renders with default value", () => {
  render(<VolumeSlider onChange={vi.fn()} />);
  const thumb = screen.getByRole("slider", { name: "Volume slider" });
  expect(thumb).toHaveAttribute("aria-valuenow", "50");
  expect(thumb).toHaveAttribute("aria-valuemin", "0");
  expect(thumb).toHaveAttribute("aria-valuemax", "100");
});

it("increases value with ArrowRight key", async () => {
  const user = userEvent.setup();
  const handleChange = vi.fn();
  render(<VolumeSlider onChange={handleChange} />);

  const thumb = screen.getByRole("slider");
  await user.click(thumb);
  await user.keyboard("{ArrowRight}");

  expect(handleChange).toHaveBeenCalledWith([51]);
  expect(thumb).toHaveAttribute("aria-valuenow", "51");
});

it("increases by 10 with PageUp key", async () => {
  const user = userEvent.setup();
  const handleChange = vi.fn();
  render(<VolumeSlider onChange={handleChange} />);

  const thumb = screen.getByRole("slider");
  await user.click(thumb);
  await user.keyboard("{PageUp}");

  expect(handleChange).toHaveBeenCalledWith([60]);
});

it("clamps value at maximum", async () => {
  const user = userEvent.setup();
  render(<VolumeSlider onChange={vi.fn()} />);

  const thumb = screen.getByRole("slider");
  await user.click(thumb);
  await user.keyboard("{End}"); // Jump to max

  expect(thumb).toHaveAttribute("aria-valuenow", "100");
});

Testing DatePicker

Ark UI's DatePicker has a complex machine — calendar navigation, date selection, and range selection:

import { DatePicker } from "@ark-ui/react";

function SimpleDatePicker({ onChange }: { onChange: (date: string) => void }) {
  return (
    <DatePicker.Root onValueChange={(e) => onChange(e.valueAsString[0] ?? "")}>
      <DatePicker.Label>Select Date</DatePicker.Label>
      <DatePicker.Control>
        <DatePicker.Input aria-label="Date input" />
        <DatePicker.Trigger aria-label="Open calendar" />
      </DatePicker.Control>
      <DatePicker.Positioner>
        <DatePicker.Content>
          <DatePicker.View view="day">
            <DatePicker.ViewControl>
              <DatePicker.PrevTrigger aria-label="Previous month" />
              <DatePicker.ViewTrigger aria-label="Month and year" />
              <DatePicker.NextTrigger aria-label="Next month" />
            </DatePicker.ViewControl>
            <DatePicker.Table>
              <DatePicker.TableBody>
                {(week) => (
                  <DatePicker.TableRow key={week.id}>
                    {(day) => (
                      <DatePicker.TableCell key={day.id} value={day}>
                        <DatePicker.TableCellTrigger>{day.day}</DatePicker.TableCellTrigger>
                      </DatePicker.TableCell>
                    )}
                  </DatePicker.TableRow>
                )}
              </DatePicker.TableBody>
            </DatePicker.Table>
          </DatePicker.View>
        </DatePicker.Content>
      </DatePicker.Positioner>
    </DatePicker.Root>
  );
}

it("opens calendar on trigger click", async () => {
  const user = userEvent.setup();
  render(<SimpleDatePicker onChange={vi.fn()} />);

  await user.click(screen.getByRole("button", { name: "Open calendar" }));

  await waitFor(() => {
    expect(screen.getByRole("grid")).toBeInTheDocument();
  });
});

it("navigates to next month", async () => {
  const user = userEvent.setup();
  render(<SimpleDatePicker onChange={vi.fn()} />);

  await user.click(screen.getByRole("button", { name: "Open calendar" }));

  const monthLabel = screen.getByRole("button", { name: "Month and year" });
  const currentMonth = monthLabel.textContent;

  await user.click(screen.getByRole("button", { name: "Next month" }));

  expect(monthLabel.textContent).not.toBe(currentMonth);
});

Asserting on Machine State Attributes

Build a helper to assert on Ark UI's state attributes:

function expectMachineState(
  element: HTMLElement,
  state: Record<string, string | boolean>
) {
  for (const [attr, value] of Object.entries(state)) {
    if (typeof value === "boolean") {
      if (value) {
        expect(element).toHaveAttribute(`data-${attr}`);
      } else {
        expect(element).not.toHaveAttribute(`data-${attr}`);
      }
    } else {
      expect(element).toHaveAttribute(`data-${attr}`, value);
    }
  }
}

it("select reflects state machine transitions", async () => {
  const user = userEvent.setup();
  render(<FrameworkSelect onValueChange={vi.fn()} />);

  const trigger = screen.getByRole("combobox");

  expectMachineState(trigger, { state: "closed" });

  await user.click(trigger);
  await waitFor(() => {
    expectMachineState(trigger, { state: "open" });
  });

  await user.keyboard("{Escape}");
  await waitFor(() => {
    expectMachineState(trigger, { state: "closed" });
  });
});

Summary

Testing Ark UI components effectively:

  1. Mock browser APIs: PointerEvent, ResizeObserver, scrollIntoView, pointer capture — all needed
  2. Assert on machine state via data attributes: data-state, data-focus, data-highlighted are the ground truth
  3. Use ARIA attributes for selection state: aria-expanded, aria-selected, aria-valuenow, aria-disabled
  4. Test keyboard navigation: Ark UI components are fully keyboard operable — verify each key binding
  5. Test state machine transitions: Open → select → close; focus → input → suggest — verify the full flow
  6. Use createListCollection for Select and Combobox — it's required, not optional, for Ark UI's collection-based components

Read more