Search code examples
reactjsreduxreact-testing-libraryrtk-querymsw

Testing with Jest while using MSW and RTK Query leads to strange error in test


I have been spending most of the day trying to sort out this insanely annoying bug.

I am using redux-toolkit, MSW, RTK query, and React Testing Libary and am currently busy writing an integration test that tests a simple login flow.

The problem I have is that I am testing two different scenarios in one test suite, one is a successful login and one is a failed one.

When I run one at a time, I get no problems, but when when I run both, I get the following error for the failed scenario.

TypeError: Cannot convert undefined or null to object
        at Function.values (<anonymous>)

      59 |       (state, action) => {
      60 |         const { payload } = action;
    > 61 |         adapter.upsertMany(state, payload);
         |                 ^
      62 |       }
      63 |     );
      64 |   },

      at ensureEntitiesArray (node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:794:27)
      at splitAddedUpdatedEntities (node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:799:19)
      at upsertManyMutably (node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:911:18)
      at runMutator (node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:772:17)
      at Object.upsertMany (node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:776:13)
      at src/features/customers/store/customersSlice.ts:61:17
      at recipe (node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:663:32)
      at Immer.produce (node_modules/immer/src/core/immerClass.ts:94:14)
      at node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:662:54
          at Array.reduce (<anonymous>)
      at node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:641:29
      at combination (node_modules/redux/lib/redux.js:536:29)
      at dispatch (node_modules/redux/lib/redux.js:296:22)
      at node_modules/@reduxjs/toolkit/dist/query/rtk-query.cjs.development.js:1366:26
      at node_modules/@reduxjs/toolkit/dist/query/rtk-query.cjs.development.js:1264:26
      at node_modules/@reduxjs/toolkit/dist/query/rtk-query.cjs.development.js:1224:22
      at node_modules/@reduxjs/toolkit/dist/query/rtk-query.cjs.development.js:1138:26
      at node_modules/@reduxjs/toolkit/dist/query/rtk-query.cjs.development.js:1087:22
      at node_modules/@reduxjs/toolkit/dist/query/rtk-query.cjs.development.js:1049:26
      at node_modules/@reduxjs/toolkit/dist/query/rtk-query.cjs.development.js:1424:26
      at node_modules/@reduxjs/toolkit/dist/query/rtk-query.cjs.development.js:1458:24
      at node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:446:22
      at node_modules/redux-thunk/lib/index.js:14:16
      at node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:374:36
      at dispatch (node_modules/redux/lib/redux.js:667:28)
      at node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:1204:37
      at step (node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:38:23)
      at Object.next (node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:19:53)
      at fulfilled (node_modules/@reduxjs/toolkit/dist/redux-toolkit.cjs.development.js:97:32)

What makes this strange is that the failed scenario isn't supposed to get to the page that calls the API call that results in this extra reducer matcher, hence why there is no payload and the error happens.

This doesn't happen when I test in the browser, only when testing with Jest.

Below are my tests:

import React from "react";
import { render, screen, waitFor, cleanup } from "./test-utils";
import App from "../App";
import userEvent from "@testing-library/user-event";
import { waitForElementToBeRemoved } from "@testing-library/react";
import { configureStore } from "@reduxjs/toolkit";
import { api } from "../services/api/api";
import counterReducer from "../features/counter/counterSlice";
import customersReducer from "../features/customers/store/customersSlice";
import subscriptionsReducer from "../features/subscriptions/store/subscriptionsSlice";
import uiReducer from "../features/common/store/uiSlice";
import authReducer from "../features/auth/store/authSlice";

describe("LoginIntegrationTests", () => {
  afterEach(() => {
    cleanup();
  });

  it("should render the correct initial state", function () {
    render(<App />);

    // it doesnt render an appbar
    let navbar = screen.queryByRole("heading", {
      name: /fincon admin console/i,
    });
    expect(navbar).not.toBeInTheDocument();

    // it renders an empty email address field
    const emailField = screen.getByLabelText(/email address/i);
    expect(emailField).toHaveTextContent("");

    // it renders an empty password password field and hides the input
    const passwordField = screen.getByLabelText(/password/i);
    expect(passwordField).toHaveTextContent("");
    expect(passwordField).toHaveAttribute("type", "password");

    // it renders a disabled login button
    const loginButton = screen.getByRole("button", { name: /login/i });
    emailField.focus();
    expect(loginButton).toBeDisabled();
  });

  it("should complete a successful login flow", async function () {
    render(<App />);

    // it fills out the email address and password
    const emailField = screen.getByLabelText(/email address/i);
    const passwordField = screen.getByLabelText(/password/i);

    await userEvent.type(emailField, "[email protected]");
    await userEvent.type(passwordField, "blabla");

    // it clicks the login button
    const loginButton = screen.getByRole("button");
    expect(loginButton).toHaveTextContent(/login/i);

    userEvent.click(loginButton);

    // it sets the loading state
    expect(loginButton).toBeDisabled();
    expect(loginButton).toHaveTextContent(/loading .../i);

    const loadingSpinner = document.querySelector(".k-loading-mask");
    expect(loadingSpinner).toBeInTheDocument();

    // it removes the previous page's components
    await waitFor(() => {
      expect(emailField).not.toBeInTheDocument();
      expect(passwordField).not.toBeInTheDocument();
      expect(loginButton).not.toBeInTheDocument();
      expect(loadingSpinner).not.toBeInTheDocument();
    });

    // it navigates to the customers page
    const accountsPage = screen.getByRole("heading", { name: /accounts/i });
    expect(accountsPage).toBeInTheDocument();

    // it displays the appbar
    const navbar = screen.getByRole("heading", {
      name: /fincon admin console/i,
    });

    expect(navbar).toBeInTheDocument();
  });

  it("should present an error when invalid credentials are entered", async function () {
    render(<App />);

    // it fills in invalid credentials
    const emailField = screen.getByLabelText(/email address/i);
    const passwordField = screen.getByLabelText(/password/i);

    await userEvent.type(emailField, "[email protected]");
    await userEvent.type(passwordField, "blabla1");

    // it clicks the login button
    const loginButton = screen.getByRole("button");
    expect(loginButton).toHaveTextContent(/login/i);

    userEvent.click(loginButton);

    // it sets the loading state
    expect(loginButton).toBeDisabled();
    expect(loginButton).toHaveTextContent(/loading .../i);

    const loadingSpinner = document.querySelector(".k-loading-mask");
    expect(loadingSpinner).toBeInTheDocument();

    // it removes the loading spinner
    await waitForElementToBeRemoved(loadingSpinner);

    // it displays the error
    const errors = await screen.findByText(
      /the provided credentials are invalid/i
    );
    expect(errors).toBeInTheDocument();

    // it stays on the same page
    expect(screen.getByText(/log into the admin console/i)).toBeInTheDocument();

    // it retains the input of the fields
    expect(emailField).toHaveValue("[email protected]");
    expect(passwordField).toHaveValue("blabla1");
  });
});

Below is my redux setup for the tests:

import React from "react";
import { render as rtlRender } from "@testing-library/react";
import { configureStore } from "@reduxjs/toolkit";
import { Provider, useDispatch } from "react-redux";
import { Router } from "react-router-dom";
import { createMemoryHistory } from "history";
import { reducer, store } from "../app/store";
import { api } from "../services/api/api";
import { setupListeners } from "@reduxjs/toolkit/query";
import { renderHook } from "@testing-library/react-hooks";
import counterReducer from "../features/counter/counterSlice";
import customersReducer from "../features/customers/store/customersSlice";
import subscriptionsReducer from "../features/subscriptions/store/subscriptionsSlice";
import uiReducer from "../features/common/store/uiSlice";
import authReducer from "../features/auth/store/authSlice";
// import { useAppDispatch } from "../app/hooks";

function render(
  ui,
  {
    preloadedState,
    store = configureStore({
      reducer: {
        [api.reducerPath]: api.reducer,
        counter: counterReducer,
        customers: customersReducer,
        subscriptions: subscriptionsReducer,
        ui: uiReducer,
        auth: authReducer,
      },
      preloadedState,
      middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware().concat(api.middleware),
    }),
    ...renderOptions
  } = {}
) {
  setupListeners(store.dispatch);

  function Wrapper({ children }) {
    const history = createMemoryHistory();

    return (
      <Provider store={store}>
        <Router history={history}>{children}</Router>
      </Provider>
    );
  }

  // function useAppDispatch() {
  //     return useDispatch();
  // }

  // type AppDispatch = typeof store.dispatch;
  // const useAppDispatch = () => useDispatch<AppDispatch>();

  store.dispatch(api.util.resetApiState());

  return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
}

export * from "@testing-library/react";
export { render };

Below is my setupTests.ts file.

import "@testing-library/jest-dom/extend-expect";
import { server } from "./mocks/server";

beforeAll(() => server.listen());

afterAll(() => server.close());

afterEach(() => {
  server.resetHandlers();
});

And finally my MSW files.

handlers

import { rest } from "msw";
import { authResponse } from "./data";
import { customers } from "../utils/dummyData";
import { LoginRequest } from "../app/types/users";
import { ApiFailResponse } from "../app/types/api";

export const handlers = [
  rest.post("/login", (req, res, ctx) => {
    const body = req.body as LoginRequest;

    if (body.emailAddress === "[email protected]") {
      const response: ApiFailResponse = {
        errors: ["The provided credentials are invalid"],
      };

      return res(ctx.status(400), ctx.json(response));
    } else {
      return res(ctx.json(authResponse));
    }
  }),
  rest.get("/customers", (req, res, ctx) => {
    return res(ctx.json(customers));
  }),
];

server

import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);

Any ideas?

Thanks for all your help!


Solution

  • This was due to a bug in my app that appears in edge cases, as @phry correctly guessed.