Skip to content

testing library carbon combobox

Paulo Gomes da Cruz Junior edited this page Sep 16, 2026 · 6 revisions

title: "Fix Guide — Interacting with Carbon ComboBox in Tests" type: fix-guide domain: frontend framework: react testing: "vitest + @testing-library/react" component: "@carbon/react ComboBox" severity: warning tags: [testing-library, carbon, combobox, react, vitest] created: 2026-07-14 updated: 2026-07-22

Fix Guide — Interacting with Carbon ComboBox in Tests

Problem

Carbon's ComboBox does NOT respond to raw DOM events (CustomEvent, dispatchEvent) in most cases. It manages its own internal state via Downshift and only fires its onChange React prop when the user interacts through the actual UI (typing, clicking options). Tests that dispatch CustomEvent('change') or FocusEvent('blur') on the input element will silently fail — the component state never updates.

However, in some test setups (particularly when form state is controlled externally via react-form or similar), CustomEvent dispatch CAN work if the component passes the event through to the external handler.

Root Cause

Carbon's ComboBox wraps Downshift internally. The onChange callback is a React prop, not a DOM event listener. Raw DOM events bypass React's synthetic event system and never reach the ComboBox's internal handlers in the normal flow.

False Green Warning — synthetic events + weak assertions

A test that dispatches CustomEvent('change', { detail: { selectedItem } }) and then asserts something trivial, or asserts nothing, reports pass while silently skipping the real behavior check. The suite stays green, but it proves nothing. This is a false green.

In coverage.unit.test.tsx, this exact pattern passed 34/34 while never validating the grade-field logic. The synthetic events fired, or did not, and the assertions were too weak or absent to catch the gap. The district selection may or may not have run; nothing verified it.

Drive the ComboBox for real with userEvent:

const districtInput = await screen.findByTestId("district-combobox");
await userEvent.clear(districtInput);
await userEvent.type(districtInput, "DKM");
const option = await screen.findByText(/Dease Lake/i);
await userEvent.click(option);

This fires onChange with the selected item, so assertions that check the resulting state run against real behavior.

Rule of thumb: if a test passes only because nothing threw, it is not a real test. Prefer real interaction plus assertions that check the resulting state.

Caveat: CustomEvent dispatch only works on the input node

The CustomEvent approach in Fix 2 works only because document.querySelector('#create-ru-district') targets the ComboBox inner <input>, where Carbon's handler listens for the synthetic change event. Carbon puts id on the inner <input> and data-testid on the root wrapper <div>.

Once you refactor the locator to satisfy testing-library/no-node-access (for example screen.getByTestId('district-combobox')), you select the wrapper, not the input. The synthetic CustomEvent no longer reaches the handler. The district is never selected, and the dependent grade field never appears. In additional-coverage.unit.test.tsx this broke 8 tests.

Even when the synthetic CustomEvent does fire, for example in a @tanstack/react-form-controlled context where the event bubbles to an external handler, weak or absent assertions still produce a false green. The sanctioned fix is real userEvent regardless of whether the event appears to fire.

Do not keep the synthetic CustomEvent. Switch to a real userEvent interaction:

import userEvent from "@testing-library/user-event";

const districtInput = await screen.findByTestId("district-combobox");
await userEvent.clear(districtInput);
await userEvent.type(districtInput, "DKM");

const districtText = await screen.findByText(/Dease Lake/i);
districtText.click();

This drives the ComboBox for real, fires onChange with the selected item, and renders the grade field. See Fix Guide — testing-library/no-node-access for the full worked example and the prefer-find-by bonus.

Fix 1 — Use userEvent for Real Interaction (Recommended)

import userEvent from "@testing-library/user-event";

// ✅ Correct — interact like a real user
const user = userEvent.setup();
const comboboxInput = screen.getByRole("combobox", { name: /district/i });
await user.click(comboboxInput); // Open the dropdown
await user.type(comboboxInput, "DKM"); // Type to filter
const option = screen.getByText("DKM - Dease Lake"); // Carbon formats as "code - description"
await user.click(option); // Select the option

// ❌ Wrong — raw DOM events are ignored by Carbon in most cases
const combobox = document.querySelector("#create-ru-district");
const event = new CustomEvent("change", {
  detail: { selectedItem: { code: "DKM", description: "Dease Lake" } },
  bubbles: true,
  cancelable: true,
});
combobox.dispatchEvent(event); // Nothing happens

Fix 2 — CustomEvent Dispatch (Works in Controlled Form Contexts)

When the ComboBox is controlled by an external form library (e.g., @tanstack/react-form) that passes onChange to the ComboBox, CustomEvent CAN work because the event bubbles to the form's handler:

// ✅ Works when form library controls the ComboBox
const comboBox = document.querySelector("#create-ru-district");
await act(async () => {
  const event = new CustomEvent("change", {
    detail: { selectedItem: { code: "DKM", description: "Dease Lake" } },
    bubbles: true,
    cancelable: true,
  });
  comboBox?.dispatchEvent(event);
});

// Wait for form state to update
await waitFor(() => {
  const field = document.querySelector("#conditional-field-id");
  expect(field).toBeDefined();
});

When this works: The component uses onChange={(event) => updateDistrictSelection(event.selectedItem?.code ?? null, field.handleChange)} — the external handler receives the event.

When it fails: Pure Carbon ComboBox without external form control.

Read this before refactoring: Fix 2 relies on document.querySelector('#create-ru-district') returning the ComboBox input. If you replace that locator with getByTestId to satisfy testing-library/no-node-access, see the Caveat above and switch to real userEvent.

Fix 3 — Mock Carbon ComboBox for Unit Tests (Fastest)

If the test doesn't need to verify Carbon integration, mock @carbon/react with a native <select>:

vi.mock("@carbon/react", () => ({
  ComboBox: ({ id, titleText, items, selectedItem, onChange }) => (
    <select
      id={id}
      aria-label={titleText}
      value={selectedItem?.code ?? ""}
      onChange={(e) => {
        const item = items.find((i) => i.code === e.target.value);
        onChange?.({ selectedItem: item ?? null });
      }}
    >
      <option value="">Select</option>
      {items.map((item) => (
        <option key={item.code} value={item.code}>
          {item.description}
        </option>
      ))}
    </select>
  ),
  // ... other Carbon components
}));

This allows userEvent.selectOptions() to work naturally.

Fix 4 — act() + setTimeout(0) for act() warnings from real ComboBox interactions (validated 2026-07-22)

The other fixes in this page cover getting a real userEvent interaction to land on the ComboBox correctly. A separate problem shows up even after that: real Carbon ComboBox interaction still logs act(...) warnings when a test calls .focus(), .click(), or .blur() directly on the ComboBox element instead of going through user.click()/user.type(). Carbon's onFocus/onBlur handlers fire an async state update (opening the listbox, running validation) that resolves after the raw event's synchronous act() scope closes.

The validated fix wraps the raw event and drains a macrotask tick inside act():

import { act } from "@testing-library/react";

await act(async () => {
  districtComboBox.focus();
  await new Promise((r) => setTimeout(r, 0));
});

await act(async () => {
  districtComboBox.blur();
  await new Promise((r) => setTimeout(r, 0));
});

focus() and blur() need separate wraps — Carbon's onFocus handler triggers its own async update independent of onBlur, so wrapping only blur() leaves the onFocus warning in place. A waitFor() or Promise.resolve() in place of setTimeout(r, 0) does not work: those only flush microtasks, and this cascade resolves on a macrotask tick.

Applied to additional-coverage.unit.test.tsx's two runValidators on blur tests, this took the file's combined ComboBox/Field/LocalSubscribe warning count from 23 to 0, with all 23 tests still passing. Full validation: tsc --noEmit clean, eslint --max-warnings=0 clean, vitest run green, full suite 165/165 files and 1994/1994 tests passing, coverage unchanged.

This is not the same as Fix 3's Carbon mock. Fix 3 (mocking @carbon/react) was piloted separately in testing-library-act-warnings.md and made form.Field/LocalSubscribe warnings worse — that finding still stands and this fix does not touch it. The act() + setTimeout(0) pattern only changes how the test dispatches its own raw DOM event; it does not mock any module. Prefer this pattern over Fix 3 whenever a raw .focus()/.click()/.blur() on a real ComboBox is producing act() warnings.

Fix 5 — Rollout to coverage.unit.test.tsx and index.unit.test.tsx, including a userEvent-needs-act() exception (validated 2026-07-22, closes remaining warnings to 0)

Fix 4 closed the pattern in two files and left coverage.unit.test.tsx and index.unit.test.tsx in the same ReportingUnitCreate folder open, carrying the last 52 warnings between them.

coverage.unit.test.tsx needed the exact Fix 4 pattern applied across several more sites: districtText.click(), dcrText.click(), clientBlurInput.blur(), districtInput.focus()/.blur(), coastalRadio.focus()/.blur(), samplingInput.focus()/.blur(). One thing to watch for: some of these calls already sat inside an act() wrapper from an earlier fix pass. Adding Fix 4's wrapper on top nests one act() inside another around the same event — collapse to a single act() call instead of stacking two.

index.unit.test.tsx exposed a different root cause. Its findSelectedItem should be used when grade value exists test already used userEvent.selectOptions() — the Testing-Library-recommended API — and still left the ComboBox/Field/LocalSubscribe cascade unflushed. A trailing setTimeout(0) after the call, with no act() wrapper, made it worse (3 → 7 warnings) instead of better. The working fix wraps userEvent.selectOptions() itself inside act(), with two chained flushes:

// eslint-disable-next-line testing-library/no-unnecessary-act
await act(async () => {
  await userEvent.selectOptions(gradeSelect, "coastal");
  await new Promise((r) => setTimeout(r, 0));
  await new Promise((r) => setTimeout(r, 0));
});

The testing-library/no-unnecessary-act rule assumes RTL utility calls like userEvent.selectOptions() are self-sufficient and never need an act() wrapper. This test disproves that assumption for this specific ComboBox/Field/LocalSubscribe cascade, so a scoped eslint-disable-next-line on this one call is justified — measured, not assumed. Treat this as a refinement to Fix 4, not a contradiction: raw DOM events still need the single-flush act() wrap; this cascade specifically needed the same treatment applied to a userEvent call plus a second flush.

Result: both files' ComboBox/Field/LocalSubscribe warnings dropped to 0, closing out all 52 previously-open warnings. Full suite: 165/165 files, 1994/1994 tests passing. Coverage unchanged at 94.09% / 87.9% / 93.11% / 95.04%. Every act() warning bucket documented on this page and in testing-library-act-warnings.md is now at 0.

See Fix Guide — React Testing Library act(...) Warnings for the full progression table and resolution log across all fixes.

Key Details

  1. Option text format: Carbon's itemToString formats options as {code} - {description} (e.g., DKM - Dease Lake), NOT just the description. Use the full format in screen.getByText().

  2. Query by role: Use screen.getByRole('combobox', { name: /label text/i }) to find the input. The name matches the associated <label> or aria-label.

  3. Dropdown opens on focus/click: Carbon's ComboBox opens the dropdown menu when the input receives focus or is clicked. user.click() handles this automatically.

  4. waitFor vs findBy: After selecting an option, use screen.findByTestId() (async) instead of waitFor(() => screen.queryByTestId()) to wait for conditional rendering.

  5. Multiple label match issue: getByLabelText(/sampling option/i) may match both the label and the combo box text. Use document.querySelector('#component-id') or getByTestId instead.

Trade-offs

Approach Pros Cons
Real Carbon + userEvent Tests actual behavior, catches integration issues Slower, complex setup, tied to Carbon internals
CustomEvent (controlled forms) Fast, works with form state Only works with external form handlers
Mocked ComboBox Fast, simple, deterministic Doesn't test Carbon integration, mock can drift

Related Files

  • /frontend/src/components/waste/ReportingUnits/ReportingUnitCreate/additional-coverage.unit.test.tsx — Reference implementation. District selection now uses real userEvent (findByTestId('district-combobox') → clear + type + click the option text). Grade radios use getByRole('radio', { name: /Coastal|Interior/i }) and null assertions use queryByTestId('grade-radio-group'). No CustomEvent remains.
  • /frontend/src/components/waste/ReportingUnits/ReportingUnitCreate/coverage.unit.test.tsx — Grade-interaction tests migrated to real userEvent. Remaining raw .click()/.focus()/.blur() sites (district, dcr, client, sampling, coastal radio) now wrapped per Fix 5; act() warnings at 0.
  • /frontend/src/components/waste/ReportingUnits/ReportingUnitCreate/index.tsx — Component with @tanstack/react-form controlled ComboBoxes

Related Pages

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally