Skip to content

Instantly share code, notes, and snippets.

@kapral18
Last active February 26, 2026 12:24
Show Gist options
  • Select an option

  • Save kapral18/423c41891e65cc78efcbd1d43b894b73 to your computer and use it in GitHub Desktop.

Select an option

Save kapral18/423c41891e65cc78efcbd1d43b894b73 to your computer and use it in GitHub Desktop.
Enzyme to RTL Migration Wisdom
0. HOW TO USE THIS WISDOM BEAD (read this first)
This bead is optimized for “in-the-trenches” migration work.
Use it like a field manual:
- Start with the decision trees below to pick the right tool/pattern fast.
- Then jump to the referenced Pattern for the full explanation + examples.
- Prefer canonical sections: if two places mention the same idea, the canonical Pattern is the source of truth.
0.1 Decision trees (fast entry points)
Waiting / async:
- Need element to appear? -> Pattern 4 (findBy*)
- Need element to disappear? -> Pattern 4 (waitForElementToBeRemoved)
- Need dropdown/portal to close? -> Pattern 12C (canonical dropdown-closure)
- Act warning? -> Pattern 4 (missing await), Pattern 11 (EuiPopover)
Querying:
- Default selector? -> Pattern 5 (ByTestId first)
- Multiple elements found? -> Pattern 5 (queryAllBy* + filter) + Pattern 31 (common errors)
- Portal element? -> Pattern 11
- Space-separated data-test-subj? -> Pattern 5 (canonical)
- Redundant getBy after findBy? -> Pattern 4 (store findBy result, reuse it)
Timers:
- Start here -> Pattern 6 (suite setup + hygiene)
- Need to flush timer chains? -> Pattern 6 (timer-runtime helpers + flushUntil)
- Using userEvent? -> Pattern 8 (setup) + Pattern 6 (timer rules)
Helpers (test readability without testbed indirection):
- When to extract helpers? -> Pattern 25
- What to avoid (actions/testbed/DSL)? -> Pattern 25 + Pattern 26
Mocks / TS:
- HTTP mock signature differences? -> Pattern 40
- Strict TS mock objects? -> Pattern 39
- Lint TS projects / tsconfig coverage? -> Pattern 38
- Heavy UI deps in JSDOM (Monaco/CodeEditor, @elastic/charts)? -> Pattern 43 (prefer shared mocks; fallback to plugin-local __mocks__)
- Jest mock factory "out-of-scope variables" error? -> Pattern 44 (hoisting-safe patterns)
0.2 What “good” looks like
- Tests read as Arrange -> Act -> Assert (Pattern 17)
- Helpers reduce noise but don’t hide control flow or waits (Pattern 25)
- Waits are explicit and tied to a UI boundary (Pattern 4, Pattern 6, Pattern 7)
0.3 QUICK REFERENCE INDEX
By Topic:
Performance:
-> Pattern 21: Test Performance Quick Wins (When: tests are slow; fix selectors/interactions first)
-> Pattern 22: Test Splitting Strategy (When: one test does too much / hard to debug)
-> Pattern 5: Query Selector Strategy (When: choosing selectors; perf-sensitive queries)
-> Pattern 8: fireEvent vs userEvent (When: choosing interaction API; speed vs realism)
Forms:
-> Pattern 7: Form Testing - Complete Guide (When: forms/validation/submit flows)
Waiting/Async:
-> Pattern 4: Element Queries & Async Waiting (When: waiting for UI state changes)
-> Pattern 6: Fake Timers Setup & Strategy (When: fake timers, act warnings, timer chains)
-> Pattern 7: Form Testing (When: deciding wait placement for form helpers)
-> Pattern 19: Increasing Individual Test Timeout (When: tests exceed default timeout)
Timers:
-> Pattern 6: Fake Timers Setup & Strategy (When: fake timers, act warnings, timer chains)
-> Pattern 23: Systematic Utility Refactoring (When: removing timer logic from legacy utilities)
Components:
-> Pattern 9: Router Mocking (When: route params/history/location are required)
-> Pattern 10: License & Feature Flags (When: UI is gated/conditionally rendered)
-> Pattern 11: Portal Components (When: modals/popovers render outside container)
-> Pattern 12: Async Select Components (When: interacting with EUI dropdown/select components)
-> Pattern 13: Tab Navigation (When: clicking tabs + waiting for panel)
-> Pattern 37: EUI Harness Implementation Guidelines (When: editing @kbn/test-eui-helpers harnesses)
Refactoring:
-> Pattern 23: Systematic Utility Refactoring
-> Pattern 24: Enzyme Artifact Cleanup (When: hunting Enzyme remnants)
-> Pattern 25: Helpers vs Testbed/Actions Architecture (When: test files get large; choosing helper boundaries)
-> Pattern 26: Avoid Plugin-Specific Harness/Testbed Frameworks (When: tempted to build an actions DSL)
-> Pattern 37: EUI Harness Implementation Guidelines (When: editing @kbn/test-eui-helpers harnesses)
Debugging:
-> Pattern 28: No Imports from Test Files (When: sharing fixtures/helpers safely)
-> Pattern 29: Semantic Code Search for Debugging (When: compare with main quickly)
-> Pattern 30: Console Log Debugging for Data Flow (When: form state vs UI mismatch)
-> Pattern 31: Common Errors Reference (When: error-driven triage)
-> Pattern 44: Jest Mock Factory Hoisting / Out-of-Scope Errors (When: jest.mock() factory throws)
0.4 SYMPTOM INDEX (error -> pattern)
By Error Message:
Found multiple elements -> Pattern 31 (duplicate renders)
Functions are not valid as React child -> Pattern 31
Element not found (but visible in UI) -> Pattern 10 (license check)
Cannot read properties of undefined -> Pattern 9 (router mock)
Act warning -> Pattern 4 (missing await), Pattern 11 (EuiPopover - convert getBy* to findBy*)
Warning: Cannot update a component (`X`) while rendering a different component (`Y`) -> Pattern 31 (render-phase update warning; not an act issue)
Suspense resolution warning ('A suspended resource finished loading...') -> Often Monaco/JsonEditorField in JSDOM; prefer shared mocks (e.g. @kbn/code-editor-mock/jest_helper). If no shared mock exists, use plugin-local __mocks__ for @kbn/code-editor (Pattern 43); then await a real UI boundary (findBy*/waitFor)
Fake timers not enabled warning ('timers APIs are not replaced with fake timers') -> If you opted into fake timers for the suite, ensure jest.useFakeTimers() is enabled before calling any timer-runtime helpers; treat fake timers as an explicit contract and use suite hygiene (Pattern 6)
Test timeout -> Pattern 21 (performance), Pattern 19 (individual timeouts)
Slow tests -> Pattern 21 (performance quick wins)
Skipped/flaky tests -> Pattern 35 (unskip and fix)
Redundant getBy after findBy (code smell) -> Pattern 4 (store findBy result and reuse)
0.5 GLOSSARY (terms we use consistently)
- UI boundary: the visible DOM state you wait on (element appears/disappears, text changes, panel mounts, listbox closes).
- Canonical section: the single source of truth for a concept; other places should reference it, not re-explain it.
- Timer-runtime helper: a shared helper that only wraps fake-timers APIs in act() correctly (e.g. flushPendingTimers/flushUntil).
- Domain/action helper: helper named after product/domain behavior (e.g. clickSave, fillForm). Should not hide waits/timer flushing.
- Testbed/actions architecture: multi-layer abstraction (setup + actions/page objects + helpers) that hides selectors/waits and increases indirection.
- Portal: UI rendered outside the normal React tree (modals, popovers). Often requires querying document/body.
0.6 STYLE / FORMAT CONVENTIONS
- Prefer this structure inside patterns when adding new guidance:
Rule: ...
Why: ...
✅ DO: ...
❌ DON'T: ...
Example: ...
See also: Pattern N
- Keep examples as plain text (no markdown code fences).
- Prefer one canonical home per concept; link to it elsewhere.
0.7 MINIMAL TEST SCAFFOLD (copy/paste; adjust as needed)
describe('Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('SHOULD ...', async () => {
// Arrange
render(<Component />);
// Act
fireEvent.click(screen.getByTestId('saveButton'));
// Assert (wait for a UI boundary)
await screen.findByText('Saved');
});
});
Notes:
- Default: use REAL timers.
- Use fake timers only when they provide a measurable improvement in speed and/or stability (e.g. by advancing long waits like debounces/intervals/backoff/polling). See Pattern 6.
- Keep helpers shallow; keep waits explicit (Pattern 4, Pattern 25).
- Mock hygiene: prefer ONE top-level beforeEach() per test file that calls jest.clearAllMocks().
Do not repeat jest.clearAllMocks() in nested describe blocks unless that block also does additional
scoped setup (re-clearing alone is redundant and adds noise).
Variant: minimal scaffold with FAKE timers (opt-in; requires clear justification)
describe('Component', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(async () => {
// Suite hygiene for fake-timer suites (Pattern 6)
await fakeTimers.flushPendingTimers();
jest.clearAllTimers();
});
it('SHOULD ...', async () => {
// Arrange
render(<Component />);
// Act
fireEvent.click(screen.getByTestId('saveButton'));
// Assert (wait for a UI boundary)
await screen.findByText('Saved');
});
});
I. ESSENTIAL PRINCIPLES
(Must-read for all work - patterns that prevent incorrect results)
1. Investigation-First Principle
Before fixing any test or helper:
- Read the actual source code of the component/feature under test FIRST
- Understand what test IDs exist, conditions controlling rendering, required props
- Check for license requirements, feature flags, conditional rendering logic
- Verify actual DOM structure and element attributes
- When tests fail, inspect implementation to understand WHY
Rule: Never fix tests in a vacuum without understanding the implementation.
Example: Index Management Analyzer Components
- Custom analyzers from index.analysis.analyzer render as native <select>
- NOT as SuperSelect with "Custom" button text
- Must inspect AnalyzerParameterSelects component to understand rendering logic
- Test expectations must match actual conditional rendering behavior
2. Verification After Every Change
Always verify at narrowest scope, expand at milestones:
Per file change (default):
- Run the narrowest-scope checks for the file(s) you touched.
- Canonical command strings live in VIII. REFERENCE (Test & Lint Commands).
- Typical trio: type check (closest tsconfig), eslint on the file/dir, jest for the test file.
Iteration/milestone complete (expand scope):
* Utility done -> test all files using that utility
* Feature iteration done -> test entire feature/plugin
* PR ready -> full suite
Integration tests:
* Use node scripts/jest_integration <path> only if jest.integration.config.js exists nearby
Rules:
- Always verify changed file immediately
- Default narrow, expand when iteration achieved
- Don't skip verification for small changes
- Don't run full suites after each line change
2A. Quick Checks for Migrations (fast CI parity)
These are individual CI checks that are especially useful during Enzyme -> RTL migrations.
They do NOT create commits. Some can modify files only when run with --fix.
Use during iteration (when you add/move files, mocks, configs, or touch test infra):
- TS project coverage (new files belong to a tsconfig)
Command: node scripts/lint_ts_projects
When: after adding new TS/TSX files (especially __mocks__/**, new helpers, new test files)
Note: can be run with --fix (may modify files); without --fix it is check-only.
- Jest config sanity
Command: node scripts/check_jest_configs
When: after moving/adding jest test projects/configs; when Jest behaves differently locally vs CI
- File casing
Command: node scripts/check_file_casing --quiet
When: after renames/moves; catches macOS-vs-CI casing mismatches
- Test hardening rules
Command: node scripts/test_hardening
When: before pushing; catches CI-enforced test patterns and common footguns
Optional (run when relevant):
- Package metadata lint
Command: node scripts/lint_packages
When: after touching kibana.jsonc / package manifests / project wiring
Note: can be run with --fix (may modify files); without --fix it is check-only.
- Prettier topology
Command: node scripts/prettier_topology_check
When: after touching tooling/config in a way that could affect prettier setup
- i18n
Command: node scripts/i18n_check --quiet
When: only if you changed user-visible strings / i18n usage
- FTR configs / Scout configs
Commands:
- node scripts/check_ftr_configs
- node scripts/scout discover-playwright-configs --validate
When: only if you touched FTR/Scout configuration or moved configs
- Moon project generation (moon.yml)
Command: node scripts/regenerate_moon_projects.js --update --filter <project>
When: after adding/removing new path groups under a project (common: __mocks__/**, new test folders)
Why: CI runs a verify step that may regenerate moon.yml; it can add fileGroups entries (e.g. __mocks__/**/*)
so Moon tasks include those files as inputs.
Notes:
- The generated file is the project’s moon.yml; changes are expected and should be committed.
- Prefer running the filtered regenerate command locally to avoid CI-only diffs.
3. Git & Commit Hygiene
- NEVER commit changes without explicit user approval
- NEVER push to remote without explicit user approval
- Always request approval before EACH commit, even if prior approval given
- Always request approval before EACH push, even if prior approval given
- Do not assume continuing permission across operations
- Present changes and wait for explicit proceed/yes/commit/push confirmation
- When in doubt, stop and ask
II. RTL FUNDAMENTALS
(Core API usage - how to query, wait, and interact)
4. Element Queries & Async Waiting
CANONICAL: Waiting/async boundaries + act-warning diagnosis.
Decision Tree:
Q: What are you waiting for?
- Element to APPEAR? -> Use findBy*
await screen.findByTestId('hot-phase')
await screen.findByRole('button')
await within(container).findByTestId('combobox')
- Element to DISAPPEAR? -> Use waitForElementToBeRemoved
await waitForElementToBeRemoved(screen.getByTestId('loading'))
await waitForElementToBeRemoved(modal)
Note: Use getBy (not queryBy) - element must exist before removal
- Attribute change? -> Use waitFor + expect
await waitFor(() => expect(element.getAttribute('aria-checked')).toBe('true'))
- Text content change? -> Use waitFor + expect
await waitFor(() => expect(element.textContent).toBe('Complete'))
- Mock function call? -> Use waitFor + expect
await waitFor(() => expect(mockFn).toHaveBeenCalled())
- Dropdown/portal to CLOSE? -> Use waitFor + queryBy
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
Rationale: Prevents race conditions when selecting multiple options in sequence (see Pattern 12C for the canonical dropdown-closure pattern)
Separate UI boundaries (intentional multiple waits):
Rule: It is OK to use multiple consecutive waitFor calls when each wait is a different UI boundary.
Why:
- Portal teardown (listbox closing/unmounting) and form validation/state settlement often happen on different ticks.
- Keeping waits separate enforces causal order: close/unmount first so it can’t intercept the next interaction.
- Separate boundaries reduce ambiguity when debugging timeouts.
✅ DO:
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
await waitFor(() => expect(screen.getByTestId('nextButton')).toBeEnabled());
✅ DO: Add a short comment when you intentionally split waits (so reviewers don’t “simplify” it away).
❌ DON'T:
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
expect(screen.getByTestId('nextButton')).toBeEnabled();
});
See also: Pattern 12C (dropdown closure), Pattern 7 (forms/validation).
Anti-Patterns (NEVER use these):
WRONG: await waitFor(() => { expect(screen.getByTestId('foo')).toBeInTheDocument(); })
CORRECT: await screen.findByTestId('foo')
WRONG: await waitFor(() => expect(screen.queryByTestId('loading')).not.toBeInTheDocument())
CORRECT: await waitForElementToBeRemoved(screen.getByTestId('loading'))
WRONG: await waitFor(() => { const el = screen.getByRole('button'); expect(el).toBeInTheDocument(); })
CORRECT: await screen.findByRole('button')
WRONG (redundant re-query after findBy):
await screen.findByTestId('pageTitle');
expect(screen.getByTestId('pageTitle')).toHaveTextContent('Edit');
CORRECT (use the returned element):
const title = await screen.findByTestId('pageTitle');
expect(title).toHaveTextContent('Edit');
Why: findBy* already returns the element. Re-querying with getBy* is redundant and wasteful.
This also applies to fireEvent - store the result and reuse it:
WRONG:
await screen.findByTestId('saveButton');
fireEvent.click(screen.getByTestId('saveButton'));
CORRECT:
const saveButton = await screen.findByTestId('saveButton');
fireEvent.click(saveButton);
Act Warnings = Missing Await:
- Act warnings mean React state updates outside act() boundaries
- Root cause: Missing await on async operation BEFORE assertion
- Fix: Add await to the operation BEFORE the assertion
- Common sources: findBy, waitFor, waitForElementToBeRemoved, user interactions
- EuiPopover: When EuiPopover-related tests cause act() warnings, convert getBy* selectors to await findBy* to resolve (see Pattern 11)
Additional Act Warning Case: Mount-time Requests (no interaction yet)
Symptom:
- Warning mentions component under test (e.g. "update to Foo inside a test was not wrapped in act(...)")
- It happens even before the test clicks/types anything
Root cause:
- The component triggers async work on mount (useEffect/useRequest), and state updates land after the test has already moved on.
Fix (tests):
- Immediately after render/setup, await the first stable UI boundary for the initial request:
- await screen.findByTestId('nameInput')
- OR await waitForElementToBeRemoved(() => screen.getByTestId('sectionLoading'))
- Keep this wait in setupPage() so every test starts from the same settled baseline.
Hard rule: NEVER use empty act() blocks
- ❌ WRONG: await act(async () => {});
- Why: It has no trigger and no UI boundary; it becomes “maybe flush something” and hides missing awaits.
- ✅ DO instead:
- Prefer an explicit UI boundary: await screen.findBy* / await waitFor(...) / await waitForElementToBeRemoved(...)
- If you truly need act(), include the triggering operation inside it (almost always timer advancement in fake-timer suites),
and then assert a UI boundary.
Additional Act Warning Case: Save + Navigation (common in wizards/forms)
Symptom:
- Warning mentions component under test AND Router (e.g. "update to TemplateCreate" and "update to Router")
- Typical flow: click final Save/Create -> setIsSaving(true) -> await save -> setIsSaving(false) -> history.push(...)
- If the test ends right after asserting the API call or error toast, the post-save state updates + navigation
can complete after the test finishes, producing act warnings.
Fix:
- After clicking the final Save/Create button, wait for a navigation/unmount boundary:
- the wizard action button disappears, OR
- a stable element from the destination route appears.
- Do NOT "fix" this by sprinkling act() around fireEvent; await the observable UI boundary instead.
waitFor Timeout:
- waitFor has default timeout of 1000ms (1 second)
- NEVER add extended timeouts to waitFor unless tests legitimately take longer than 1.2s locally
- If waitFor times out, investigate root cause first (slow API, missing await, etc.)
- Even if tests need >1.2s, prefer increasing test-level timeout over waitFor-specific timeouts
- CI machines are 2-3x slower: 1.2s locally → 2.4-3.6s on CI (see Pattern 19 for algorithm)
- Avoid increasing waitFor timeout as a first response. Prefer:
- using findBy* instead of waitFor(getBy*) (see above),
- tightening the UI boundary you wait for,
- improving selector performance (Pattern 5, Pattern 21).
- Only if the suite uses fake timers AND you truly need to advance timer chains:
- Use the timer-runtime helpers described in Pattern 6 (flushPendingTimers / flushUntil).
- Do NOT sprinkle act() around fireEvent; keep timer advancement isolated and explicit.
5. Query Selector Strategy
CANONICAL: Query selection strategy + performance + space-separated data-test-subj rule.
Priority (fastest to slowest):
1. ByTestId - Direct data-testid/data-test-subj lookup (fastest, O(1))
2. ByLabelText - Label association lookup (fast, O(n))
3. ByPlaceholderText - Direct attribute lookup (fast, O(n))
4. ByText - Text content search (fast with specific strings, O(n))
5. ByRole - Role + accessible name computation (slowest, O(n^2))
Guidelines:
- Default: Use ByTestId (Kibana convention: data-test-subj)
- Use ByText for precise, unambiguous text matches
- Avoid ByText when ambiguous (multiple elements with same text)
- Avoid ByRole unless scoped or for structural validation
ByRole - When Acceptable:
YES: Scoped with within(): within(cell).getByRole('button')
YES: Structural validation: getAllByRole('cell') to verify column count
YES: No simpler alternative exists
NO: screen.getByRole('button', { name: /Save/ }) -> screen.getByText(/Save/)
NO: screen.getByRole('searchbox') -> screen.getByPlaceholderText(/Search/i)
Rationale: ByRole is 20-30% slower (computes roles + accessible names across entire DOM)
Null Handling:
- Use getBy when element MUST exist (throws if not found)
- Use queryBy when element MAY NOT exist (returns null)
- Pattern: expect(screen.queryByTestId('optional')).not.toBeInTheDocument()
Avoid "*AllBy* then [0]" (anti-pattern):
Rule: If you only need one element, do NOT use getAllBy*/findAllBy*/queryAllBy* and then take [0].
Why:
- It's wasteful (builds an array of all matches, then throws most away).
- It's less correct: it can mask accidental duplicate renders. Prefer queries that fail when duplicates exist.
✅ DO:
- Use getBy*/findBy*/queryBy* when you expect exactly one match.
- If there are legitimately many matches, narrow the query first (within(row)/within(panel)) so the singular query
is unambiguous, or filter by a meaningful predicate (tagName, textContent, id/aria-label).
❌ DON'T:
- const el = screen.getAllByTestId('foo')[0];
- const el = (await screen.findAllByText('Save'))[0];
- Click "the first one" unless ordering is the thing you are explicitly testing (then assert order/length too).
Example:
- Instead of: const firstLink = screen.getAllByTestId('indexTableIndexNameLink')[0]
Prefer: within(row).getByTestId('indexTableIndexNameLink') after you identify the correct row (by text or role),
or use a singular query by the expected label/text when uniqueness is the intent.
Practical disambiguation (when you legitimately need getAllByTestId):
- Prefer filtering by the interaction target’s capability, not by index.
- Example (EUI FormRow + control share test-subj):
const rows = screen.getAllByTestId('versionField');
const rowWithInput = rows.find((row) => within(row).queryByRole('spinbutton') !== null);
expect(rowWithInput).toBeDefined();
const input = within(rowWithInput!).getByRole('spinbutton');
Multiple Elements with Same test-subj:
Problem: EUI components create wrapper (FormRow) + control with same test-subj
Example: data-test-subj="searchQuoteAnalyzer" appears on both FormRow and button
✅ CORRECT - Use queryAllByTestId and filter:
const elements = within(container).queryAllByTestId('searchQuoteAnalyzer');
const button = elements.find((el) => el.tagName === 'BUTTON');
const formRow = elements.find((el) => el.tagName === 'DIV');
✅ CORRECT - Filter by textContent:
const buttons = within(container).queryAllByRole('button');
const languageButton = buttons.find((btn) => btn.textContent?.includes('Language'));
❌ WRONG - Using singular query (throws "Found multiple elements"):
const button = within(container).getByTestId('searchQuoteAnalyzer');
Rationale: Many EUI form components wrap controls in FormRow with same test-subj
Screen Queries Over Container:
- Prefer: screen.getByTestId() over const { getByTestId } = render()
- Rationale: screen queries are stable across re-renders
- Container destructuring creates stale references
CRITICAL: Space-Separated data-test-subj Limitation
Problem: screen.queryByTestId/getByTestId does EXACT attribute match for string queries.
EUI components often combine test subjects: data-test-subj="comboBoxOptionsList fieldName-optionsList"
❌ WRONG: screen.queryByTestId('fieldName-optionsList') // Returns null - exact match fails!
✅ PREFERRED: screen.queryByTestId(/fieldName-optionsList/)
⚠️ FALLBACK (rare): Use a scoped CSS selector only when RTL semantics cannot express what you need.
- Prefer putting this fallback inside a harness in @kbn/test-eui-helpers, not in individual test files.
- If you do use a selector, scope it to a known container (no global document scans).
- See also: Pattern 37 (EUI harnesses: where scoped DOM walking belongs).
Rationale:
- RegExp testId matching keeps us inside RTL semantics while still matching within the space-separated attribute value.
- Avoid spreading raw querySelector/querySelectorAll across tests; it leaks EUI DOM knowledge and creates brittle tests.
See Pattern 12 for EUI-specific examples.
Avoid naked querySelector/querySelectorAll (anti-pattern):
Rule: In RTL tests, do NOT default to document.querySelector/querySelectorAll for EUI components.
Why:
- It bypasses RTL semantics and makes tests brittle to DOM/layout changes.
- It encourages “DOM archaeology” (repeat the same selector logic everywhere).
- It hides problems like duplicate renders or duplicate accessible text.
✅ DO:
- Prefer screen/within queries.
- If you need DOM traversal for an EUI pattern (pagination buttons, context menus, table cell extraction),
move it into @kbn/test-eui-helpers (Pattern 37) and keep tests intent-focused.
❌ DON'T:
- document.querySelectorAll('.euiPaginationButton') in test files
- document.querySelector('[data-test-subj="foo"] ...') in test files
See also: Pattern 37 (put EUI-shaped DOM traversal + normalization in harnesses).
6. Fake Timers Setup & Strategy
CANONICAL: Fake timers setup/hygiene + when/how to flush timers safely.
CRITICAL: Use MODERN fake timers (NOT legacyFakeTimers)
Setup (only if you opt into fake timers):
When to use fake timers (opt-in):
Rule: Fake timers are NOT a default. Use them only when they provide a concrete benefit.
✅ Use fake timers when:
- Advancing timers is measurably faster than waiting real time (common with debounce/throttle/interval/backoff/polling).
- The test suite has long timer delays that dominate wall-clock runtime, and advancing timers is measurably faster.
- You have a stable UI boundary to advance-until (Pattern 6: flushUntil) and it reduces flake.
❌ Do NOT use fake timers when:
- The only goal is “make act warnings go away”. Act warnings are usually missing awaits / missing UI boundaries (Pattern 4, Pattern 11).
- The suite has little/no timer behavior. Fake timers can make tests slower and increase complexity.
If in doubt:
- Run the file once with real timers and once with fake timers and compare. Keep the faster and simpler option.
Pattern (fake-timer suites only):
beforeAll(() => {
jest.useFakeTimers(); // NO legacyFakeTimers!
});
afterAll(() => {
jest.useRealTimers();
});
Hygiene (prevents cross-test timer leakage and random slowdowns; fake-timer suites only):
Hygiene in fake-timer suites (only if the suite uses jest.useFakeTimers()):
- If the file uses jest.useFakeTimers() at suite scope, add an afterEach that:
- flushes pending timers in act(), then
- clears timers (jest.clearAllTimers()).
- Rationale: pending debounces/intervals from one test can leak into the next, inflating timings and causing flake.
Minimal pattern:
afterEach(async () => {
await act(async () => {
await jest.runOnlyPendingTimersAsync();
});
jest.clearAllTimers();
});
Flush vs Clear (why both exist):
- Flushing (runOnlyPendingTimersAsync inside act) EXECUTES timer callbacks so any timer-driven React updates
settle inside act boundaries.
- Clearing (jest.clearAllTimers) DELETES scheduled timers without executing callbacks.
Practical rule:
- In afterEach for fake-timer suites: flush first, then clear remaining timers if you need to prevent leakage.
- Do NOT use clearAllTimers() as a replacement for flushing. It can mask real async work and is a common
“fix-all hatch” that hides missing awaits/UI boundaries (see Pattern 4, Pattern 7).
Non-arbitrary timer-runtime helpers:
- Avoid hardcoding runOnlyPendingTimersAsync twice/three times. If you’re waiting on an observable UI boundary,
use a bounded "advance until condition" helper (this is NOT guessing; it encodes the boundary).
- It is OK for a timer-runtime helper to guard timer flushing with jest.getTimerCount() to avoid pointless work,
but NEVER use an empty act() block. If there is nothing to run, return early without act().
- Avoid file/global jest.setTimeout as a generic “make CI green” workaround. Prefer fixing waits, or, if truly
necessary, use a narrowly-scoped timeout with a concrete justification (see Pattern 19).
When to advance timers (rare):
Timer Advancement - RARELY Needed:
Rule: Prefer waitFor/findBy over manual timer advancement.
Under REAL timers, waitFor polls (it does not “advance timers”).
Under FAKE timers, RTL will advance timers during waitFor polling.
Decision Tree:
Q: Do you need manual timer advancement?
- Waiting for async UI updates?
→ NO (real timers): Use findBy/waitFor (polling a UI boundary).
→ NO (fake timers): Use findBy/waitFor first; only advance timers explicitly if you can’t reach a stable UI boundary otherwise.
- Testing specific timer-dependent behavior? (intervals, debounce)
→ YES: Use async versions wrapped in act()
→ Do not call sync timer APIs directly in tests (e.g. jest.advanceTimersByTime).
Exception: userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) needs a sync callback; that’s OK (see Pattern 8).
- Act() warnings persist even after using waitFor/findBy?
→ YES: Use jest.runOnlyPendingTimersAsync() wrapped in act()
→ Rationale: waitFor only advances timers every 50ms for max 1000ms (20 checks). If multiple async operations are queued (EuiPopover updates, debounced callbacks, timer chains), waitFor may not flush everything. runOnlyPendingTimersAsync() immediately completes all pending timers, ensuring all React state updates are flushed.
Correct Timer Advancement Pattern:
When manual timer advancement is truly needed:
✅ CORRECT: Each timer call in its own isolated act() wrapper
await act(async () => {
await jest.runOnlyPendingTimersAsync();
});
✅ CORRECT: Multiple separate act() calls for multiple timer advancements
await act(async () => {
await jest.runOnlyPendingTimersAsync();
});
await act(async () => {
await jest.runOnlyPendingTimersAsync();
});
Timer Chains (Why you sometimes see multiple runOnlyPendingTimersAsync calls):
This is NOT arbitrary. It happens when:
- A timer callback triggers a React state update/effect
- That effect schedules ANOTHER timer (debounce, popover reposition, portal lifecycle, etc.)
- Result: one timer flush is not enough; a follow-up timer appears after the first flush
Practical rule:
- If you are flushing timers because you are waiting for an observable UI state, do NOT hardcode "2".
- Instead, advance fake timers UNTIL the expected DOM condition becomes true (bounded).
Pattern: "advance until condition" (bounded)
Prefer implementing this once in a timer-runtime helper (see "Central timer-runtime helpers" below)
and calling it from tests, rather than re-declaring flushUntil in every file.
Examples:
- After clicking a toggle that conditionally renders a field:
await fakeTimers.flushUntil(() => screen.queryByTestId('indexModeField') !== null);
- After clicking Next and waiting for the next step to mount:
await fakeTimers.flushUntil(() => screen.queryByTestId('stepAliases') !== null);
When it is applicable:
- The UI boundary you need is represented in DOM (field/step/modal/listbox appears/disappears)
- waitFor/findBy is not sufficient under fake timers because timer chains/debounces are involved
When it is NOT applicable (or not helpful):
- There is no stable DOM condition to wait on (you are flushing only to "make act warnings go away")
- In that case: re-check missing awaits first (Pattern 4) and only then consider targeted timer flushing
✅ CORRECT: runAllTimersAsync also needs act() wrapper
await act(async () => {
await jest.runAllTimersAsync();
});
Helper naming rule (avoid misleading helpers):
- If a helper only advances a specific debounce window (e.g. JsonEditor debounces ~300ms),
do not name it "flushAll".
- Name it after what it actually does (e.g. flushJsonEditorDebounce / flushDebounceWindow)
and keep it narrowly scoped (advanceTimersByTimeAsync(300) inside act()).
Promise-splitting with fake timers:
- When an action returns a promise that resolves via timers, start it inside act, advance timers, then await the promise - all within the same act block.
- Pattern:
await act(async () => {
const createPromise = handleCreate();
await jest.runOnlyPendingTimersAsync();
await createPromise;
});
- Rationale: Keeps promise creation, timer advancement, and promise resolution all within act boundaries; prevents act warnings and ensures React state updates are properly flushed.
Examples:
// PREFERRED - No manual timers needed
await waitFor(() => expect(screen.getByText('Loaded')).toBeInTheDocument());
// Only if truly testing timer behavior
await act(async () => {
await jest.runOnlyPendingTimersAsync();
});
Anti-Patterns:
❌ WRONG: jest.useFakeTimers({ legacyFakeTimers: true })
❌ WRONG: jest.advanceTimersByTime(1000) // sync version
❌ WRONG: jest.runOnlyPendingTimersAsync() without act()
❌ WRONG: await jest.runOnlyPendingTimersAsync() // Missing act() wrapper
❌ WRONG: act(() => { jest.advanceTimersByTime(0); }) // Sync timer in act
❌ WRONG: Manual timer code in helpers (masks real issues)
✅ CORRECT: jest.useFakeTimers()
✅ CORRECT: await act(async () => { await jest.runOnlyPendingTimersAsync(); })
✅ CORRECT: await waitFor(() => ...) // Preferred!
7. Form Testing - Complete Guide
CANONICAL: Form helpers, validation triggers, and where waiting belongs (call site vs helper).
Core Principle: Form helpers are SYNCHRONOUS. Tests handle all waiting.
Why Helpers Must Be Synchronous:
The Trap: Manual timers in helpers mask the real problem (synchronous assertions in tests).
Anti-Pattern:
// Helper with timer (seems necessary but WRONG)
async function setPolicyName(name: string) {
fireEvent.change(input, { target: { value: name } });
fireEvent.blur(input);
await act(async () => {
await jest.runOnlyPendingTimersAsync(); // WRONG: Masking the problem
});
}
// Test with synchronous assertion (the ACTUAL problem)
await setPolicyName('test');
expectErrorMessages([...]); // WRONG: Checks immediately - no waiting
Correct Pattern:
// Helper - just fire events (synchronous)
function setPolicyName(name: string) {
fireEvent.change(input, { target: { value: name } });
fireEvent.blur(input);
// No timer! Tests handle waiting
}
// Test - wait for the outcome
setPolicyName('test'); // Synchronous call
await waitFor(() => expectErrorMessages([...])); // CORRECT: Waits for validation
The Truth:
- Under REAL timers: waitFor polls for a UI boundary (no timer advancement).
- Under FAKE timers: waitFor advances timers during polling.
- Manual timers are ONLY needed because tests don't wait properly
- When manual timers ARE needed, wrap each call in its own act()
- Fix the test pattern first, timers become unnecessary
Clarification: Central timer-runtime helpers ARE allowed (and often preferred)
The anti-pattern above is NOT “having a helper file”. The anti-pattern is:
- hiding waits/timer flushing inside domain/action helpers that also click/type/fill,
because it makes call sites look synchronous and masks missing UI boundaries.
It IS OK (recommended) to centralize “timer-runtime” operations into a shared utility
(e.g. fake_timers.ts) as long as it only wraps timer APIs + act() correctly and is
used explicitly from tests (or suite hooks) rather than being buried inside action helpers.
Why this is good:
- keeps tests readable (no repeated act()+runOnlyPendingTimersAsync boilerplate)
- guarantees correct act() wrapping for async timer APIs
- provides bounded, intention-revealing helpers like flushUntil(condition)
What this DOES NOT mean:
- do not create a “doEverything” helper that clicks + flushes + asserts
- do not call timer-runtime helpers from form/action helpers (see DON’T below)
DO: shared fake_timers.ts (timer-runtime only)
// fake_timers.ts
import { act } from 'react-dom/test-utils';
export async function flushPendingTimers() {
await act(async () => {
await jest.runOnlyPendingTimersAsync();
});
}
export async function advanceBy(ms: number) {
await act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
}
// Bounded “advance until a DOM boundary is true”
export async function flushUntil(
condition: () => boolean,
{ maxIterations = 10 }: { maxIterations?: number } = {}
) {
for (let i = 0; i < maxIterations; i++) {
if (condition()) return;
await flushPendingTimers();
}
expect(condition()).toBe(true);
}
DO: suite hygiene using the helper (ONLY if the suite opted into fake timers; prevents timer leakage)
import * as fakeTimers from './fake_timers';
// Only if you opted into fake timers for this suite:
beforeAll(() => {
jest.useFakeTimers();
});
// Only if you opted into fake timers for this suite:
afterAll(() => {
jest.useRealTimers();
});
afterEach(async () => {
await fakeTimers.flushPendingTimers();
jest.clearAllTimers();
});
DO: explicit use at call sites when timer chains/debounces are involved
fireEvent.click(screen.getByTestId('openPopover'));
await fakeTimers.flushUntil(() => screen.queryByTestId('popoverContent') !== null);
expect(screen.getByTestId('popoverContent')).toBeInTheDocument();
DO: prefer RTL waits first; use timer-runtime only when needed
fireEvent.click(screen.getByTestId('openPopover'));
await screen.findByTestId('popoverContent'); // preferred, no manual timers
// Only when waitFor/findBy doesn’t flush all queued timer chains under fake timers:
await fakeTimers.flushPendingTimers();
DON’T: bury timer-runtime into action helpers (masking the real wait)
// ❌ Anti-pattern: looks “synchronous” at call sites even though it depends on timers
async function clickSave() {
fireEvent.click(screen.getByTestId('saveButton'));
await fakeTimers.flushPendingTimers(); // hidden waiting/timers
}
// The problem: tests now call clickSave() and immediately assert; when it flakes,
// the fix is unclear because the UI boundary is hidden.
DO INSTEAD:
function clickSave() {
fireEvent.click(screen.getByTestId('saveButton'));
}
clickSave();
// Wait for a real UI boundary (preferred):
await screen.findByText('Saved');
// If timers are legitimately the boundary (rare), do it explicitly at call site:
// await fakeTimers.flushUntil(() => screen.queryByText('Saved') !== null);
Setting Input Values:
- ALWAYS use fireEvent.change for input value changes
- NOT: fireEvent.input (unreliable in jsdom)
- NOT: userEvent.type (unless testing realistic typing behavior)
- Pattern: fireEvent.change(input, { target: { value: 'new value' } })
- Rationale: Matches React's controlled component pattern, fastest
Triggering Validation:
- Validation typically triggers on blur or form submit
- For field validation: fireEvent.blur(input) after setting value
- For form validation: fireEvent.submit(form) OR fireEvent.click(submitButton)
Example:
const input = screen.getByLabelText(/Policy Name/i);
fireEvent.change(input, { target: { value: 'test' } });
fireEvent.blur(input); // Triggers field validation
await screen.findByText('Name is required'); // Wait for error
Waiting for Validation Results:
- ALWAYS wait for validation messages to appear
- Use findBy* to wait for error/success messages
- Don't assume validation is synchronous
Pattern for errors:
fireEvent.blur(input);
await screen.findByText('Field is required'); // Wait for async validation
Pattern for success:
fireEvent.click(submitButton);
await screen.findByText('Successfully saved');
Async Input Effects (Search, Autocomplete):
When input change triggers async side effects (API calls, search):
fireEvent.change(searchInput, { target: { value: 'search term' } });
await screen.findByText('Search Result 1'); // Wait for results
Use case: Debounced search, autocomplete, async validation
Form Submission Testing:
Mock submit handler:
const onSubmit = jest.fn();
render(<MyForm onSubmit={onSubmit} />);
// Fill form
fireEvent.change(nameInput, { target: { value: 'John' } });
// Submit
fireEvent.click(submitButton);
// Wait for outcome
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
Complex Form Interactions - Different Wait Conditions:
// Success case - wait for navigation
fireEvent.click(submitButton);
await waitFor(() => expect(mockNavigate).toHaveBeenCalled());
// Validation error - wait for error message
fireEvent.click(submitButton);
await screen.findByRole('alert');
// Loading state - wait for spinner
fireEvent.click(submitButton);
await screen.findByRole('progressbar');
Wait Logic Placement - Utility vs Call Site:
Decision Matrix:
Q: Where should waitFor go?
Utility is always async (API calls, navigation)?
-> Wait INSIDE utility: async function submitAndWait() { ...; await waitFor(...); }
Utility sometimes sync, sometimes async?
-> Wait at CALL SITE: utility(); await waitFor(...);
Wait condition varies by test?
-> Wait at CALL SITE with test-specific condition
Default: Wait at call site. Tests are more readable when waits are explicit.
8. fireEvent vs userEvent
Decision Matrix:
Q: Which interaction API?
Simple click/change/blur?
-> fireEvent (fastest, synchronous)
EUI portals/overlays (EuiPopover, context menus) AND act warnings persist even after correct awaits?
-> Use userEvent for the specific interaction(s) that trigger the warning (keep it localized).
Text entry (input value changes) - which one?
-> fireEvent.change + fireEvent.blur (default, fastest; good when you only care about final value + blur-driven validation)
-> userEvent.paste (use when the input is inside an EUI overlay/popover/portal OR fireEvent.change causes act warnings;
sets the full value in one step; typically faster than userEvent.type)
-> userEvent.type (use when per-keystroke behavior is under test: keydown handlers, suggestions, debounce-on-each-char,
masking/formatters, or you need special keys like {Enter}/{ArrowDown})
Typing with special keys (Enter, Tab, Arrow)?
-> userEvent with keyboard API
Keyboard vs Type (important distinction):
- Use user.keyboard(...) for key-driven UI behavior that does NOT require changing an input’s value
(Escape to close a listbox/popover, Enter/Arrow navigation, Tab focus management).
- Use user.type(...) when the intent is text entry/value changes (and you care about per-keystroke events).
Realistic user behavior important?
-> userEvent (simulates actual events)
Performance critical?
-> fireEvent (no event simulation overhead)
fireEvent Patterns:
fireEvent.click(button); // Synchronous
fireEvent.change(input, { target: { value: 'text' } });
fireEvent.blur(input);
fireEvent.submit(form);
userEvent Patterns:
// Default setup (REAL timers):
const user = userEvent.setup();
// If (and only if) the suite uses fake timers:
// Note: Passing jest.advanceTimersByTime here is OK (userEvent needs a sync callback).
// Do not call jest.advanceTimersByTime directly in tests; prefer async timer APIs in act() (Pattern 6).
// const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.click(button); // Async!
await user.type(input, 'text');
await user.paste(input, 'text');
await user.keyboard('{Enter}');
await user.tab();
Perf note (important):
- userEvent.type is often significantly slower than fireEvent.change in CI.
- If you just need “set a value”, prefer fireEvent.change (and blur when validation is blur-driven).
- If fireEvent.change triggers act warnings in a portal/overlay context, prefer userEvent.paste over userEvent.type
to set the full value in one step (still user-like, usually faster than per-char typing).
userEvent lifecycle (avoid accidental state/overhead):
- Prefer one userEvent instance PER TEST (in beforeEach inside the describe):
let user;
beforeEach(() => { user = userEvent.setup(); });
- Avoid helper footgun:
async function click(el) { const user = userEvent.setup(); await user.click(el); }
This silently creates a new userEvent instance per call and adds needless indirection.
Nuance (copy/paste-safe rules):
- fireEvent.change triggers React onChange for typical inputs; follow with fireEvent.blur when validation/formatting is blur-driven.
- userEvent.paste DOES trigger onChange for normal inputs/textarea (it changes the value and fires the expected events),
but it does NOT exercise keydown/keypress/keyup paths or per-character intermediate states.
- Therefore:
- If the component behavior depends on key events or intermediate values, use userEvent.type.
- If the test only cares about the final value and downstream UI reaction, use fireEvent.change+blur (default) or userEvent.paste
(overlay/popover/act-warning exception).
Key Difference:
- fireEvent: Fires single event directly (synchronous)
- userEvent: Simulates full user interaction (async, multiple events)
Example: userEvent.click fires mousedown, mouseup, click in sequence
III. COMPONENT PATTERNS
(Specific component testing techniques)
9. Router Mocking
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({ name: 'test-policy' }),
useHistory: () => ({
push: jest.fn(),
location: { search: '' },
}),
}));
10. License & Feature Flags
Mock license as valid:
httpRequestsMockHelpers.setLoadPolicies([mockPolicy]);
httpRequestsMockHelpers.setGetLicense({ license: { status: 'active', type: 'trial' } });
Or mock license hook:
jest.mock('../path/to/license', () => ({
useLicense: () => ({ isActive: true, isGoldPlus: true }),
}));
11. Portal Components
Portal components render outside normal DOM tree. Query from document:
// For modals, flyouts, tooltips
const modal = document.querySelector('[data-test-subj="confirmModal"]');
expect(modal).toBeInTheDocument();
// Or use within on document.body
const confirmButton = within(document.body).getByTestId('confirmModalButton');
EuiPopover - Act() Warnings:
Problem: EuiPopover renders as a portal and can cause act() warnings when using getBy* selectors.
Solution: Convert getBy* selectors to await findBy* for elements inside or related to EuiPopover.
❌ WRONG:
fireEvent.click(button);
const popoverContent = screen.getByTestId('popoverContent'); // Causes act() warning
expect(popoverContent).toBeInTheDocument();
✅ CORRECT:
fireEvent.click(button);
const popoverContent = await screen.findByTestId('popoverContent'); // Waits for portal to render
expect(popoverContent).toBeInTheDocument();
Rationale: EuiPopover renders asynchronously in a portal. Using findBy* waits for the portal to mount and prevents act() warnings.
12. EUI Select Components
CANONICAL: EUI select interaction patterns (ComboBox/SuperSelect).
12A. EUI ComboBox (Searchable Multi-Select)
// Open combo box
fireEvent.click(screen.getByTestId('comboBoxToggleListButton'));
// Wait for options
await screen.findByTestId('comboBoxOptionsList');
// Select option
fireEvent.click(screen.getByText('Option Text'));
IMPORTANT harness nuance (EuiComboBoxTestHarness):
- EuiComboBoxTestHarness.selectedOptions reads from pills (data-test-subj="euiComboBoxPill").
- Therefore EuiComboBoxTestHarness.selectOptionAsync() relies on pills appearing to confirm selection.
- If the ComboBox is configured with singleSelection: { asPlainText: true }, it may NOT render pills.
In that case, selectOptionAsync() can fail even when the UI selection is correct.
- What to do instead:
- Use the sync selection API (selectOption(...)) and then wait on a concrete downstream UI boundary
(e.g. Next button enabled, validation cleared, summary text updated).
- Or (if you need async search behavior), wait on the listbox/options UI boundary, click the option,
and assert via a DOM boundary that actually changes for the asPlainText variant (not pills).
12B. EuiSuperSelect (Single-Select Dropdown)
EuiSuperSelect renders as button + portal listbox with role="option" items.
Pattern: Direct Option Selection by ID
const button = within(container).getByRole('button');
fireEvent.click(button);
await screen.findByRole('listbox');
const option = document.getElementById('whitespace')!; // Value is the id
fireEvent.click(option);
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
Key Facts:
- Options have role="option" and id={value}
- Option values become element ids: <option id="standard" ...>
- More reliable than test-subj which may not exist on options
- Must wait for listbox to close before next interaction
Discovery Source: src/platform/packages/shared/kbn-scout/src/playwright/eui_components/super_select.ts
12C. Wait for Dropdown Closure Pattern
Problem: Race conditions when selecting multiple dropdowns in sequence
Solution: Wait for current dropdown to close before opening next
✅ CORRECT:
fireEvent.click(option1);
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
// Now safe to open next dropdown
fireEvent.click(button2);
❌ WRONG:
fireEvent.click(option1);
fireEvent.click(button2); // May fail - listbox still open
Rationale: EUI dropdowns are portals that can interfere with subsequent interactions
Note: If you need to wait for a follow-up UI boundary after the dropdown closes (e.g. validation enables Next),
keep the waits separate (Pattern 4: Element Queries & Async Waiting).
Space-separated data-test-subj:
- Canonical rule + examples are in Pattern 5: "CRITICAL: Space-Separated data-test-subj Limitation".
- In practice for EUI dropdowns/options lists, prefer the RegExp matcher:
screen.queryByTestId(/myTestSubj-optionsList/)
- Use document.querySelector('[data-test-subj*="..."]') only as a last resort.
13. Tab Navigation
// Click tab
fireEvent.click(screen.getByRole('tab', { name: /Settings/i }));
// Wait for tab panel
await screen.findByRole('tabpanel');
IV. HTTP MOCKING
(API request testing patterns)
14. HTTP Request Mocking
httpRequestsMockHelpers.setLoadPolicies([
{ name: 'policy1', phases: { hot: { ... } } }
]);
// Verify calls
expect(httpSetup.get).toHaveBeenCalledWith('/api/policies');
15. Async HTTP Responses
// Delay response
httpSetup.get.mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve({ data }), 100))
);
// Wait for loading to complete
await waitForElementToBeRemoved(screen.getByTestId('loading'));
Testing Loading States with Render Helpers That Advance Timers:
Problem: Render helpers (like renderHome) advance timers internally to flush
async operations. When testing loading states with delayed responses, the helper
advancing timers can resolve the delayed response before you can assert the
loading state appeared.
Solution: Split the promise - start the render, wait for loading state to
appear, THEN await the render promise (which will advance timers and resolve).
Pattern:
setDelayResponse(true);
httpRequestsMockHelpers.setLoadDataResponse([]);
const renderPromise = renderHome(httpSetup, {
initialEntries: ['/my_route'],
});
// Wait for loading state to appear BEFORE renderHome completes
expect(await screen.findByTestId('sectionLoading')).toBeInTheDocument();
// Now await renderHome to complete (may advance timers internally; resolves delayed response)
await renderPromise;
Rationale:
- renderHome() may advance timers internally, which would resolve delayed responses
- By splitting the promise, we can catch the loading state before resolution
- This pattern is needed when testing intermediate states (loading indicators)
- Only use when the render helper advances timers AND you need to test loading state
16. HTTP Mock State Management
Problem: Old mock responses persist and contaminate subsequent tests.
Solution:
beforeEach(() => {
jest.clearAllMocks(); // Preferred: clears all mocks at once
// Re-register default responses
httpRequestsMockHelpers.setLoadPolicies([]);
});
Rule: Prefer a single top-level beforeEach for mock reset per file.
Why: Clearing mocks in multiple nested describe blocks is redundant and can hide the actual setup differences
between scenarios. Instead, use nested describe blocks only to apply additional scoped setup/overrides.
Preferred reset order (when spies exist):
- If the file uses jest.spyOn(), prefer:
beforeEach(() => {
jest.restoreAllMocks(); // restore spied originals from previous test
jest.clearAllMocks(); // clear call history for fresh assertions
// recreate any spies needed for this test after restoreAllMocks
});
Note: Use individual mockClear()/mockReset()/mockRestore() only when you need
targeted behavior; prefer the global helpers when possible.
TypeScript Type Safety:
Use jest.mocked() for type-safe mock access:
const mockPost = jest.mocked(httpSetup.post);
mockPost.mockClear();
const calls = mockPost.mock.calls;
Benefits:
- TypeScript knows mock properties (mock.calls, mockClear, etc.)
- Avoids "Property mockClear does not exist" errors
- Works with strict TypeScript settings
V. TEST ORGANIZATION
(Structure and patterns)
17. Test File Structure
MIGRATION RULE: Preserve existing test names and ordering
Rule: When migrating Enzyme -> RTL, keep existing describe()/it() titles and their relative order.
Why: Minimizes diff churn and makes review/debugging easier.
Exception: Only rename/reorder/consolidate tests when it is a clear net benefit (readability, flake reduction, or removing redundancy), and explicitly call that out in the PR/commit message.
MIGRATION VERIFICATION: f-jest-test-title-report (mandatory)
Rule: Every Enzyme -> RTL migration MUST prove test coverage parity by comparing test titles between a BEFORE worktree and the AFTER worktree.
Why: Title diffs are the fastest way to detect accidental coverage drops (deleted tests) or untracked restructures during migration.
Workflow (always local-first):
1) Generate a local CSV report (NO gist).
- Only after the report is clean should you create a gist for sharing.
2) If tests were intentionally renamed/merged/consolidated, use --replacements to explicitly map old titles -> new titles.
- Run once without replacements to see raw deltas.
- Then run again with replacements to confirm all deltas are accounted for.
CLI (required args):
- --before <abs path> Absolute path to BEFORE repo worktree (usually main)
- --after <abs path> Absolute path to AFTER repo worktree (your migration branch/worktree)
- --scope <rel path> Relative directory inside the repo to scan
- --out <abs path> Absolute output path for the CSV
Important:
- Do not rely on --help; the script currently crashes when required args are missing.
- Default expectation is 1:1; do NOT use replacements to hide missing coverage.
Local report (no gist):
f-jest-test-title-report --before "/ABS/PATH/TO/BEFORE_WORKTREE" --after "/ABS/PATH/TO/AFTER_WORKTREE" --scope "RELATIVE/SCOPE/INSIDE/REPO" --out "/tmp/<scope>.jest_titles.before_after.csv"
Pass criteria (no replacements):
- 0 added tests (AFTER-only rows)
- 0 removed tests (BEFORE-only rows)
- 0 changed titles (BEFORE != AFTER)
Replacements (for intentional rename/merge/consolidation):
f-jest-test-title-report --before "/ABS/PATH/TO/BEFORE_WORKTREE" --after "/ABS/PATH/TO/AFTER_WORKTREE" --scope "RELATIVE/SCOPE/INSIDE/REPO" --out "/tmp/<scope>.jest_titles.before_after.csv" --replacements "/tmp/<scope>.jest_title_replacements.json"
Replacements JSON format:
- Keys are repo-relative test file paths (as shown in the report FILE column)
- Values are mappings of old title -> new title
{
"x-pack/.../some.test.tsx": {
"old title A": "new consolidated title B",
"old title C": "new consolidated title B"
}
}
Pass criteria (with replacements):
- 0 added tests and 0 removed tests
- 0 unaccounted title changes
- Consolidations are only allowed when they are explicitly mapped via replacements and justified in the PR description/commit message
Only after pass -> create/update gist:
f-jest-test-title-report --before "/ABS/PATH/TO/BEFORE_WORKTREE" --after "/ABS/PATH/TO/AFTER_WORKTREE" --scope "RELATIVE/SCOPE/INSIDE/REPO" --out "/tmp/<scope>.jest_titles.before_after.csv" --gist
Example (Snapshot & Restore client integration scope):
f-jest-test-title-report --before "/tmp/kibana-before-main-<sha>" --after "/ABS/PATH/TO/AFTER_WORKTREE" --scope "x-pack/platform/plugins/private/snapshot_restore/__jest__/client_integration" --out "/tmp/snapshot_restore.jest_titles.before_after.csv"
NAMING CONVENTION SCOPE: WHEN/SHOULD is for new tests only
Rule: Use describe('WHEN ...') and it('SHOULD ...') for net-new tests.
Existing tests may keep their current naming. Do NOT rename tests solely to enforce WHEN/SHOULD.
describe('ComponentName', () => {
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
});
Note: Keep this beforeEach at the top-level of the file's main describe().
Avoid adding additional nested describe-level beforeEach blocks that only call jest.clearAllMocks().
Nested beforeEach is fine when it adds scenario-specific setup (different mock responses, props, routing),
but re-clearing mocks alone is redundant.
describe('WHEN condition', () => {
it('SHOULD expected behavior', async () => {
// Arrange
render(<Component />);
// Act
fireEvent.click(screen.getByText('Button'));
// Assert
await screen.findByText('Result');
});
});
});
18. No Manual cleanup()
Jest auto-cleanup between tests. NEVER call cleanup() manually.
19. Increasing Individual Test Timeout
Note: For waitFor timeout guidance, see Section 4 (Element Queries & Async Waiting).
Algorithm for Increasing Timeouts:
Decision Process:
1. Test takes >1.2s locally?
→ NO: Use default waitFor timeout (1000ms), investigate root cause
→ YES: Continue to step 2
2. Account for CI slowdown:
- Local time: 1.2s
- CI machines typically 2-3x slower: 2.4-3.6s
- Jest default test timeout: 5000ms (5s)
- If CI time < Jest default: default is sufficient
- If CI time > Jest default: increase test-level timeout
3. Increase test-level timeout (not waitFor-specific) only if needed:
- Calculate: (local_time * 3) + safety_margin
- Example: 1.2s locally → 3.6s CI → Jest default (5s) is sufficient
- Example: 2s locally → 6s CI → increase to 10000ms (10s) test timeout
- Example: 3s locally → 9s CI → increase to 15000ms (15s) test timeout
Rationale:
- CI machines are slower (2-3x multiplier)
- Jest default timeout (5s) covers most cases up to ~1.2s local time
- Only increase test-level timeout when CI time exceeds Jest default
- Test-level timeout applies to entire test, not just one waitFor
- Prevents flakiness from CI slowdown without over-engineering individual waits
Test-Level Timeout:
Jest default timeout is 5000ms (5s). Only increase if test needs more:
// Default (5s) - no need to specify for ~1.2s local operations
it('SHOULD handle slow operation', async () => {
// test code
});
// Increase only when CI time exceeds Jest default (5s)
it('SHOULD handle very slow operation', async () => {
// test code (2s+ locally, 6s+ on CI)
}, 10000); // 10 second timeout
Or for all tests in file:
jest.setTimeout(10000); // 10 seconds for file with multiple slow operations
20. Test Isolation
Each test must be independent:
- No shared state between tests
- No test order dependencies
- Clean mock state in beforeEach
Anti-pattern:
let sharedComponent;
beforeAll(() => { sharedComponent = render(<Component />); });
it('test 1', () => { /* uses sharedComponent */ });
it('test 2', () => { /* uses same sharedComponent - BAD */ });
Correct:
it('test 1', () => { render(<Component />); /* ... */ });
it('test 2', () => { render(<Component />); /* ... */ });
setupEnvironment() Pattern:
Problem: Calling setupEnvironment() at the top level creates shared mock state
that persists across tests, causing contamination between tests.
Anti-pattern:
const { httpSetup, httpRequestsMockHelpers, setDelayResponse } = setupEnvironment();
describe('Component', () => {
it('test 1', () => { /* uses shared httpSetup */ });
it('test 2', () => { /* uses same shared httpSetup - BAD */ });
});
Correct:
describe('Component', () => {
let httpSetup: ReturnType<typeof setupEnvironment>['httpSetup'];
let httpRequestsMockHelpers: ReturnType<typeof setupEnvironment>['httpRequestsMockHelpers'];
let setDelayResponse: ReturnType<typeof setupEnvironment>['setDelayResponse'];
beforeEach(() => {
jest.clearAllMocks();
const mockEnvironment = setupEnvironment();
httpSetup = mockEnvironment.httpSetup;
httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers;
setDelayResponse = mockEnvironment.setDelayResponse;
});
it('test 1', () => { /* uses fresh httpSetup */ });
it('test 2', () => { /* uses fresh httpSetup - GOOD */ });
});
Rationale:
- Each test gets a fresh environment with clean mock state
- Prevents mock responses from one test affecting another
- Ensures test independence and prevents order dependencies
- jest.clearAllMocks() clears all mocks before creating new environment
21. Test Performance Quick Wins
Order of impact:
1. Replace ByRole with ByTestId (20-30% improvement)
2. Use fireEvent instead of userEvent where possible
3. Remove unnecessary waitFor wrappers (use findBy instead)
4. Reduce test scope (test one behavior per test)
Detection:
Slow tests often have:
- Multiple getByRole/findByRole queries
- userEvent for simple interactions
- waitFor wrapping getBy (should be findBy)
- Single test validating multiple unrelated behaviors
Fix: Split into focused tests
Exception: Don't split if setup overhead dominates (>50% of test time)
Quick Diagnostic Commands:
# Find slow tests
SHOW_ALL_SLOW_TESTS=true node scripts/jest <path>
# Check for heavy selectors
rg "getByRole|findByRole|getAllByRole" <test-file>
# Check for userEvent
rg "userEvent\.(click|type|keyboard)" <test-file>
22. Test Splitting Strategy
When to Split:
- Test validates multiple independent behaviors
- Test >3s AND >50 lines
- Setup time <50% of total test time
Benefits:
- Faster execution
- Better failure isolation
- Clearer intent
- Easier maintenance
When NOT to Split:
- Setup overhead dominates time (>50% of test)
- Test validates true integration behavior (not independent units)
Decision Rule:
Split if: Test body >3s AND setup <1s
Keep unified if: Setup >50% of test time
VI. REFACTORING LEGACY CODE
(Migration patterns for existing Enzyme tests)
23. Systematic Utility Refactoring
Problem: Codebase has test utilities using manual timer advancement.
WRONG: File-by-file refactoring (breaks tests unpredictably)
CORRECT: Utility-by-utility refactoring (isolated impact)
Workflow (one utility at a time):
1. Pick ONE utility function with timer code (e.g., clickSubmitButton)
2. Remove timer advancement from utility itself
3. Find ALL call sites: rg "utilityName\(" --files-with-matches
4. Fix EACH call site with appropriate waitFor
5. Verify each changed file at narrowest scope (Pattern 2)
6. Run full suite when utility refactoring complete
7. Move to next utility
Why this works:
- Isolated impact: Only call sites of current utility affected
- Fast feedback: Verify each file immediately
- Clear debugging: Know which utility caused issues
- Incremental progress: Each utility is stable checkpoint
Critical Pitfall - Missing Await:
Problem: Remove timers from utility, but forget utility is still async.
WRONG (missing await):
waitForValidation(); // No await!
expect(someCondition()).toBe(true); // Runs immediately!
CORRECT:
await waitForValidation(); // Awaited
expect(someCondition()).toBe(true); // Runs after validation
Detection:
- TypeScript/ESLint: @typescript-eslint/no-floating-promises
- Manual: rg "utilityName\(" | rg -v "await.*utilityName"
Common scenarios:
- Utility was async for timer, still async for waitFor
- Utility signature changes when removing timer parameters
- All call sites must update when signature changes
- Use TypeScript compiler errors as checklist
24. Enzyme Artifact Detection & Cleanup
Search patterns:
- .exists()
- .find()
- .simulate
- shallow
- mount
- wrapper
Replace with:
- screen.getBy/queryBy
- fireEvent
- render
Common artifact:
WRONG: expect(wrapper.find('Component').exists()).toBe(true)
CORRECT: expect(screen.getByTestId('component')).toBeInTheDocument()
25. Helpers vs Testbed/Actions Architecture (keep indirection low)
Problem: Tests become hard to read and debug when they turn into a mini-framework:
- “testbed” setup() + actions object + page objects + helpers layers
- intent is spread across many files; failures require jumping through indirection
Anti-pattern (high indirection):
Test file -> .helpers.tsx -> Action creators / actions object -> Implementation (4 layers)
Goal:
- Keep the test body readable (Arrange -> Act -> Assert)
- Allow helpers when they improve readability, but avoid turning helpers into a DSL/framework
Helpers are OK (often preferred) when they buy readability.
The thing we avoid is “testbed/actions architecture”, not “having helper functions”.
When to extract helpers (practical triggers):
- Duplication trigger: same 4-8 lines repeated in 3+ tests
- Readability trigger: test body no longer fits on one screen / hard to scan A/A/A
- Noise trigger: boilerplate (timers, portal scoping, selectors) overwhelms intent lines
Helper shape rules (keep helpers shallow):
- Prefer helpers that do ONE thing:
- query helpers (get the right element deterministically)
- interaction helpers (perform one interaction, like “open dropdown” or “select option”)
- data builders (fixtures/factories)
- Avoid “workflow helpers” that do many unrelated steps and hide control flow.
Location rules (local first, shared when proven):
- Default: file-local helpers (at bottom of the test file) for readability and low indirection
- If helpers grow, it is OK to extract a file-local helper module next to the test
(e.g. foo.helpers.ts(x) / helpers.ts(x) / test_helpers.ts(x)) as long as:
- it is still low indirection (small functions, no actions object)
- imports are direct (see Pattern 27: no barrel files)
- Extract shared helpers only when reused across multiple files AND stable.
Naming rule (prevent ambiguity without churn):
- Under __jest__/**:
- Prefer *.helpers.ts(x) for extracted helpers that are tightly coupled to a single test file or test folder.
- Rationale: keeps “this is test-only” clear via the directory boundary; avoids redundant “test_” prefixes.
- Outside __jest__/** (helpers adjacent to source code):
- Avoid ambiguous names that could be mistaken for production helpers.
- Prefer file-local helpers, or a clearly test-scoped filename like test_helpers.ts(x) when a separate module is needed.
- Rule of thumb: choose the name that makes it obvious whether the helper is test-only without needing to inspect imports.
Waiting/timers rule (preserve correctness):
- Do not hide waiting inside domain/action helpers (see Pattern 6 and Pattern 7).
- If a helper MUST await, make it obvious in name + signature (async + “AndWait/Until”),
and keep the wait condition specific (not “flushAll”).
Examples:
✅ Good: small local helpers that reduce noise but keep the UI story visible
function getSaveButton() {
return screen.getByTestId('saveButton');
}
function clickSave() {
fireEvent.click(getSaveButton());
}
clickSave();
await screen.findByText('Saved');
✅ Good: file-local helper module when file becomes too large (still no DSL)
// helpers.ts (same folder as test)
export function openAdvancedOptions() {
fireEvent.click(screen.getByTestId('advancedOptionsButton'));
}
// test
openAdvancedOptions();
await screen.findByTestId('advancedOptionsPanel');
❌ Bad: actions object / DSL that hides what happens and where to wait
await actions.form.save(); // what did it click? what boundary does it wait for?
await actions.toast.expectSuccess(); // now assertions are hidden too
Note: Jest auto-cleanup between tests. NEVER call cleanup() manually (see Pattern 18).
Gradual Migration - Mixed Testbed/RTL Files:
Problem: When testbed (Enzyme) and RTL tests coexist in same file,
Enzyme-rendered portals/modals persist into subsequent RTL tests.
Cause: RTL auto-cleanup only cleans RTL-rendered components.
Enzyme/testbed uses different React root; its DOM persists.
Portals (modals, flyouts) render outside React root.
Symptoms:
- RTL test fails with unexpected modal/overlay blocking interaction
- DOM shows elements from previous testbed test (e.g., deleteConfirmationModal)
- waitFor timeouts because UI state polluted by prior test
Solution - Migrate Consecutive Sections:
- Migrate adjacent describe blocks together to RTL
- Avoid testbed->RTL boundaries within same file
- RTL->RTL and testbed->testbed boundaries work correctly
Example:
WRONG: [RTL] -> [testbed] -> [RTL] (middle testbed pollutes third RTL)
CORRECT: [RTL] -> [RTL] -> [RTL] (consecutive RTL, clean boundaries)
26. Avoid Plugin-Specific Harness/Testbed Frameworks (prefer lightweight helpers)
Goal: keep plugin tests readable without building a mini testing framework.
Use @kbn/test-eui-helpers ONLY for EUI-specific component harnesses.
For plugin tests, prefer lightweight helpers over harness frameworks:
✅ OK:
- small file-local helpers (inline) to reduce repetition
- file-local helper module when the test file is overflowing readability
- small shared utilities when they are reused across multiple files (e.g. fake_timers.ts)
❌ Avoid:
- “actions” objects and page-object-style DSLs that hide selectors and waits
- deep helper stacks (test -> helpers -> actions -> helpers -> implementation)
- plugin-specific “harness” layers that duplicate what RTL already provides
Rationale:
- Helpers are a sweet spot: they improve readability without sacrificing debuggability.
- Framework-like testbed architecture creates too much indirection and slows iteration.
27. No Barrel Files (Index Imports)
Problem: Barrel files (index.ts) increase Jest parsing times and can re-export Enzyme artifacts during migration.
CORRECT: import { setupEnvironment } from './helpers/setup_environment'
CORRECT: import { defaultTextParameters } from './datatypes/fixtures'
WRONG: import { setupEnvironment } from './helpers'
WRONG: import { defaultTextParameters } from './datatypes'
Rationale:
- Direct imports reduce Jest parsing overhead
- Avoids accidental re-exports of Enzyme artifacts
- Clearer dependency tracking
- Better tree-shaking in test builds
Rule: Always import directly from source files, never from index.ts barrel files in test directories.
28. No Imports from Test Files
Problem: Importing from other test files (e.g., *.test.tsx, *.test.ts) causes test leaking.
Jest will execute the imported test file as separate tests when it's imported, leading to:
- Duplicate test execution
- Unexpected test failures in unrelated files
- Confusing test output
- Slower test runs
CORRECT: import { defaultDateRangeParameters } from './datatypes/fixtures'
CORRECT: import { setupEnvironment } from './helpers/setup_environment'
WRONG: import { defaultDateRangeParameters } from './datatypes/date_range_datatype.test'
WRONG: import { someHelper } from './other_test.test'
Rationale:
- Test files contain test code that Jest executes on import
- Shared constants/fixtures should be in dedicated fixture files (fixtures.ts, helpers.ts)
- Prevents test leakage and duplicate execution
- Keeps test boundaries clear
Rule: Never import from files ending in .test.ts, .test.tsx, .spec.ts, or .spec.tsx.
Move shared test data to dedicated fixture files instead.
VII. DEBUGGING & TROUBLESHOOTING
(Problem-solving techniques)
29. Semantic Code Search for Debugging
When tests fail in your branch but you suspect they worked before:
- Use semantic_code_search to check main branch
- The MCP tool searches main branch by default
- Instant access to reference implementations
Pattern:
1. Search for test file or helper files
2. Compare implementations, not just test structure
3. Look for differences in: default routes, function signatures, test setup patterns
ALWAYS use when:
- This test should work but doesn't
- How did this work before?
Advantage: No need for git worktrees or checkouts - instant main branch access.
30. Console Log Debugging for Data Flow
When form state doesn't match UI state:
- Trace data flow with strategic console.logs
- Add logging at transformation points: helpers, serializers, components
- Reveals mismatches invisible in test output
Example:
fireEvent.change(input, { target: { value: 'test' } });
console.log('Form state:', form.getValues());
console.log('Serialized:', serializeForm(form));
Remove console.logs after debugging.
31. Common Errors Reference
Error: Found multiple elements by: [data-test-subj=...]
Cause: Component rendered twice (e.g., describe() + beforeEach() both call setup)
Fix: Remove duplicate render/setup call(s). If you have helpers, ensure they don’t re-render or re-run setup implicitly.
Rule: One render per test (RTL cleanup handles it between tests)
Error: Found multiple elements by: [data-test-subj="fieldsList"] (or other “list container” ids)
Cause: Some UIs render multiple lists (e.g. nested trees render multiple <ul data-test-subj="fieldsList">).
Fix:
- Don’t assume container test ids are globally unique.
- Prefer querying the real unit of interaction (list items) by a stable prefix:
within(documentFields).getAllByTestId((content) => content.startsWith('fieldsListItem '))
- Then locate the correct item by its own fieldName element (see Pattern 5: multiple elements + filtering).
See also: Pattern 5 (query strategy), Pattern 37 (harnesses for generic list mechanics).
Error: Found multiple elements by: [data-test-subj="toggleExpandButton"] / [data-test-subj="editFieldButton"]
Cause: Nested list items can be rendered inside a parent list item; “within(parent)” may see both parent + child buttons.
Fix:
- First identify the correct list item (root vs nested) using the fieldName element that belongs to THAT list item.
(Guard against matching a nested child: if fieldName is inside another fieldsListItem before reaching the candidate, reject.)
- Then query inside that list item for the button.
- Prefer role + accessible name where available (e.g. expand/collapse buttons include the field name).
See also: Pattern 5 (scope before you query).
Error: Found multiple elements with the text: Edit / Clone / Delete
Cause: EUI can render screen-reader-only nodes and visible context menu text nodes for the same label.
Fix:
- Prefer selecting the clickable item by role + accessible name, scoped to the open popover panel (portal):
- Find the open popover panel ([data-popover-panel]) and then query within that panel
for role='button' or role='link' with name=label.
- If you need reuse, use EuiPopoverPanelTestHarness + EuiContextMenuTestHarness (Pattern 37) so tests stay intent-focused.
- Avoid relying on EUI internal classnames (e.g. euiContextMenuItem__text) for this.
See also: Pattern 5 (avoid ambiguous ByText; prefer role/button selection + scoping), Pattern 11 (portals).
Error: Functions are not valid as a React child
Cause: Component function referenced but not called/rendered
WRONG: {MigrationGuidance} // Function reference
CORRECT: {MigrationGuidance({ docLinks })} // Called with props
CORRECT: <MigrationGuidance docLinks={docLinks} /> // Rendered as JSX
Error: Element not found (but element exists in UI)
Cause: License check failed, component didn't render
Fix: Mock license in test setup (see Pattern 10)
Error: Cannot read properties of undefined (reading 'url')
Cause: Router context missing
Fix: Mock react-router-dom hooks (see Pattern 9)
Act warning with EuiPopover
Cause: EuiPopover renders asynchronously in a portal; getBy* selectors query before portal mounts
Fix: Convert getBy* to await findBy* for elements inside or related to EuiPopover (see Pattern 11)
Warning: Cannot update a component (`X`) while rendering a different component (`Y`)
Meaning: Render-phase update (a child triggers a parent setState/update during render). This is not an RTL await issue.
Default (migration):
- Do NOT add console.error allowlists/filters to hide this warning.
- Do NOT change source code by default.
What to do instead:
- Surface it to the user/reviewer with the exact warning + stack trace context.
- Suggest a fix, but only implement with explicit approval.
(In most cases the fix is to avoid cross-component render-phase updates; e.g. move the update to an effect in the state owner.)
- If source changes are approved, keep them minimal and compatible (e.g. data-test-subj placement/value changes are OK; avoid behavior changes unless explicitly requested).
32. Test Helper Type Definitions
Problem: TypeScript cannot infer properties when intersecting multiple function return types.
WRONG - Pure intersection type:
let helpers: ReturnType<A> & ReturnType<B>;
CORRECT - Explicit properties + intersection:
const helpers: { prop1: Type1; prop2: Type2 } & ReturnType<A> & ReturnType<B>;
General rule:
- Functions return functions -> explicit
- Objects return objects -> intersection
33. Test Helper Return Types - Async vs Sync
Common mistake: Treating synchronous render helpers as async.
RTL render functions return RenderResult synchronously.
Check helper signature:
- Returns Promise? -> Use await
- Returns RenderResult/object? -> No await, use directly
Remove await if helper is synchronous; use screen queries directly.
34. Setup Functions - Return Stable Objects
Setup functions should return object with stable references.
CORRECT: const result = setup(); return result; // Use result consistently
WRONG: Call setup() multiple times in same test
Rationale: Avoids stale closure issues.
35. Skipped Tests - Unskip and Fix
Skipped tests (it.skip, describe.skip, xtest) are usually disabled due to CI flakiness.
Look for comments with GitHub issue links explaining why.
Rule: Skipped tests are NOT dead code. Do not delete describe.skip()/it.skip blocks during migration.
Why: The skip is usually a temporary quarantine for flake/instability; removing loses coverage and history.
Exception (rare): Only remove skipped tests when you can prove the feature/test is obsolete by design
(e.g. feature removed) and you can link the removal PR/issue in the PR description.
Migration responsibility:
- Unskip the test
- Fix root cause (apply patterns from this bead: isolation, timers, splitting, fireEvent)
- Track closure in the concluding PR description (e.g. Fixes/Closes #XXXXX). Do not add inline "Fixes" comments in test code.
Common flakiness causes (all covered by existing patterns):
- Inter-test state leak -> Pattern 20 (test isolation)
- Timeouts -> Pattern 21 (performance), Pattern 19 (individual timeouts). Pattern 6 only if fake timers measurably help (speed/stability).
- Heavy interactions -> Pattern 8 (fireEvent over userEvent)
- Large test files -> Pattern 22 (test splitting)
36. Hybrid Mocks for Incremental Migration
Problem: Component mocks must support BOTH Enzyme and RTL during gradual migration.
Critical Insight: Enzyme's simulate() creates synthetic events WITH .target property.
Naive check (if syntheticEvent.target) fails - both Enzyme and RTL events have .target.
Solution: Check for array-like structure FIRST (Enzyme uses numeric indices).
Pattern:
Keep this logic local to the mock implementation and avoid any. Use unknown + narrow checks.
Example (plain text):
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null;
const isArrayLikeWithZero = (v: unknown): v is { 0: unknown } =>
isRecord(v) && '0' in v;
EuiComboBox: (props: { onChange: (v: Array<{ label: string; value: string }>) => void }) => (
<input
data-test-subj="mockComboBox"
onChange={(evt: unknown) => {
// Enzyme simulate() often passes an array-like object where [0] is the “selected option”.
if (isArrayLikeWithZero(evt)) {
const first = (evt as { 0: unknown }).0;
// This is intentionally permissive; adapt shape if your Enzyme path carries a richer object.
if (isRecord(first) && typeof first.label === 'string' && typeof first.value === 'string') {
props.onChange([ { label: first.label, value: first.value } ]);
}
return;
}
// RTL fireEvent.change passes a DOM event with target.value
if (isRecord(evt) && isRecord(evt.target) && typeof evt.target.value === 'string') {
const value = evt.target.value;
props.onChange([ { label: value, value } ]);
}
}}
/>
)
Detection Logic:
- syntheticEvent['0'] === undefined → not array-like → RTL
- syntheticEvent['0'] !== undefined → array-like → Enzyme
Application:
- Use for EUI component mocks during migration
- Enables incremental test migration (no big-bang)
- Remove hybrid logic after full migration
Example: Index Management EuiComboBox - unblocked 38 tests for incremental migration.
37. EUI Harness Implementation Guidelines
When creating or modifying EUI harnesses in @kbn/test-eui-helpers:
Harness API semantics (hard rules for consistency):
Rule: All non-action public get*() methods behave like queries.
Why: Test authors can decide whether missing elements should fail the test, and how.
✅ DO:
- Return null/empty (not throw) for lookups like getElement/getMenuItem/getBody/getOptions/getSelected/getButtons.
- Keep action methods (click/select/toggle/refresh) responsible for throwing when they cannot proceed.
❌ DON'T:
- Mix throwing getters with non-throwing getters in the same harness.
- Add both queryX() and getX() with the same behavior (pick one API).
Rule: No dynamic DOM-query getters (public get foo()).
Why: Properties read as static data, but DOM queries are dynamic and can be null; this is ambiguous in tests.
✅ DO: Prefer explicit methods (getFoo()) returning null/empty.
❌ DON'T: public get body()/options()/selected() that call querySelector/getBy* internally.
Rule: Avoid backwards-compat aliases inside harnesses; update call sites instead.
Why: It doubles surface area and makes “the right API” unclear.
Rule: Do not add new dependencies for harness logic.
Why: Harnesses must stay lightweight and easy to consume across Kibana tests.
✅ DO: Prefer RTL queries + basic DOM attributes (aria-label/title/textContent) when needed.
❌ DON'T: Pull in extra libs (e.g. dom-accessibility-api) just to compute names.
✅ CORRECT - Follow established RTL patterns:
- Use direct fireEvent calls (Pattern 8) - no act() wrapping (fireEvent is already wrapped in act() internally)
- Use waitFor/findBy for async waiting (Pattern 4) - no manual timer advancement
- Prefer RegExp queryByTestId/getByTestId for space-separated data-test-subj values (Pattern 5)
- If you truly need CSS selector semantics, keep querySelector fallback INSIDE the harness and scoped to a known container
- Use document.getElementById for EuiSuperSelect options (Pattern 12B)
- Wait for dropdown closure after selections (Pattern 12C)
- Keep methods simple and focused on one interaction per method
Hard rule (harnesses too): NEVER use empty act() blocks.
- If you need act(), it must wrap a real trigger (typically async timer advancement in fake-timer contexts).
- Otherwise, wait on a concrete DOM boundary (findBy*/waitFor/waitForElementToBeRemoved).
IMPORTANT harness nuance (EuiComboBoxTestHarness):
- selectOptionAsync() validates selection by reading combo box pills.
- For singleSelection: { asPlainText: true } (no pills), prefer selectOption() + a downstream UI boundary.
What belongs in @kbn/test-eui-helpers (generic + EUI-shaped):
- Pagination button selection (EuiPagination/EuiTablePagination)
- Popover panel discovery ([data-popover-panel]) + context menu item selection (EuiContextMenu / overflow menus)
by role + accessible name (button/link), scoped to the open panel
- Table row discovery helpers where selector logic is non-trivial (e.g. row by cell text)
Note: Avoid wrappers that only index arrays (first/only/at/count); inline those at call sites.
- Table cell value extraction WITH optional normalization (trim + collapse whitespace) for stable assertions
- List item action selection where the action button is “near” the label but not a strict direct parent
(bounded ancestor walk is acceptable inside harnesses; fail fast, don’t traverse the whole document)
EuiSuperSelect control test-subj placement (IMPORTANT):
- Some EuiSuperSelect usages apply data-test-subj to the *actual <button>* control (not a wrapper).
- Harnesses MUST handle both cases:
- If getByTestId(testId) returns a BUTTON (or role=button), use it directly.
- Otherwise, find the button within the container.
- Anti-pattern: within(screen.getByTestId(testId)).getByRole('button') (fails when container is the button).
EuiSuperSelect option selection determinism:
- Always wait for the dropdown listbox to open after clicking the control, and wait for it to close after selection (Pattern 12C).
- If multiple options share the same data-test-subj, default behavior should be to throw with a clear message.
If a test intentionally expects duplicates, allow selecting by index (deterministic).
Selecting options when there is no stable data-test-subj:
- Prefer selecting by DOM id (document.getElementById) when EUI sets option ids to values (Pattern 12B).
❌ WRONG - Anti-patterns to avoid:
- act() wrapping around fireEvent calls (unnecessary - fireEvent is already wrapped in act() internally)
- Promise.resolve() microtask flushing (unnecessary complexity)
- Manual timer advancement (only valid when you opted into fake timers and it provides a measurable speed/stability win; otherwise it adds complexity without benefit)
- Extended waitFor timeouts unless test legitimately needs >1.2s (see Pattern 19)
- Over-engineering simple interactions
- Assuming consistent test-subj patterns on dropdown options
When to Use Harness vs Manual:
Use EuiSuperSelectTestHarness When:
- Standard SuperSelect with single element having that test-subj
- Options have consistent data-test-subj pattern
- Simple selection without complex multi-part controls
Use Manual Approach When:
- Multiple elements share test-subj (use queryAllByTestId + filter - Pattern 5)
- Options don't have test-subj (use document.getElementById with value - Pattern 12B)
- Complex multi-part controls (SuperSelect + native select combo)
- Component conditionally renders different control types
Manual Pattern:
const elements = within(container).queryAllByTestId('fieldName');
const button = elements.find((el) => el.tagName === 'BUTTON');
fireEvent.click(button!);
await screen.findByRole('listbox');
const option = document.getElementById('optionValue')!;
fireEvent.click(option);
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
Rationale: Harnesses expect single element + specific option patterns; manual gives full control
Example - Correct harness implementation:
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
public async selectOptionAsync(searchText: string) {
const input = this.#inputEl;
// Focus and click to open dropdown - Pattern 8: direct fireEvent
fireEvent.focus(input);
fireEvent.click(input);
// Type the search text - Pattern 8: direct fireEvent
fireEvent.change(input, { target: { value: searchText } });
// Wait for async onSearchChange to complete - Pattern 4: waitFor (default timeout)
await waitFor(() => {
// Pattern 12: Prefer RegExp matcher for space-separated data-test-subj
const optionsList = screen.queryByTestId(
new RegExp(`${escapeRegExp(this.#testId)}-optionsList`)
);
if (!optionsList) {
throw new Error(`Options list did not appear for search: "${searchText}"`);
}
const options = within(optionsList).queryAllByRole('option');
if (options.length === 0) {
throw new Error(`No options found in list for search: "${searchText}"`);
}
});
// Click the matching option - Pattern 8: direct fireEvent
const optionsList = screen.queryByTestId(new RegExp(`${escapeRegExp(this.#testId)}-optionsList`));
if (optionsList) {
const options = within(optionsList).queryAllByRole('option');
// Avoid "first item wins" when the intent is selecting a specific option.
// Prefer clicking the matching option (by text) or a stable option id when available.
const option = within(optionsList).queryByText(searchText);
if (option) {
fireEvent.click(option);
// Wait for selection to propagate - Pattern 4: waitFor (default timeout)
await waitFor(() => {
const selected = this.selectedOptions;
if (!selected.includes(searchText)) {
throw new Error(`Selection did not propagate for: "${searchText}"`);
}
});
}
}
}
Key Principles:
- Harnesses are reusable utilities - follow all established RTL patterns
- Direct fireEvent calls (no act() wrapping - fireEvent is already wrapped in act() internally)
- Simplicity wins - waitFor/findBy over manual timers
- EUI specifics - prefer RegExp testId matchers for space-separated attributes; getElementById for options; querySelector is fallback
- Wait for closure - prevent race conditions in sequential interactions
38. TS Projects Lint (tsconfig coverage)
Problem: CI can fail with "files do not belong to a tsconfig.json file" when new files
(especially test-only files like __mocks__/**) are not included by any TypeScript project.
Symptom:
- Lint TS Projects fails (runs: node scripts/lint_ts_projects)
Fix:
- Ensure the nearest owning tsconfig.json includes those paths.
Example (tsconfig.json):
"include": [
"__jest__/**/*",
"__mocks__/**/*",
"public/**/*",
"server/**/*"
]
Verify:
- node scripts/lint_ts_projects
Rationale:
- Kibana validates repository file-to-project mapping; every TS/TSX file must belong to a tsconfig.
39. Typed Mock Response Factories (avoid partial objects)
Problem: In strict TypeScript tests, mocked service responses often require required metadata fields.
Using partial objects like {} or { status: ... } produces type errors and brittle tests.
Pattern:
- Create a typed factory function that returns a fully valid object, and accept overrides.
Example (typed factory with nested required fields):
type FooResponse = {
meta: { id: string };
payload: { status: 'ok' | 'err' };
};
const createFooResponse = (overrides: Partial<FooResponse> = {}): FooResponse => ({
meta: { id: 'test-id', ...(overrides.meta ?? {}) },
payload: { status: 'ok', ...(overrides.payload ?? {}) },
...overrides,
});
// Usage
mockFn.mockResolvedValue({
data: createFooResponse({ payload: { status: 'err' } }),
error: null,
});
Apply to:
- Service responses with required top-level fields (e.g. meta + nested payload)
- Domain objects returned by mocks (provide all required properties, override what matters)
Rationale:
- Keeps mocks correct as types evolve and avoids sprinkling type assertions.
Addendum (TypeScript typing lessons from this session):
Rule: Replace 'any' with real types where it improves safety and readability.
✅ DO:
- Use ComponentProps<typeof X> for test helpers that accept component props.
- Use ReturnType<typeof fn> for hook/service helpers (especially mocked hook returns).
- Import existing Kibana/EUI types (e.g. HttpSetup/CoreStart/State) when they exist.
Rule: Keep "overrides" arguments in shared test helpers flexible.
Why:
- Many tests intentionally pass partial shapes (e.g. only config/url); strict typing causes widespread churn.
✅ DO:
- Accept overrides as unknown/Record<string, unknown> at the helper boundary and merge into fully-formed defaults.
❌ DON'T:
- Expose Partial<AppDependencies> as the override type if callers commonly pass incomplete shapes.
Rule: Keep HTTP mock "response" payloads typed as unknown.
Why:
- Domain objects (e.g. DataStream) often do not have index signatures; forcing Record<string, ...> breaks call sites.
✅ DO:
- type HttpResponse = unknown;
Rule: When removing 'as any' from mocks, satisfy required fields explicitly.
Example:
- Hook return shapes may require fields like isInitialRequest/resendRequest even if tests don't assert them.
Note: Non-null assertion operator (!) policy is local.
- Don't churn tests by replacing '!' with runtime guards unless the team explicitly wants that change.
40. HTTP Mock Call Signature Normalization (path/options vs optionsWithPath)
Problem: Some http mocks are typed as post(path, options) while others are typed as post(optionsWithPath).
Inspecting mock.calls with fixed tuple indices can fail with TypeScript errors.
Pattern:
- Normalize the call into a stable shape before asserting.
Example (normalize + assert):
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null;
const normalizeHttpCall = (call: readonly unknown[]) => {
const a0 = call[0];
const a1 = call[1];
// (path, options)
if (typeof a0 === 'string') {
if (!isRecord(a1)) throw new Error('expected options object');
return { path: a0, options: a1 };
}
// (optionsWithPath)
if (!isRecord(a0) || typeof a0.path !== 'string') {
throw new Error('expected optionsWithPath');
}
return { path: a0.path as string, options: a0 };
};
const calls = jest
.mocked(http.post)
.mock.calls
.map((c) => normalizeHttpCall(c as unknown as readonly unknown[]));
const simulate = calls.find((c) => c.path.includes('/simulate'));
expect(simulate).toBeDefined();
const body = JSON.parse(simulate!.options.body as string);
expect(body).toMatchObject({ /* ... */ });
Rationale:
- Works across different HttpSetup typing variants without using any.
41. Prefer “Request Count Increased” Over Exact Call Counts
Problem: UI actions often trigger background refreshes; exact request counts become brittle as
implementation changes (extra refresh, retry, polling, etc.).
Pattern:
- Capture requestsBefore = mock.calls.length
- Perform the action
- Assert mock.calls.length > requestsBefore
Example:
const getMock = jest.mocked(http.get);
const requestsBefore = getMock.mock.calls.length;
fireEvent.click(screen.getByTestId('reloadButton'));
await waitFor(() => {
expect(getMock.mock.calls.length).toBeGreaterThan(requestsBefore);
});
Use exact counts only when:
- The exact number is the behavior under test.
42. Keeping Large Migrations Reviewable at the End (permission-gated fixup + autosquash)
Goal: After the migration is working and verified, consolidate/split commits into a clean, reviewable series.
This is a history rewrite operation and must be done only with explicit user approval.
Example workflow:
# Safety pointer (local backup ref)
git branch backup/before-history-rewrite-$(git rev-parse --short HEAD)
# Create a fixup targeting the commit you want to amend
git commit --fixup=<target-sha>
# Fold fixups without opening an editor
GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash <base>
Notes:
- Editor-free, but not conflict-free: conflicts still require manual resolution.
- If the branch was already pushed, updating the remote requires --force-with-lease, which also needs explicit approval.
42A. Remote-safe History Rewrite: Recovery Anchor + Parity Checks
Goal: Make history rewrites safe, reviewable, and reversible when the branch already exists on a remote.
Rule: Before rewriting history on a branch that already exists on a remote, create BOTH:
- a local backup ref, AND
- a remote “recovery anchor” branch that points to the pre-rewrite tip.
Why:
- A local backup only helps the person who did the rewrite.
- A remote anchor lets anyone recover or compare old history without guesswork.
- It reduces risk when you later force-update the PR branch.
✅ DO (recovery anchor):
- Create local backup:
git branch backup/before-history-rewrite-<short-sha>
- Push that backup branch to the same remote:
git push origin backup/before-history-rewrite-<short-sha>
✅ DO (parity verification when re-committing):
- Before force-updating the PR branch, compare old remote tip vs new HEAD to confirm only intended deltas exist:
git diff --name-status origin/<branch> HEAD
git diff --stat origin/<branch> HEAD
- If you expect the rewritten branch to be “structurally identical” to the old branch (except for intentional renames),
also compare the sets of files changed vs main:
git diff --name-only main..origin/<branch> | sort > /tmp/origin-files.txt
git diff --name-only main..HEAD | sort > /tmp/head-files.txt
comm -3 /tmp/origin-files.txt /tmp/head-files.txt
✅ DO (remote update):
- Always force-update with lease:
git push --force-with-lease origin <branch>
❌ DON'T:
- Use plain --force unless you have a very specific reason (prefer --force-with-lease).
- Force-update the PR branch before you have a recovery anchor and have checked parity.
42B. PR Tooling Gotcha After a Rewrite (gh + forks)
Problem: When your local repository has multiple remotes (fork + upstream), GitHub CLI commands can target the wrong repo
unless you are explicit.
✅ DO:
- Use --repo <owner>/<repo> when editing or viewing PR metadata:
gh pr view <PR_NUMBER> --repo <owner>/<repo>
gh pr edit <PR_NUMBER> --repo <owner>/<repo> --body "..."
Why:
- Prevents “edited the PR in the wrong repo” mistakes, especially when origin points to a fork.
43. Plugin-Local __mocks__ for Heavy UI Dependencies (Monaco/CodeEditor, @elastic/charts)
Problem:
- Some UI dependencies are too heavy for JSDOM (Monaco editor, charts) and can cause:
- Suspense resolution warnings
- requestAnimationFrame/open-handle leaks
- noisy act warnings unrelated to your test logic
- repeated, copy/pasted jest.mock factories across many test files
Priority order (to reduce long-term churn):
1) Shared mocks (repo-wide) when available
2) Plugin-local manual mocks under __mocks__/ (only if no shared mock exists or it cannot support your test needs)
3) Inline jest.mock factories in individual test files (last resort)
Note: Warning noise from shared mocks is acceptable short-term; fix the shared mock once rather than forking mocks per plugin.
Rule: Prefer shared mocks when they exist; otherwise use plugin-local manual mocks under __mocks__/ for heavy deps.
Why:
- Removes repeated inline mock factories from each test file.
- Avoids Jest mock-factory hoisting pitfalls (see Pattern 44).
- Keeps the mock implementation discoverable, shared, and easy to update.
- Keeps the test intent focused (tests use jest.mock('pkg') and move on).
✅ DO:
- First, check for an existing shared mock for the dependency and use it.
Example (CodeEditor):
import '@kbn/code-editor-mock/jest_helper';
- Only if no shared mock exists (or it cannot support the needed surface area), add a plugin-local manual mock file:
<pluginRoot>/__mocks__/@kbn/code-editor/index.tsx
<pluginRoot>/__mocks__/@elastic/charts/index.tsx
- In tests, use the simple one-liner for plugin-local mocks:
jest.mock('@kbn/code-editor');
jest.mock('@elastic/charts');
- Ensure TypeScript project coverage includes __mocks__/** (Pattern 38).
❌ DON'T:
- Create plugin-local mocks when a shared mock exists for the dependency; prefer fixing the shared mock once.
- Paste the same jest.mock('@kbn/code-editor', () => { ... }) factory into multiple test files.
- Create a repo-root __mocks__ that affects unrelated plugins unless you explicitly want global behavior.
Example (CodeEditor):
- If you must create a plugin-local mock, keep the surface minimal and aligned with the shared mock shape:
- module.exports = { ...jest.requireActual('@kbn/code-editor'), CodeEditor: MockedCodeEditor }
- Use a simple <input> with:
- data-test-subj (default to 'mockCodeEditor')
- data-currentvalue/value string
- onChange that forwards the new string value to props.onChange
Example (Charts mock shape):
- Export stubs for Chart/Axis/Settings/etc. and render a simple container:
- <div data-test-subj="mockChart">{children}</div>
See also:
- Pattern 38 (tsconfig coverage; include __mocks__/**)
- Pattern 44 (hoisting-safe mocking patterns)
- Pattern 4 (wait on a UI boundary after mount)
44. Jest Mock Factory Hoisting / Out-of-Scope Variables Error
Symptom:
- Jest fails before tests run with an error like:
"The module factory of jest.mock() is not allowed to reference any out-of-scope variables"
Root cause:
- jest.mock('x', () => { ... }) factories are hoisted and evaluated in a restricted way.
- Referencing symbols declared outside the factory (including local helper functions and TS types)
can trigger the out-of-scope guard.
Rule: Prefer hoisting-safe mocking patterns; avoid complex inline factories.
✅ DO (preferred):
- Use a manual mock in __mocks__/ and a one-liner in tests:
jest.mock('some-package');
This also reduces duplication (Pattern 43).
✅ DO (safe fallback when you need a factory):
- Require the implementation inside the factory:
jest.mock('some-package', () => require('./path/to/mock_impl'));
❌ DON'T:
- Reference locally-declared variables/types from inside the mock factory.
- Build large mock implementations inline in each test file (hard to maintain and error-prone).
See also:
- Pattern 43 (plugin-local __mocks__ for heavy deps)
- Pattern 38 (tsconfig coverage for new __mocks__ files)
VIII. REFERENCE
(Commands, PRs, and quick lookups)
Test & Lint Commands:
Quick CI Parity Checks (useful during migrations): see Pattern 2A
- Type check: node scripts/type_check --project <path-to-closest-tsconfig>
- NOTE: Do not use tsconfig.type_check.json / type_check.json; run scripts/type_check with --project pointing at the closest tsconfig.json.
- Lint: node scripts/eslint <path>
- Unit: node scripts/jest <path to test>
- Integration: node scripts/jest_integration <path> (only if jest.integration.config.js exists nearby)
Reference PRs:
- #242062 (ILM migration - most recent, canonical reference)
- #239643 (CCR migration - canonical patterns)
- #238764 (Management/ES UI Shared - timer handling, userEvent setup)
Kibana Conventions:
- Use data-test-subj (NOT data-testid) in Kibana codebase
- RTL query: screen.getByTestId('foo') queries data-test-subj="foo"
- Rationale: Kibana convention across FTR and Jest tests
---
name: react-testing-rtl
description: React Testing Library (RTL) patterns for reliable, performant, maintainable Jest tests (queries, async waiting, fake timers, forms, interactions, HTTP mocking, portals, performance, TypeScript, debugging).
---
NOTE: Agent Skills format expects this file at `.agents/skills/react-testing-rtl/SKILL.md` (folder name must match `name`).
# React Testing Library — Skill Reference
## 1. Principles
### 1.1 Investigation-First
Before writing or fixing any test:
- Read the source code of the component under test **first**.
- Understand test IDs, conditional rendering, required props, feature flags.
- Verify actual DOM structure and element attributes.
- When tests fail, inspect the implementation to understand **why**.
> Never fix tests in a vacuum without understanding the implementation.
### 1.2 Verification After Every Change
- **Per-file change**: Run the narrowest-scope checks (type check, lint, jest for the file).
- **Milestone complete**: Expand scope — all files using that utility → entire feature → full suite.
- Always verify changed files immediately; never skip verification for small changes.
### 1.3 What "Good" Looks Like
- Tests read as **Arrange → Act → Assert**.
- Helpers reduce noise but don't hide control flow or waits.
- Waits are explicit and tied to a **UI boundary** (element appears/disappears, text changes, panel mounts).
---
## 2. Glossary
| Term | Definition |
|---|---|
| **UI boundary** | The visible DOM state you wait on (element appears/disappears, text changes, panel mounts, listbox closes). |
| **Timer-runtime helper** | A shared helper that only wraps fake-timers APIs in `act()` correctly (e.g. `flushPendingTimers`, `flushUntil`). |
| **Domain/action helper** | Helper named after product/domain behavior (e.g. `clickSave`, `fillForm`). Should **not** hide waits or timer flushing. |
| **Canonical section** | The single source of truth for a concept; other mentions should reference it, not re-explain it. |
| **Testbed/actions architecture** | Multi-layer abstraction (setup + actions/page objects + helpers) that hides selectors/waits and increases indirection. |
| **Portal** | UI rendered outside the normal React tree (modals, popovers). Often requires querying `document.body`. |
---
## 3. Minimal Test Scaffold
### Default (real timers)
```typescript
describe('Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('SHOULD do something', async () => {
// Arrange
render(<Component />);
// Act
fireEvent.click(screen.getByTestId('saveButton'));
// Assert (wait for a UI boundary)
await screen.findByText('Saved');
});
});
```
**Notes:**
- Default to **real timers**. See §6 for when fake timers are justified.
- Keep helpers shallow; keep waits explicit.
- Prefer **one** top-level `beforeEach` per file that calls `jest.clearAllMocks()`.
Do not repeat it in nested `describe` blocks unless that block adds additional scoped setup.
### Variant: fake timers (opt-in; requires justification)
```typescript
describe('Component', () => {
beforeAll(() => { jest.useFakeTimers(); });
afterAll(() => { jest.useRealTimers(); });
beforeEach(() => { jest.clearAllMocks(); });
afterEach(async () => {
await fakeTimers.flushPendingTimers();
jest.clearAllTimers();
});
it('SHOULD do something', async () => {
render(<Component />);
fireEvent.click(screen.getByTestId('saveButton'));
await screen.findByText('Saved');
});
});
```
---
## 4. Element Queries & Async Waiting
### Decision Tree — What Are You Waiting For?
| Situation | Solution |
|---|---|
| Element to **appear** | `await screen.findByTestId('hot-phase')` |
| Element to **disappear** | `await waitForElementToBeRemoved(screen.getByTestId('loading'))` |
| Attribute change | `await waitFor(() => expect(el.getAttribute('aria-checked')).toBe('true'))` |
| Text content change | `await waitFor(() => expect(el.textContent).toBe('Complete'))` |
| Mock function call | `await waitFor(() => expect(mockFn).toHaveBeenCalled())` |
| Dropdown/portal to **close** | `await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())` |
### Separate UI Boundaries
It is OK to use **multiple consecutive** `waitFor` calls when each wait is a different UI boundary.
```typescript
// ✅ Separate boundaries — close first, then check enablement
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
await waitFor(() => expect(screen.getByTestId('nextButton')).toBeEnabled());
```
```typescript
// ❌ Combined — masks which boundary failed
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
expect(screen.getByTestId('nextButton')).toBeEnabled();
});
```
### Anti-Patterns
| Wrong | Correct |
|---|---|
| `await waitFor(() => expect(screen.getByTestId('x')).toBeInTheDocument())` | `await screen.findByTestId('x')` |
| `await waitFor(() => expect(screen.queryByTestId('loading')).not.toBeInTheDocument())` | `await waitForElementToBeRemoved(screen.getByTestId('loading'))` |
| `await screen.findByTestId('title'); expect(screen.getByTestId('title')).toHaveTextContent('Edit')` | `const title = await screen.findByTestId('title'); expect(title).toHaveTextContent('Edit')` |
> `findBy*` already returns the element. Re-querying with `getBy*` is redundant.
### Act Warnings = Missing Await
- Act warnings mean React state updates outside `act()` boundaries.
- Root cause: missing `await` on an async operation **before** the assertion.
- Common sources: `findBy`, `waitFor`, `waitForElementToBeRemoved`, user interactions.
**Mount-time requests (no interaction yet):**
If a component triggers async work on mount (`useEffect` / `useRequest`), await the first stable UI boundary immediately after render:
```typescript
render(<Component />);
await screen.findByTestId('nameInput');
// OR
await waitForElementToBeRemoved(() => screen.getByTestId('sectionLoading'));
```
**Save + Navigation (wizards/forms):**
Symptom: Act warning mentions the component under test **and** Router (e.g. "update to TemplateCreate" and "update to Router"). Typical flow: click Save → `setIsSaving(true)` → await save → `setIsSaving(false)` → `history.push(...)`. If the test ends right after asserting the API call, post-save state updates + navigation complete after the test finishes.
Fix: After clicking the final Save/Create button, wait for a navigation/unmount boundary (e.g. wizard action button disappears, or a stable element from the destination route appears). Do **not** sprinkle `act()` around `fireEvent`.
**Hard rule:** Never use empty `act()` blocks (`await act(async () => {})`). They have no trigger, no UI boundary, and hide missing awaits. If you truly need `act()`, include the triggering operation inside it (almost always timer advancement in fake-timer suites), and then assert a UI boundary.
### `waitFor` Timeout
- Default timeout is 1000 ms.
- **Never** add extended timeouts as a first response. Prefer:
- `findBy*` instead of `waitFor(getBy*)`
- Tightening the UI boundary
- Improving selector performance (§5, §11)
- CI machines are 2–3× slower: 1.2 s locally → 2.4–3.6 s on CI.
- If tests legitimately take > 1.2 s locally, prefer increasing the **test-level** timeout (§9).
- Only if the suite uses fake timers **and** you truly need to advance timer chains: use timer-runtime helpers (§6). Do not sprinkle `act()` around `fireEvent`.
---
## 5. Query Selector Strategy
### Priority (fastest → slowest)
| Priority | Query | Speed | Notes |
|---|---|---|---|
| 1 | `ByTestId` | O(1) | Direct attribute lookup; fastest |
| 2 | `ByLabelText` | O(n) | Label association |
| 3 | `ByPlaceholderText` | O(n) | Direct attribute |
| 4 | `ByText` | O(n) | Text content; fast with specific strings |
| 5 | `ByRole` | O(n²) | Role + accessible name computation; **slowest** |
### Guidelines
- **Default:** Use `ByTestId`.
- Use `ByText` for precise, unambiguous matches.
- Avoid `ByRole` unless scoped (`within(cell).getByRole('button')`) or for structural validation (`getAllByRole('cell')` to verify column count).
- `ByRole` is 20–30% slower due to full DOM role + accessible name computation.
**Common ByRole → faster replacements:**
```typescript
// ❌ Slow
screen.getByRole('button', { name: /Save/ })
screen.getByRole('searchbox')
// ✅ Faster
screen.getByText(/Save/)
screen.getByPlaceholderText(/Search/i)
```
### Null Handling
| Need | Query | Behavior |
|---|---|---|
| Element **must** exist | `getBy*` | Throws if not found |
| Element **may not** exist | `queryBy*` | Returns `null` |
| Element **will** appear | `findBy*` | Waits; rejects if not found |
### Avoid `*AllBy*` Then `[0]`
```typescript
// ❌ Wasteful and masks duplicate renders
const el = screen.getAllByTestId('foo')[0];
// ✅ Narrow the query first
within(row).getByTestId('foo');
```
If you legitimately need `getAllBy*`, filter by a meaningful predicate (tag, text, aria attribute), not by index.
**Practical disambiguation (when `getAllByTestId` is needed):**
```typescript
// EUI FormRow + control share the same test-subj — filter by capability
const rows = screen.getAllByTestId('versionField');
const rowWithInput = rows.find((row) => within(row).queryByRole('spinbutton') !== null);
expect(rowWithInput).toBeDefined();
const input = within(rowWithInput!).getByRole('spinbutton');
```
```typescript
// Filter by textContent
const buttons = within(container).queryAllByRole('button');
const languageButton = buttons.find((btn) => btn.textContent?.includes('Language'));
```
### Multiple Elements with Same Test ID
When a UI framework creates wrapper + control with the same test ID:
```typescript
// ✅ Filter by element type
const elements = within(container).queryAllByTestId('searchQuoteAnalyzer');
const button = elements.find((el) => el.tagName === 'BUTTON');
```
### Screen Queries Over Container Destructuring
```typescript
// ✅ Stable across re-renders
screen.getByTestId('foo');
// ❌ Creates stale references
const { getByTestId } = render(<Component />);
```
### Space-Separated `data-test-subj` Values
Some UI frameworks combine test subjects: `data-test-subj="comboBoxOptionsList fieldName-optionsList"`.
String queries do exact match and will fail.
```typescript
// ❌ Exact match — returns null
screen.queryByTestId('fieldName-optionsList');
// ✅ RegExp — matches within the space-separated value
screen.queryByTestId(/fieldName-optionsList/);
```
**Fallback (rare):** Use a scoped CSS selector only when RTL semantics cannot express what you need. Prefer putting this fallback inside a reusable test harness, not in individual test files. Always scope to a known container (no global document scans).
### Avoid Naked `querySelector` / `querySelectorAll`
Prefer `screen` / `within` queries. If DOM traversal is needed for a UI-framework pattern (pagination, context menus, table cells), encapsulate it in a reusable test harness — not in individual test files.
---
## 6. Fake Timers Setup & Strategy
### When to Use (Opt-In Only)
| ✅ Use When | ❌ Do Not Use When |
|---|---|
| Advancing timers is measurably faster than real waiting (debounce, throttle, interval, polling) | Only goal is "make act warnings go away" |
| Long timer delays dominate wall-clock runtime | Suite has little/no timer behavior |
| Stable UI boundary to advance-until, reducing flake | |
If in doubt: run the file with real timers and fake timers, compare, keep the simpler one.
### Setup Pattern
```typescript
beforeAll(() => { jest.useFakeTimers(); }); // NO legacyFakeTimers
afterAll(() => { jest.useRealTimers(); });
```
### Suite Hygiene (fake-timer suites only)
```typescript
afterEach(async () => {
await act(async () => { await jest.runOnlyPendingTimersAsync(); });
jest.clearAllTimers();
});
```
- **Flushing** executes callbacks so React updates settle inside `act`.
- **Clearing** deletes scheduled timers without executing.
- Flush first, then clear. Never use `clearAllTimers()` as a replacement for flushing.
- It is OK for a timer-runtime helper to guard flushing with `jest.getTimerCount()` to avoid pointless work, but never use an empty `act()` block — if there is nothing to run, return early without `act()`.
- Avoid file/global `jest.setTimeout()` as a generic "make CI green" workaround. Prefer fixing waits, or use a narrowly-scoped timeout with concrete justification (§9).
**Real vs fake timer behavior of `waitFor`:**
- Under **real** timers: `waitFor` polls (it does **not** advance timers).
- Under **fake** timers: RTL advances timers during `waitFor` polling (every 50 ms, max 1000 ms = 20 checks).
- If multiple async operations are queued (popover updates, debounced callbacks, timer chains), `waitFor` may not flush everything. `runOnlyPendingTimersAsync()` immediately completes all pending timers.
### Timer Advancement — Rarely Needed
**Decision tree:**
| Situation | Manual Timers? |
|---|---|
| Waiting for async UI (real timers) | No — use `findBy` / `waitFor` |
| Waiting for async UI (fake timers) | No — use `findBy` / `waitFor` first; advance only if you can't reach a stable boundary |
| Testing timer-dependent behavior (intervals, debounce) | Yes — use async APIs in `act()` |
| Act warnings persist after `waitFor` / `findBy` | Yes — `runOnlyPendingTimersAsync()` in `act()` |
### Correct Advancement Pattern
```typescript
// Each timer call in its own act() wrapper
await act(async () => { await jest.runOnlyPendingTimersAsync(); });
```
### Timer Chains
When a callback triggers a state update that schedules another timer, one flush is not enough.
Do **not** hardcode "flush twice". Instead, advance until the expected DOM condition is true (bounded):
```typescript
// Central timer-runtime helper (define once, reuse)
export async function flushUntil(
condition: () => boolean,
{ maxIterations = 10 }: { maxIterations?: number } = {}
) {
for (let i = 0; i < maxIterations; i++) {
if (condition()) return;
await act(async () => { await jest.runOnlyPendingTimersAsync(); });
}
expect(condition()).toBe(true);
}
// Usage
await flushUntil(() => screen.queryByTestId('popoverContent') !== null);
```
**When `flushUntil` is applicable:**
- The UI boundary is represented in DOM (field/step/modal/listbox appears or disappears).
- `waitFor` / `findBy` is not sufficient under fake timers because timer chains or debounces are involved.
**When it is NOT applicable:**
- There is no stable DOM condition to wait on (you are flushing only to "make act warnings go away"). Re-check missing awaits first (§4).
**Helper naming rule:** If a helper only advances a specific debounce window (e.g. JsonEditor debounces ~300 ms), do **not** name it `flushAll`. Name it after what it actually does (e.g. `flushJsonEditorDebounce` / `flushDebounceWindow`) and keep it narrowly scoped.
### Promise-Splitting with Fake Timers
When an action returns a promise that resolves via timers:
```typescript
await act(async () => {
const createPromise = handleCreate();
await jest.runOnlyPendingTimersAsync();
await createPromise;
});
```
### Anti-Patterns
| ❌ Wrong | ✅ Correct |
|---|---|
| `jest.useFakeTimers({ legacyFakeTimers: true })` | `jest.useFakeTimers()` |
| `jest.advanceTimersByTime(1000)` (sync) | `await act(async () => { await jest.advanceTimersByTimeAsync(1000); })` |
| `jest.runOnlyPendingTimersAsync()` without `act()` | `await act(async () => { await jest.runOnlyPendingTimersAsync(); })` |
| `act(() => { jest.advanceTimersByTime(0); })` (sync in act) | Use async timer APIs |
| Manual timer code hidden inside helpers | Keep timer advancement explicit at call sites or in timer-runtime helpers |
---
## 7. Form Testing
### Core Principle
Form helpers are **synchronous**. Tests handle all waiting.
```typescript
// ❌ Helper hides the wait
async function setPolicyName(name: string) {
fireEvent.change(input, { target: { value: name } });
fireEvent.blur(input);
await act(async () => { await jest.runOnlyPendingTimersAsync(); });
}
// ✅ Helper is sync; test waits explicitly
function setPolicyName(name: string) {
fireEvent.change(input, { target: { value: name } });
fireEvent.blur(input);
}
setPolicyName('test');
await waitFor(() => expectErrorMessages([...]));
```
### Setting Input Values
- **Always** use `fireEvent.change(input, { target: { value: '...' } })` — fastest, matches React's controlled component pattern.
- **Not** `fireEvent.input` (unreliable in jsdom).
- **Not** `userEvent.type` unless testing per-keystroke behavior.
### Triggering Validation
- Field validation: `fireEvent.blur(input)` after setting value.
- Form validation: `fireEvent.submit(form)` or `fireEvent.click(submitButton)`.
- **Always** wait for validation messages: `await screen.findByText('Field is required')`.
### Wait Logic Placement
| Scenario | Where to Wait |
|---|---|
| Utility is always async (API calls, navigation) | Inside the utility |
| Utility is sometimes sync, sometimes async | At the call site |
| Wait condition varies by test | At the call site |
**Default:** wait at the call site. Tests are more readable when waits are explicit.
### Async Input Effects (Search, Autocomplete)
When input changes trigger async side effects (API calls, search, debounced validation):
```typescript
fireEvent.change(searchInput, { target: { value: 'search term' } });
await screen.findByText('Search Result 1');
```
### Form Submission Testing
```typescript
const onSubmit = jest.fn();
render(<MyForm onSubmit={onSubmit} />);
fireEvent.change(nameInput, { target: { value: 'John' } });
fireEvent.click(submitButton);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
```
### Complex Form Interactions — Different Wait Conditions
```typescript
// Success → wait for navigation
fireEvent.click(submitButton);
await waitFor(() => expect(mockNavigate).toHaveBeenCalled());
// Validation error → wait for error message
fireEvent.click(submitButton);
await screen.findByRole('alert');
// Loading state → wait for spinner
fireEvent.click(submitButton);
await screen.findByRole('progressbar');
```
### Central Timer-Runtime Helpers Are OK
It **is** recommended to centralize timer-runtime operations in a shared utility (e.g. `fake_timers.ts`) as long as it only wraps timer APIs + `act()` correctly:
```typescript
// fake_timers.ts — timer-runtime only
export async function flushPendingTimers() {
await act(async () => { await jest.runOnlyPendingTimersAsync(); });
}
export async function advanceBy(ms: number) {
await act(async () => { await jest.advanceTimersByTimeAsync(ms); });
}
```
The anti-pattern is burying timer-runtime inside domain/action helpers that also click/type/fill.
```typescript
// ❌ Hidden wait
async function clickSave() {
fireEvent.click(screen.getByTestId('saveButton'));
await flushPendingTimers(); // hidden
}
// ✅ Explicit at call site
function clickSave() { fireEvent.click(screen.getByTestId('saveButton')); }
clickSave();
await screen.findByText('Saved');
```
---
## 8. `fireEvent` vs `userEvent`
### Decision Matrix
| Situation | Use |
|---|---|
| Simple click / change / blur | `fireEvent` (fastest, synchronous) |
| Portal/overlay interactions with persistent act warnings | `userEvent` (localized) |
| Text entry — just need final value | `fireEvent.change` + `fireEvent.blur` |
| Text entry in portal/overlay context | `userEvent.paste` (one step, faster than `type`) |
| Per-keystroke behavior (suggestions, masking, formatters) | `userEvent.type` |
| Special keys (Enter, Tab, Arrow) | `userEvent.keyboard` / `userEvent.tab` |
| Realistic user behavior matters | `userEvent` |
| Performance critical | `fireEvent` |
### `fireEvent` Patterns
```typescript
fireEvent.click(button);
fireEvent.change(input, { target: { value: 'text' } });
fireEvent.blur(input);
fireEvent.submit(form);
```
### `userEvent` Patterns
```typescript
// Real timers (default)
const user = userEvent.setup();
// Fake timers (only if suite uses them)
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.click(button);
await user.type(input, 'text');
await user.paste('text');
await user.keyboard('{Enter}');
await user.tab();
```
### Lifecycle
Prefer **one** `userEvent` instance per test (in `beforeEach`):
```typescript
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => { user = userEvent.setup(); });
```
Avoid the helper footgun:
```typescript
// ❌ Creates a new userEvent instance per call — needless overhead + indirection
async function click(el: HTMLElement) { const user = userEvent.setup(); await user.click(el); }
// ✅ Reuse the instance from beforeEach
await user.click(el);
```
### `keyboard` vs `type`
- `user.keyboard(...)` — key-driven UI (Escape, Enter, Arrow nav, Tab focus). Does **not** change input values.
- `user.type(...)` — text entry with per-keystroke events.
### Copy/Paste-Safe Nuances
- `fireEvent.change` triggers React `onChange` for typical inputs; follow with `fireEvent.blur` when validation/formatting is blur-driven.
- `userEvent.paste` **does** trigger `onChange` for normal inputs/textarea (changes the value and fires expected events), but does **not** exercise keydown/keypress/keyup paths or per-character intermediate states.
- Therefore:
- Component behavior depends on key events or intermediate values → use `userEvent.type`.
- Test only cares about final value and downstream UI → use `fireEvent.change` + `blur` (default) or `userEvent.paste` (overlay/popover/act-warning exception).
---
## 9. Test Timeouts
### Algorithm
1. Test takes > 1.2 s locally? → **No:** default 5 s Jest timeout is sufficient.
2. Account for CI slowdown (2–3×): `local_time × 3`.
3. If CI time > 5 s → increase **test-level** timeout: `(local_time × 3) + margin`.
| Local Time | CI Estimate | Action |
|---|---|---|
| 1.2 s | 3.6 s | Default (5 s) is fine |
| 2 s | 6 s | `it('...', async () => { ... }, 10_000)` |
| 3 s | 9 s | `it('...', async () => { ... }, 15_000)` |
Avoid `jest.setTimeout()` at file level unless most tests in the file are slow.
---
## 10. Test Organization
### Structure
```typescript
describe('ComponentName', () => {
beforeEach(() => { jest.clearAllMocks(); });
describe('WHEN condition', () => {
it('SHOULD expected behavior', async () => {
// Arrange → Act → Assert
});
});
});
```
### No Manual `cleanup()`
Jest auto-cleans between tests. Never call `cleanup()` manually.
### Test Isolation
Each test must be independent — no shared state, no order dependencies, clean mocks in `beforeEach`.
```typescript
// ❌ Shared render
let sharedComponent;
beforeAll(() => { sharedComponent = render(<Component />); });
// ✅ Fresh render per test
it('test 1', () => { render(<Component />); });
it('test 2', () => { render(<Component />); });
```
### `setupEnvironment()` — Fresh Per Test
```typescript
describe('Component', () => {
let httpSetup: ReturnType<typeof setupEnvironment>['httpSetup'];
beforeEach(() => {
jest.clearAllMocks();
const env = setupEnvironment();
httpSetup = env.httpSetup;
});
});
```
### No Barrel Files in Test Directories
```typescript
// ✅ Direct import
import { setupEnvironment } from './helpers/setup_environment';
// ❌ Barrel import
import { setupEnvironment } from './helpers';
```
### No Imports from Test Files
Never import from `*.test.ts` / `*.spec.ts` — Jest executes the imported file as tests, causing duplicate runs.
Move shared data to dedicated fixture files.
---
## 11. Component Patterns
### Portal Components (Modals, Popovers, Tooltips)
Portals render outside the normal DOM tree. Query from `document.body`:
```typescript
const confirmButton = within(document.body).getByTestId('confirmModalButton');
```
**Popover act warnings:** Convert `getBy*` to `await findBy*` for elements inside popovers — they render asynchronously in a portal.
```typescript
// ❌ Causes act warning
fireEvent.click(button);
const content = screen.getByTestId('popoverContent');
// ✅ Waits for portal to render
fireEvent.click(button);
const content = await screen.findByTestId('popoverContent');
```
### Select / Dropdown Components
**ComboBox (searchable multi-select):**
```typescript
fireEvent.click(screen.getByTestId('comboBoxToggleListButton'));
await screen.findByTestId('comboBoxOptionsList');
fireEvent.click(screen.getByText('Option Text'));
```
**SuperSelect (single-select dropdown with portal listbox):**
```typescript
fireEvent.click(within(container).getByRole('button'));
await screen.findByRole('listbox');
const option = document.getElementById('optionValue')!;
fireEvent.click(option);
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
```
**Wait for dropdown closure before next interaction:**
```typescript
// ✅ Wait for close, then proceed
fireEvent.click(option1);
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
fireEvent.click(button2);
// ❌ Race condition
fireEvent.click(option1);
fireEvent.click(button2);
```
### Tab Navigation
```typescript
fireEvent.click(screen.getByRole('tab', { name: /Settings/i }));
await screen.findByRole('tabpanel');
```
### Router Mocking
```typescript
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({ name: 'test-policy' }),
useHistory: () => ({
push: jest.fn(),
location: { search: '' },
}),
}));
```
### License & Feature Flags
If UI is conditionally rendered based on license/flags, mock them active in test setup.
Otherwise "element not found" errors will mislead you.
```typescript
// Mock via HTTP helpers
httpRequestsMockHelpers.setGetLicense({ license: { status: 'active', type: 'trial' } });
// Or mock the hook directly
jest.mock('../path/to/license', () => ({
useLicense: () => ({ isActive: true, isGoldPlus: true }),
}));
```
---
## 12. HTTP Mocking
### Basic Pattern
```typescript
httpRequestsMockHelpers.setLoadPolicies([
{ name: 'policy1', phases: { hot: { ... } } },
]);
expect(httpSetup.get).toHaveBeenCalledWith('/api/policies');
```
### Async / Delayed Responses
```typescript
httpSetup.get.mockImplementation(() =>
new Promise((resolve) => setTimeout(() => resolve({ data }), 100))
);
await waitForElementToBeRemoved(screen.getByTestId('loading'));
```
### Testing Loading States with Render Helpers That Advance Timers
Split the promise — start render, catch loading state, then await completion:
```typescript
setDelayResponse(true);
const renderPromise = renderHome(httpSetup);
expect(await screen.findByTestId('sectionLoading')).toBeInTheDocument();
await renderPromise;
```
### Mock State Management
```typescript
beforeEach(() => {
jest.clearAllMocks();
httpRequestsMockHelpers.setLoadPolicies([]);
});
```
When spies exist:
```typescript
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});
```
Use individual `mockClear()` / `mockReset()` / `mockRestore()` only when you need targeted behavior; prefer the global helpers when possible.
### Type-Safe Mock Access
```typescript
const mockPost = jest.mocked(httpSetup.post);
mockPost.mockClear();
const calls = mockPost.mock.calls;
```
### Prefer "Request Count Increased" Over Exact Counts
```typescript
const getMock = jest.mocked(http.get);
const requestsBefore = getMock.mock.calls.length;
fireEvent.click(screen.getByTestId('reloadButton'));
await waitFor(() => {
expect(getMock.mock.calls.length).toBeGreaterThan(requestsBefore);
});
```
Use exact counts only when the number itself is the behavior under test.
---
## 13. Performance
### Quick Wins (ordered by impact)
1. Replace `ByRole` with `ByTestId` (20–30% improvement).
2. Use `fireEvent` instead of `userEvent` where possible.
3. Remove unnecessary `waitFor` wrappers (use `findBy` instead).
4. Reduce test scope (one behavior per test).
### Detection
Slow tests often have:
- Multiple `*ByRole` queries
- `userEvent` for simple interactions
- `waitFor` wrapping `getBy` (should be `findBy`)
- Single test validating multiple unrelated behaviors
**Quick diagnostic commands:**
```bash
# Find slow tests
SHOW_ALL_SLOW_TESTS=true node scripts/jest <path>
# Check for heavy selectors
rg "getByRole|findByRole|getAllByRole" <test-file>
# Check for userEvent usage
rg "userEvent\.(click|type|keyboard)" <test-file>
```
### Test Splitting Strategy
| Split When | Keep Unified When |
|---|---|
| Test validates multiple **independent** behaviors | Setup overhead > 50% of test time |
| Test > 3 s **and** > 50 lines | Test validates true integration behavior |
| Setup time < 50% of total | |
---
## 14. Helpers Architecture
### When to Extract Helpers
| Trigger | Action |
|---|---|
| Same 4–8 lines repeated in 3+ tests | Extract helper |
| Test body no longer fits on one screen | Extract helper |
| Boilerplate overwhelms intent | Extract helper |
### Shape Rules
- Prefer helpers that do **one** thing: query, interaction, or data building.
- Avoid "workflow helpers" that combine many steps and hide control flow.
- Do **not** hide waits inside domain/action helpers. If a helper must await, make it obvious in the name + signature (`async` + `AndWait` / `Until`).
### Location Rules
1. **Default:** file-local helpers (at bottom of test file).
2. **Growing:** extract to adjacent `*.helpers.ts(x)` file — still low indirection.
3. **Shared:** only when reused across multiple files **and** stable.
**Naming rule:**
- Under `__jest__/**`: prefer `*.helpers.ts(x)` — the directory boundary makes "test-only" clear.
- Outside `__jest__/**`: avoid ambiguous names; prefer `test_helpers.ts(x)` when a separate module is needed.
- Rule of thumb: the name should make it obvious whether the helper is test-only without inspecting imports.
### Anti-Pattern: Testbed / Actions Architecture
```typescript
// ❌ Too much indirection — hides what happens
await actions.form.save();
await actions.toast.expectSuccess();
// ✅ Explicit — test tells the story
clickSave();
await screen.findByText('Saved');
```
---
## 15. TypeScript Patterns
### Helper Return Types
| Structure | Type Strategy |
|---|---|
| Functions returning functions | Explicit property types |
| Objects returning objects | Intersection (`ReturnType<A> & ReturnType<B>`) |
### Setup Functions — Return Stable Objects
Setup functions should return an object with stable references. Do not call `setup()` multiple times in the same test — this creates stale closure issues.
```typescript
// ✅ Call once, reuse the result
const result = setup();
return result;
// ❌ Multiple calls in same test
```
### Async vs Sync Helpers
RTL `render` returns `RenderResult` **synchronously**. Check helper signature:
- Returns `Promise`? → use `await`.
- Returns `RenderResult` / object? → no `await`.
### Typed Mock Factories
```typescript
const createFooResponse = (overrides: Partial<FooResponse> = {}): FooResponse => ({
meta: { id: 'test-id', ...(overrides.meta ?? {}) },
payload: { status: 'ok', ...(overrides.payload ?? {}) },
...overrides,
});
```
### Mock Typing Rules
- Use `ComponentProps<typeof X>` for test helpers accepting component props.
- Use `ReturnType<typeof fn>` for hook/service helpers.
- Import existing framework types when they exist.
- Keep "overrides" arguments flexible (`unknown` / `Record<string, unknown>`) at the helper boundary.
- Keep HTTP mock response payloads typed as `unknown` when domain objects lack index signatures.
- When removing `as any` from mocks, satisfy required fields explicitly.
- Non-null assertion operator (`!`) policy is local. Don't churn tests by replacing `!` with runtime guards unless the team explicitly wants that change.
---
## 16. Jest Mock Factory Hoisting
### Symptom
> The module factory of `jest.mock()` is not allowed to reference any out-of-scope variables
### Cause
`jest.mock('x', () => { ... })` factories are hoisted. Referencing symbols declared outside the factory triggers the guard.
### Solutions
```typescript
// ✅ Preferred: manual mock in __mocks__/ + one-liner
jest.mock('some-package');
// ✅ Safe fallback: require inside factory
jest.mock('some-package', () => require('./path/to/mock_impl'));
// ❌ References local variable — breaks
const helper = () => 'mock';
jest.mock('some-package', () => ({ fn: helper }));
```
---
## 17. Common Errors Reference
| Error | Cause | Fix |
|---|---|---|
| Found multiple elements by `[data-test-subj=...]` | Component rendered twice | Remove duplicate render/setup; one render per test |
| Found multiple elements by `[data-test-subj="fieldsList"]` | Nested trees render multiple lists | Don't assume container test IDs are globally unique; query list items by a stable prefix, then locate the correct item by its own field-name element |
| Found multiple elements by `[data-test-subj="toggleExpandButton"]` | Nested list items share buttons | Identify the correct list item first (by fieldName), then query the button within it; prefer role + accessible name where available |
| Found multiple elements with text: Edit / Clone / Delete | EUI renders screen-reader-only + visible text nodes | Select the clickable item by role + accessible name, scoped to the open popover panel (`[data-popover-panel]`) |
| Functions are not valid as a React child | Function referenced but not called | Use `<Component />` or `Component()`, not `{Component}` |
| Element not found (but visible in UI) | License/feature flag check failed | Mock license/flags in test setup |
| Cannot read properties of undefined (reading 'url') | Router context missing | Mock `react-router-dom` hooks |
| Act warning | Missing `await` | Add `await` to async operation before assertion |
| Act warning with Popover | Popover renders async in portal | Convert `getBy*` to `await findBy*` |
| Cannot update component X while rendering Y | Render-phase cross-component update | Not an `act` issue — see detailed handling below |
| Suspense resolution warning | Heavy UI dep in JSDOM (Monaco, charts) | Use shared mocks or plugin-local `__mocks__/` |
| Fake timers not enabled warning | `jest.useFakeTimers()` not active when timer-runtime helper called | Ensure fake timers are enabled before calling timer helpers; treat fake timers as an explicit contract (§6) |
| Skipped/flaky tests | CI flakiness quarantine | Unskip and fix root cause (isolation, timers, splitting, `fireEvent`) |
| Redundant `getBy` after `findBy` (code smell) | Unnecessary re-query | Store `findBy` result and reuse it (§4) |
| Test timeout | CI slowdown or slow test | Fix waits/selectors; increase test timeout if needed |
**"Cannot update component X while rendering Y" — detailed handling:**
- Do **not** add `console.error` allowlists/filters to hide this warning.
- Do **not** change source code by default.
- Surface it to the user/reviewer with the exact warning + stack trace context.
- Suggest a fix, but only implement with explicit approval.
- If source changes are approved, keep them minimal and compatible (e.g. `data-test-subj` placement is OK; avoid behavior changes unless explicitly requested).
---
## 18. Debugging
### Console Log for Data Flow
When form state doesn't match UI:
```typescript
fireEvent.change(input, { target: { value: 'test' } });
console.log('Form state:', form.getValues());
console.log('Serialized:', serializeForm(form));
```
Remove after debugging.
### HTTP Mock Signature Normalization
When mock types use `(path, options)` vs `(optionsWithPath)`:
```typescript
const normalizeHttpCall = (call: readonly unknown[]) => {
const a0 = call[0];
const a1 = call[1];
if (typeof a0 === 'string') {
return { path: a0, options: a1 as Record<string, unknown> };
}
const opts = a0 as Record<string, unknown>;
return { path: opts.path as string, options: opts };
};
// Usage
const calls = jest
.mocked(http.post)
.mock.calls.map((c) => normalizeHttpCall(c as unknown as readonly unknown[]));
const simulate = calls.find((c) => c.path.includes('/simulate'));
expect(simulate).toBeDefined();
const body = JSON.parse(simulate!.options.body as string);
expect(body).toMatchObject({ /* ... */ });
```
---
name: enzyme-to-rtl-migration
description: Enzyme→RTL migration playbook (systematic refactors, artifact cleanup, hybrid mocks, title parity verification, CI parity checks, EUI harness guidance, mixed testbed/RTL pitfalls, history rewrite hygiene, Kibana conventions).
---
NOTE: Agent Skills format expects this file at `.agents/skills/enzyme-to-rtl-migration/SKILL.md` (folder name must match `name`).
# Enzyme-to-RTL Migration — Skill Reference
> Companion to `react-testing-rtl.SKILL.md.txt` (general RTL patterns).
> This file covers migration-specific process, Kibana conventions, and EUI testing patterns.
---
## 1. Migration Principles
### 1.1 Investigation-First (Migration Context)
Before migrating any test file:
- Read the **source component** to understand test IDs, conditional rendering, required props.
- Check for license requirements, feature flags, portal rendering.
- Verify actual DOM structure — Enzyme's `.find()` and RTL's `screen.getBy*` target different things.
**Example:** Custom analyzers from `index.analysis.analyzer` render as native `<select>`, not as SuperSelect with "Custom" button text. You must inspect the component to know this.
### 1.2 Preserve Test Names and Ordering
When migrating Enzyme → RTL, keep existing `describe()` / `it()` titles and their relative order.
- Minimizes diff churn and makes review easier.
- Only rename/reorder/consolidate when it is a clear net benefit (readability, flake reduction, removing redundancy), and explicitly call it out in the PR/commit message.
**Naming convention scope:** Use `describe('WHEN ...')` and `it('SHOULD ...')` for **net-new** tests only. Do not rename existing tests solely to enforce WHEN/SHOULD.
### 1.3 Git & Commit Hygiene
- Never commit without explicit user approval.
- Never push without explicit user approval.
- Always request approval before **each** commit and **each** push.
- Present changes and wait for explicit confirmation.
---
## 2. Verification & CI Parity
### 2.1 Per-Change Verification
Verify at narrowest scope, expand at milestones:
| Scope | When |
|---|---|
| Single file (type check + lint + jest) | After every file change |
| All files using a utility | After utility refactoring complete |
| Entire feature/plugin | After feature iteration complete |
| Full suite | Before PR |
### 2.2 Quick CI Parity Checks
These are individual CI checks useful during migration. They do **not** create commits.
| Check | Command | When |
|---|---|---|
| TS project coverage | `node scripts/lint_ts_projects` | After adding new TS/TSX files (especially `__mocks__/**`). Can be run with `--fix` (may modify files); without `--fix` it is check-only. |
| Jest config sanity | `node scripts/check_jest_configs` | After moving/adding jest configs |
| File casing | `node scripts/check_file_casing --quiet` | After renames/moves (macOS vs CI) |
| Test hardening | `node scripts/test_hardening` | Before pushing |
**Optional (run when relevant):**
| Check | Command | When |
|---|---|---|
| Package metadata | `node scripts/lint_packages` | After touching `kibana.jsonc` / manifests. Can be run with `--fix` (may modify files); without `--fix` it is check-only. |
| Prettier topology | `node scripts/prettier_topology_check` | After touching prettier config |
| i18n | `node scripts/i18n_check --quiet` | After changing user-visible strings |
| FTR/Scout configs | `node scripts/check_ftr_configs` / `node scripts/scout discover-playwright-configs --validate` | After touching FTR/Scout config |
| Moon project generation | `node scripts/regenerate_moon_projects.js --update --filter <project>` | After adding new path groups (e.g. `__mocks__/**`) |
### 2.3 Test Title Report — Mandatory Coverage Parity
Every migration **must** prove test coverage parity by comparing test titles between BEFORE (main) and AFTER (migration branch) worktrees.
**Workflow:**
1. Generate a local CSV report first (no gist).
2. If tests were intentionally renamed/merged, use `--replacements` to map old → new titles.
3. Only after the report passes → create/update gist.
```bash
# Local report
f-jest-test-title-report \
--before "/path/to/before-worktree" \
--after "/path/to/after-worktree" \
--scope "relative/scope/inside/repo" \
--out "/tmp/scope.jest_titles.before_after.csv"
```
**Pass criteria (no replacements):**
- 0 added tests (AFTER-only rows)
- 0 removed tests (BEFORE-only rows)
- 0 changed titles
**With replacements** (for intentional rename/merge):
```bash
f-jest-test-title-report \
--before "..." --after "..." --scope "..." \
--out "/tmp/scope.csv" \
--replacements "/tmp/scope.jest_title_replacements.json"
```
Replacements JSON:
```json
{
"x-pack/.../some.test.tsx": {
"old title A": "new consolidated title B",
"old title C": "new consolidated title B"
}
}
```
After pass → `--gist` to share.
---
## 3. Systematic Utility Refactoring
### Workflow (One Utility at a Time)
1. Pick **one** utility function with timer code (e.g. `clickSubmitButton`).
2. Remove timer advancement from the utility itself.
3. Find **all** call sites: `rg "utilityName\(" --files-with-matches`.
4. Fix **each** call site with appropriate `waitFor`.
5. Verify each changed file at narrowest scope.
6. Run full suite when utility refactoring is complete.
7. Move to next utility.
### Why This Works
- Isolated impact: only call sites of the current utility are affected.
- Fast feedback: verify each file immediately.
- Clear debugging: know which utility caused issues.
- Incremental progress: each utility is a stable checkpoint.
### Critical Pitfall — Missing Await
When removing timers from a utility that was `async` for timers and is still `async` for `waitFor`:
```typescript
// ❌ Missing await — runs immediately
waitForValidation();
expect(someCondition()).toBe(true);
// ✅ Awaited
await waitForValidation();
expect(someCondition()).toBe(true);
```
**Detection:** `@typescript-eslint/no-floating-promises` or manual `rg "utilityName\(" | rg -v "await.*utilityName"`.
When utility signatures change, use TypeScript compiler errors as your checklist.
---
## 4. Enzyme Artifact Cleanup
### Search Patterns
| Enzyme | RTL Replacement |
|---|---|
| `.exists()` | `screen.getBy*` / `screen.queryBy*` |
| `.find(selector)` | `screen.getByTestId(...)` / `within(...).getBy*` |
| `.simulate('click')` | `fireEvent.click(element)` |
| `shallow(<C />)` | `render(<C />)` |
| `mount(<C />)` | `render(<C />)` |
| `wrapper.update()` | (not needed — RTL re-queries live DOM) |
| `wrapper.prop('foo')` | Assert via DOM output, not internal props |
### Common Artifact
```typescript
// ❌ Enzyme
expect(wrapper.find('Component').exists()).toBe(true);
// ✅ RTL
expect(screen.getByTestId('component')).toBeInTheDocument();
```
---
## 5. Mixed Testbed/RTL Coexistence
### Problem
When Enzyme (testbed) and RTL tests coexist in the same file, Enzyme-rendered portals/modals persist into subsequent RTL tests.
- RTL auto-cleanup only cleans RTL-rendered components.
- Enzyme uses a different React root; its DOM persists.
- Portals (modals, flyouts) render outside React root.
### Symptoms
- RTL test fails with unexpected modal/overlay blocking interaction.
- DOM shows elements from previous testbed test.
- `waitFor` timeouts because UI state is polluted.
### Solution — Migrate Consecutive Sections
- Migrate adjacent `describe` blocks together to RTL.
- Avoid testbed → RTL boundaries within the same file.
```
❌ [RTL] → [testbed] → [RTL] (middle testbed pollutes third RTL)
✅ [RTL] → [RTL] → [RTL] (consecutive RTL, clean boundaries)
```
---
## 6. Hybrid Mocks for Incremental Migration
### Problem
Component mocks must support **both** Enzyme and RTL during gradual migration.
Enzyme's `simulate()` creates synthetic events with `.target` — naive checks fail.
### Solution — Check for Array-Like Structure First
Enzyme uses numeric indices; RTL uses DOM events with `target.value`.
```typescript
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null;
const isArrayLikeWithZero = (v: unknown): v is { 0: unknown } =>
isRecord(v) && '0' in v;
// In mock onChange:
onChange={(evt: unknown) => {
if (isArrayLikeWithZero(evt)) {
// Enzyme path
const first = (evt as { 0: Record<string, string> })[0];
props.onChange([{ label: first.label, value: first.value }]);
return;
}
// RTL path
if (isRecord(evt) && isRecord((evt as any).target)) {
const value = (evt as any).target.value as string;
props.onChange([{ label: value, value }]);
}
}}
```
**Detection logic:**
- `event['0'] === undefined` → RTL
- `event['0'] !== undefined` → Enzyme
Remove hybrid logic after full migration.
---
## 7. Skipped Tests — Unskip and Fix
Skipped tests (`it.skip`, `describe.skip`, `xtest`) are usually quarantined for CI flakiness.
**Rules:**
- Skipped tests are **not** dead code. Do not delete them during migration.
- Only remove when you can prove the feature/test is obsolete (link removal PR/issue).
**Migration responsibility:**
1. Unskip the test.
2. Fix root cause (isolation, timers, splitting, `fireEvent`).
3. Track closure in PR description (`Fixes #XXXXX`). Do not add inline "Fixes" comments in test code.
**Common flakiness causes:**
| Cause | Pattern |
|---|---|
| Inter-test state leak | Test isolation |
| Timeouts | Performance / individual timeouts; fake timers only if measurably helpful |
| Heavy interactions | `fireEvent` over `userEvent` |
| Large test files | Test splitting |
---
## 8. Avoid Plugin-Specific Testbed Frameworks
Use `@kbn/test-eui-helpers` **only** for EUI-specific component harnesses.
For plugin tests, prefer lightweight helpers over harness frameworks.
| ✅ OK | ❌ Avoid |
|---|---|
| Small file-local helpers | "Actions" objects / page-object DSLs |
| File-local helper module when test overflows | Deep helper stacks (test → helpers → actions → helpers → impl) |
| Small shared utilities (e.g. `fake_timers.ts`) | Plugin-specific "harness" layers duplicating RTL |
---
## 9. EUI Harness Implementation Guidelines
When creating or modifying harnesses in `@kbn/test-eui-helpers`:
### API Semantics
- All non-action `get*()` methods return `null` / empty (query semantics, don't throw).
- Action methods (`click` / `select` / `toggle`) throw when they cannot proceed.
- No dynamic DOM-query getters (`public get foo()`). Prefer explicit methods (`getFoo()`).
- No backwards-compat aliases; update call sites instead.
- No new dependencies for harness logic. Stay lightweight.
### RTL Patterns Inside Harnesses
- Direct `fireEvent` calls (no `act()` wrapping — `fireEvent` is already wrapped).
- `waitFor` / `findBy` for async waiting (no manual timer advancement).
- RegExp `queryByTestId` / `getByTestId` for space-separated `data-test-subj`.
- `document.getElementById` for EuiSuperSelect options.
- Wait for dropdown closure after selections.
- **Never** use empty `act()` blocks.
### EuiComboBox Harness Nuance
`selectOptionAsync()` validates via pills (`data-test-subj="euiComboBoxPill"`).
For `singleSelection: { asPlainText: true }` (no pills), prefer `selectOption()` + a downstream UI boundary.
### EuiSuperSelect Test-Subj Placement
Some usages apply `data-test-subj` to the actual `<button>` (not a wrapper).
Harnesses must handle both: if `getByTestId` returns a button, use it directly; otherwise find the button within.
### What Belongs in `@kbn/test-eui-helpers`
- Pagination button selection
- Popover panel discovery + context menu item selection (by role + accessible name)
- Table row discovery (by cell text)
- Table cell value extraction with optional normalization (trim + collapse whitespace)
- List item action selection (bounded ancestor walk — fail fast, don't traverse the whole document)
### When to Use Harness vs Manual
| Harness | Manual |
|---|---|
| Standard SuperSelect with single test-subj element | Multiple elements share test-subj |
| Options have consistent `data-test-subj` | Options lack test-subj (use `getElementById`) |
| Simple selection | Complex multi-part controls |
**Manual pattern (full example):**
```typescript
const elements = within(container).queryAllByTestId('fieldName');
const button = elements.find((el) => el.tagName === 'BUTTON');
fireEvent.click(button!);
await screen.findByRole('listbox');
const option = document.getElementById('optionValue')!;
fireEvent.click(option);
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
```
### EuiSuperSelect Option Selection Determinism
- Always wait for the dropdown listbox to open after clicking the control, and wait for it to close after selection.
- If multiple options share the same `data-test-subj`, default behavior should throw with a clear message. If a test intentionally expects duplicates, allow selecting by index (deterministic).
- When there is no stable `data-test-subj`: prefer selecting by DOM id (`document.getElementById`) when EUI sets option ids to values.
### Correct Harness Implementation Example
```typescript
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
public async selectOptionAsync(searchText: string) {
const input = this.#inputEl;
fireEvent.focus(input);
fireEvent.click(input);
fireEvent.change(input, { target: { value: searchText } });
await waitFor(() => {
const optionsList = screen.queryByTestId(
new RegExp(`${escapeRegExp(this.#testId)}-optionsList`)
);
if (!optionsList) throw new Error(`Options list did not appear for: "${searchText}"`);
const options = within(optionsList).queryAllByRole('option');
if (options.length === 0) throw new Error(`No options found for: "${searchText}"`);
});
const optionsList = screen.queryByTestId(
new RegExp(`${escapeRegExp(this.#testId)}-optionsList`)
);
if (optionsList) {
const option = within(optionsList).queryByText(searchText);
if (option) {
fireEvent.click(option);
await waitFor(() => {
const selected = this.selectedOptions;
if (!selected.includes(searchText)) {
throw new Error(`Selection did not propagate for: "${searchText}"`);
}
});
}
}
}
```
Key principles:
- Direct `fireEvent` calls (no `act()` wrapping).
- `waitFor` / `findBy` over manual timers.
- RegExp `queryByTestId` for space-separated `data-test-subj`.
- Prefer matching option by text (not "first item wins" by index).
---
## 10. TS Project Lint / tsconfig Coverage
### Problem
CI fails with "files do not belong to a tsconfig.json file" when new files (especially `__mocks__/**`) are not included.
### Fix
Ensure the nearest `tsconfig.json` includes those paths:
```json
{
"include": [
"__jest__/**/*",
"__mocks__/**/*",
"public/**/*",
"server/**/*"
]
}
```
Verify: `node scripts/lint_ts_projects`
---
## 11. Plugin-Local `__mocks__` for Heavy UI Dependencies
### Priority Order
1. **Shared mocks** (repo-wide) when available — e.g. `import '@kbn/code-editor-mock/jest_helper'`.
2. **Plugin-local** `__mocks__/` (only if no shared mock exists).
3. **Inline** `jest.mock()` factories (last resort).
### Pattern
```typescript
// ✅ Shared mock exists
import '@kbn/code-editor-mock/jest_helper';
// ✅ Plugin-local mock (when no shared mock)
// File: <pluginRoot>/__mocks__/@kbn/code-editor/index.tsx
jest.mock('@kbn/code-editor');
// ❌ Repeated inline factory across files
jest.mock('@kbn/code-editor', () => ({ CodeEditor: () => <input /> }));
```
### Mock Shape Guidelines
- **CodeEditor:** Simple `<input>` with `data-test-subj`, `data-currentvalue`, and `onChange` forwarding.
- **Charts:** Stub `Chart` / `Axis` / `Settings` rendering `<div data-test-subj="mockChart">{children}</div>`.
**Additional rules:**
- Warning noise from shared mocks is acceptable short-term; fix the shared mock once rather than forking mocks per plugin.
- Do **not** create a repo-root `__mocks__/` that affects unrelated plugins unless you explicitly want global behavior.
Ensure `__mocks__/**` is included in `tsconfig.json` (see §10).
---
## 12. History Rewrite for Large Migrations
### Fixup + Autosquash (Permission-Gated)
After migration is working and verified, consolidate commits into a clean series.
**Requires explicit user approval.**
```bash
# Safety backup
git branch backup/before-history-rewrite-$(git rev-parse --short HEAD)
# Create fixup targeting the commit to amend
git commit --fixup=<target-sha>
# Fold fixups without editor
GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash <base>
```
### Remote-Safe Rewrite — Recovery Anchor
Before rewriting history on a branch that exists on a remote, create **both**:
1. **Local** backup ref.
2. **Remote** recovery anchor:
```bash
git push origin backup/before-history-rewrite-<short-sha>
```
### Parity Verification Before Force-Update
```bash
# Compare old remote tip vs new HEAD
git diff --name-status origin/<branch> HEAD
git diff --stat origin/<branch> HEAD
# Compare file sets vs main
git diff --name-only main..origin/<branch> | sort > /tmp/origin-files.txt
git diff --name-only main..HEAD | sort > /tmp/head-files.txt
comm -3 /tmp/origin-files.txt /tmp/head-files.txt
```
Always use `--force-with-lease` (never plain `--force`).
### PR Tooling with Forks
When local repo has multiple remotes (fork + upstream), use `--repo <owner>/<repo>` with `gh` commands to avoid targeting the wrong repo.
---
## 13. Kibana Conventions
### `data-test-subj` (Not `data-testid`)
Kibana uses `data-test-subj` across FTR and Jest tests.
RTL's `screen.getByTestId('foo')` queries `data-test-subj="foo"` (configured in Kibana's RTL setup).
### Test & Lint Commands
| Task | Command |
|---|---|
| Type check | `node scripts/type_check --project <path-to-closest-tsconfig>` |
| Lint | `node scripts/eslint <path>` |
| Unit test | `node scripts/jest <path>` |
| Integration test | `node scripts/jest_integration <path>` (only if `jest.integration.config.js` exists) |
**Note:** Do not use `tsconfig.type_check.json`; always use `--project` pointing at the closest `tsconfig.json`.
### Slow Test Detection
```bash
SHOW_ALL_SLOW_TESTS=true node scripts/jest <path>
```
### Reference PRs
- **#242062** — ILM migration (most recent, canonical reference)
- **#239643** — CCR migration (canonical patterns)
- **#238764** — Management/ES UI Shared (timer handling, userEvent setup)
---
## 14. Semantic Code Search for Debugging
When tests fail in your branch but you suspect they worked before:
1. Use `semantic_code_search` to check the main branch (searches main by default).
2. Compare implementations, not just test structure.
3. Look for differences in: default routes, function signatures, test setup patterns.
**Always use when:**
- "This test should work but doesn't."
- "How did this work before?"
Advantage: no git worktrees or checkouts needed — instant main branch access.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment