skip to content
walterra.dev

Lessons learned from upgrading testing-lib's user-event to v14 in Kibana

/ 4 min read

Table of Contents

Kibana is among the largest public monorepos on Github with 3+ millions lines of TypeScript, 150+ monthly committers.

Maintaining this codebase comes with its own challenges. One of them is to keep third party dependencies up to date. Last year (I wanted to write about this for a while …), I took on upgrading @testing-library/user-event from version 13 to the then latest v14.5.2, it turned out to be quite a rabbit hole!

This wasn’t a small PR: The upgrade affected 401 files (5695 inserts, 4719 deletions) and it required reviews from 20+ CODEOWNER teams! user-event version 14 was already released back in March 2022, but the update wasn’t merged to Kibana until September 2024, tech debt knocking! So this is definitely not a blog post about the newest/fanciest of things, more like acknowledging that tech debt can be of course be real in large codebases.

Breaking Changes

Version 14 of user-event came with changes in how events are handled. The most impactful changes were:

  1. user-event methods now return Promises - Every userEvent.click(), userEvent.type(), etc. now needs to be awaited.

  2. Pointer event handling changed - The old pattern of using { skipPointerEventsCheck: true } needed to be replaced with { pointerEventsCheck: 0 }.

  3. Event bubbling and propagation - The new version more closely resembles real browser behavior, which uncovered some hidden bugs in our tests.

Performance Regressions

One unexpected challenge was performance degradation. Our test suite became noticeably slower after the upgrade and hitting timeouts became a problem, particularly with typing operations. That’s not just a Kibana-specific issue – there are some reports of similar issues with v14.

We implemented several workarounds:

// Optimizing typing performance
userEvent.setup({ delay: null }); // Disable artificial delay
// For pointer event checks
userEvent.click(element, { pointerEventsCheck: 0 }); // Skip expensive DOM traversal
// Workaround for timeout via https://github.com/testing-library/user-event/issues/833#issuecomment-1171452841
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });

In other cases we had to defer to fireEvent.

The maintainers have made it clear that simulating realistic user behavior takes precedence over test speed for them. While this is philosophically sound, it creates practical challenges for large test suites with timeouts. In our case, we had to increase some test timeouts and reconsider our testing approach for some form-heavy components. Regressions like this are also a cost factor in large organizations since it will increase the time spent on CI runs.

Implementation Challenges

In a codebase with thousands of tests, these changes were non-trivial. I initially hoped to use a codemod for most of the conversion, but the edge cases proved too numerous and context-dependent, esp. around custom test harnesses.

Some patterns that needed updating:

// Before
userEvent.click(element);
expect(something).toBe(true);
// After
await userEvent.click(element);
expect(something).toBe(true);

And for pointer event checks:

// Before
userEvent.click(element, undefined, { skipPointerEventsCheck: true });
// After
userEvent.click(element, { pointerEventsCheck: 0 });

Change to check if an element was removed:

// Before
await waitForElementToBeRemoved(() => screen.getByText('Learn React'));
// After
await waitFor(() => {
expect(screen.queryByText('Learn React')).not.toBeInTheDocument();
});

Keyboard events:

// Before
userEvent.keyboard('{arrowright}');
// After
await user.keyboard('[ArrowRight]');

Migrate away from act:

// Before
act(() => {
userEvent.type(
screen.getByTestId('some-id'),
'some text'
);
});
// After
await userEvent.type(
screen.getByTestId('some-id'),
'some text'
);

The API for userEvent.paste changed:

// Before
userEvent.paste(await screen.findByTestId('some-id'), 'some text');
// After
await userEvent.click(await screen.findByTestId('some-id'));
await userEvent.paste('some text');

Lessons Learned

This upgrade was essentially an archaeological dig through years of test patterns of different teams, it was a great learning experience. Doing the whole thing was worth the pain, it was highly appreciated by other developers. Note that all of this was done manually since back then we didn’t have any approval to use fully agentic LLM coding workflows. Not sure how many projects out there are still on v13, hope this summary is still useful for some devs.

Check out the full PR on GitHub if you’re interested in the gory details.