Repository navigation
testing library carbon combobox
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
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.
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.
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.
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.
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 happensWhen 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 withgetByTestIdto satisfytesting-library/no-node-access, see the Caveat above and switch to realuserEvent.
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.
-
Option text format: Carbon's
itemToStringformats options as{code} - {description}(e.g.,DKM - Dease Lake), NOT just the description. Use the full format inscreen.getByText(). -
Query by role: Use
screen.getByRole('combobox', { name: /label text/i })to find the input. Thenamematches the associated<label>oraria-label. -
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. -
waitFor vs findBy: After selecting an option, use
screen.findByTestId()(async) instead ofwaitFor(() => screen.queryByTestId())to wait for conditional rendering. -
Multiple label match issue:
getByLabelText(/sampling option/i)may match both the label and the combo box text. Usedocument.querySelector('#component-id')orgetByTestIdinstead.
| 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 |
-
/frontend/src/components/waste/ReportingUnits/ReportingUnitCreate/additional-coverage.unit.test.tsx— Reference implementation. District selection now uses realuserEvent(findByTestId('district-combobox')→clear+type+ click the option text). Grade radios usegetByRole('radio', { name: /Coastal|Interior/i })and null assertions usequeryByTestId('grade-radio-group'). NoCustomEventremains. -
/frontend/src/components/waste/ReportingUnits/ReportingUnitCreate/coverage.unit.test.tsx— Grade-interaction tests migrated to realuserEvent. 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-formcontrolled ComboBoxes
- Test Patterns — Formula Create Screen — Carbon RadioButtonGroup mocking pattern (similar approach for radio inputs)
This wiki documents NR Waste Plus for users and developers. Verify security and deployment guidance against the current application configuration before applying it elsewhere.
- Report an issue or ask a question in the GitHub repository.
- Follow the Contributing Guidelines to propose an update.
- Read the Code of Conduct.
Collection indexes support human browsing and machine retrieval. Keep both current when adding or moving wiki pages.
NR Waste Plus Wiki | GitHub Repository