在使用 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 猜对了。
我一天中的大部分时间都在尝试解决这个极其烦人的错误。
我正在使用 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 猜对了。