在使用 MSW 和 RTK 查询时使用 Jest 进行测试会导致测试出现奇怪的错误

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

我一天中的大部分时间都在尝试解决这个极其烦人的错误。

我正在使用 redux-toolkit、MSW、RTK 查询和 React Testing Libary,目前正忙于编写测试简单登录流程的集成测试。

我遇到的问题是我在一个测试套件中测试两种不同的场景,一种是成功登录,一种是失败。

当我一次 运行 一个时,我没有遇到任何问题,但是当我 运行 两个时,我收到以下失败场景的错误。

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)

奇怪的是,失败的场景不应该到达调用 API 调用的页面,该调用会导致这个额外的 reducer 匹配器,因此没有有效负载并发生错误.

当我在浏览器中测试时不会发生这种情况,只有在使用 Jest 测试时才会发生。

以下是我的测试:

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");
  });
});

下面是我的 redux 测试设置:

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 };

下面是我的 setupTests.ts 文件。

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

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

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

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

最后是我的 MSW 文件。

处理程序

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));
  }),
];

服务器

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

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

有什么想法吗?

感谢您的帮助!

您可能还应该在测试之间重置 api,因为 api 也有内部状态。

致电

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

作为参考,RTK Query 内部设置测试的方式如下:https://github.com/reduxjs/redux-toolkit/blob/4fbd29f0032f1ebb9e2e621ab48bbff5266e312c/packages/toolkit/src/query/tests/helpers.tsx#L115-L169

这是由于我的应用程序中出现在边缘情况下的错误,@phry 猜对了。