1

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, "joe@soap.co.za");
    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, "error@error.co.za");
    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("error@error.co.za");
    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 === "error@error.co.za") {
      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!

  • Please edit the question to limit it to a specific problem with enough detail to identify an adequate answer. – Community Oct 01 '21 at 17:46

2 Answers2

8

You should probably also reset the api between the tests, as the api has internal state as well.

Call

afterEach(() => {
  store.dispatch(api.util.resetApiState())
})

For reference, this is how RTK Query internally sets up the tests: https://github.com/reduxjs/redux-toolkit/blob/4fbd29f0032f1ebb9e2e621ab48bbff5266e312c/packages/toolkit/src/query/tests/helpers.tsx#L115-L169

phry
  • 35,762
  • 5
  • 67
  • 81
  • 1
    Thanks for the recommendation. I saw you said that on another question so I tried that but it doesn't help. I just thought of something that I am going to try, will let you know if it works. – Chris van der Merwe Sep 24 '21 at 07:21
  • I've setup my api store the same way you do in the link above, and when everything is wired up, the same error comes up. It is like the first test's call to the reducer matcher is being re-used by the second test. This makes me think that it is a cleanup problem but I seem to have all the necessary cleanup in place. – Chris van der Merwe Sep 24 '21 at 08:04
  • 1
    Generally in Redux, every action is always sent to every reducer - has your test maybe just found a bug in your application where an action is handled incorrectly/only in special edge cases? – phry Sep 24 '21 at 08:10
  • It may be a lack of understanding of how redux-toolkit's addMatcher works especially with RTK query. It was my understanding that the matcher's callback is only called when the specified endpoint is called. For example, `api.getCustomers.endpoints.matchFulfilled` shouldn't be called unless that query is called and succeeds, which the "fail scenario" doesn't do. But again, it is completely plausible that I've got this wrong. – Chris van der Merwe Sep 24 '21 at 08:20
  • No, that should be correct. Can you log out all actions that are dispatched and take a look at that? – phry Sep 24 '21 at 08:25
  • Yeah, will take a look at that. Sorry to bother but could you give me some general guidance on how that can be achieved from within my testing code? Would logging middleware be the best bet? – Chris van der Merwe Sep 24 '21 at 08:29
  • Yup, middleware. Generally I'd recommend coming over to #redux on Reactiflux - many ppl there that can maybe help. I'll be gone for a few hours soon. – phry Sep 24 '21 at 08:32
  • Thank you for this!!! I've been converting over to RTK Query from GraphQL. Got most of my tests running smoothly - but one of them was sending requests to the server despite having used MSW to setup the mock server... As soon as I added this it started working as expected :) – Ryan Pierce Williams Aug 17 '23 at 13:02
-2

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

  • please can we see your type declarations, such as `ApiFailResponse`, `LoginRequest` – neil Aug 18 '22 at 21:41