Best approach to wait on material-ui ripples to complete before taking snapshots
Asked Answered
A

2

6

I am hoping to get some expert advice and guidance from the React Testing Library community on the best way to ensure a Material UI ripple animation has been completed prior to taking a snapshot.

This issue has been causing flakey tests for us with everything passing locally all the time but when we run on our CI servers the tests are intermittent and failing as the animation finishes quicker than it does locally meaning it is does not match the stored snapshot.

The issue only arose once we swapped out fireEvent for userEvent which makes sense with the additional actions under the hood userEvent delivers but want I want to get to is having snapshots stored that don't contain any UI transitions.

I have created a simple test below which will help illustrate the issue (also on codesandbox):

    import React from "react";
    import { render, screen, fireEvent, waitFor } from "@testing-library/react";
    import userEvent from "@testing-library/user-event";
    import Button from "@material-ui/core/Button";
    
    test("clicks material button with fireEvent - no ripple implication", () => {
      render(
        <Button variant="contained" color="primary">
          Ripple
        </Button>
      );
    
      const btnContainer = screen.getByRole("button", { name: /ripple/i });
    
      expect(btnContainer).toBeInTheDocument();
      screen.debug(btnContainer);
    
      fireEvent.click(btnContainer);
      screen.debug(btnContainer);
    });
    
    test("clicks material button with userEvent", async () => {
      render(
        <Button variant="contained" color="primary">
          Ripple
        </Button>
      );
    
      const btnContainer = screen.getByRole("button", { name: /ripple/i });
    
      expect(btnContainer).toBeInTheDocument();
      screen.debug(btnContainer);
    
      userEvent.click(btnContainer);
      screen.debug(btnContainer);
    });
    
    test("clicks material button with userEvent wait for ripples", async () => {
      render(
        <Button variant="contained" color="primary">
          Ripple
        </Button>
      );
    
      const btnContainer = screen.getByRole("button", { name: /ripple/i });
    
      expect(btnContainer).toBeInTheDocument();
      screen.debug(btnContainer);
    
      userEvent.click(btnContainer);
      screen.debug(btnContainer);
    
      await waitForRippleToRemove(btnContainer);
      // without the ripple transition being complete inconsitent tests runs can occur
      // when using snapshots as per commented line below
      // (ie some have ripples some don't)
      // expect(asFragment()).toMatchSnapshot();
    
      screen.debug(btnContainer);
    });
    
    function waitForRippleToRemove(container) {
      return waitFor(() => {
        expect(
          container.querySelector("span.MuiTouchRipple-root")
        ).toBeEmptyDOMElement();
      });
    }

The first test works fine using the fireEvent with the button always looking like:

    <button
      class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary"
      tabindex="0"
      type="button"
    >
      <span
        class="MuiButton-label"
      >
        Ripple
      </span>
      <span
        class="MuiTouchRipple-root"
      />
    </button> 

However, when using the userEvent in test 2 the buttons looks like the following after the click:

    <button
      class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary"
      tabindex="0"
      type="button"
    >
      <span
        class="MuiButton-label"
      >
        Ripple
      </span>
      <span
        class="MuiTouchRipple-root"
      >
        <span
          class="MuiTouchRipple-ripple MuiTouchRipple-rippleVisible"
          style="width: 2.8284271247461903px; height: 2.8284271247461903px; top: -1.4142135623730951px; left: -1.4142135623730951px;"
        >
          <span
            class="MuiTouchRipple-child MuiTouchRipple-childLeaving"
          />
        </span>
      </span>
    </button> 

The span.MuiTouchRipple-ripple element will be removed as part of the transition however my issue is what is the best approach to wait on this occurring as my test shows about I am checking implementation detail (ie using classnames of 3rd party dependency) which feels a bit horrible.


    await waitFor(() => {
        expect(
            container.querySelector("span.MuiTouchRipple-root")
        ).toBeEmptyDOMElement();
    });

Also, it is worth pointing out that depending on the component under test we may have had to use the userEvent.click multiple time so having numerous calls to waitForRippleToRemove(btnContainer)throughout the tests feels like the wrong thing to do but at present is a workable solution.

Artemas answered 11/11, 2020 at 7:37 Comment(0)
T
3

As you already found out, the issue here is with the ripple animation when the button is clicked.

I haven't looked into the MaterialUI code, but animations are usually implemented with some sort of timer i.e setTimeout.

You can usually mock these by using jest.useFakeTimers(); as shown below.

beforeEach(() => jest.useFakeTimers());

test('matches snapshot after clicking button', () => {
  const { asFragment } = render(<Button>Ripple</Button>);

  userEvent.click(screen.getByRole('button', { name: 'Ripple' }));
  jest.runAllTimers();

  expect(asFragment()).toMatchSnapshot();
});


Note

Using fireEvent.click in a few tests seems like a fair compromise. Specially if you are already firing userEvent.click on the same button on different tests.


Source

Turne answered 17/11, 2020 at 12:12 Comment(1)
Thanks for the pointer. I have rewritten the tests in favour of this approach but still seeing inconsistent runs on the CI server. I am tempted to revert back to fireEvent in the short term to stabilise the builds then look for the correct solution in a experimental branch.Artemas
R
2

You can disable the ripple globally when the app is running under jest as Jest sets an environment variable that you can then consume in your global ThemeProvider.

Because all ripples come from ButtonBase you can just add this to your createTheme function:

const muiTheme = createTheme({
    components: {
        MuiButtonBase: {
            defaultProps: {
                // Disable ripple for jest tests
                disableRipple: !!process.env.JEST_WORKER_ID,
            },
        },
     }
});
Roslyn answered 18/5, 2023 at 10:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.