当组件使用 useLoaderData
、<Link>
等时,它们必须在 React Router 应用的上下文中渲染。createRoutesStub
函数创建了该上下文,用于隔离测试组件。
考虑一个依赖 useActionData
的登录表单组件
import { useActionData } from "react-router";
export function LoginForm() {
const actionData = useActionData();
const errors = actionData?.errors;
return (
<Form method="post">
<label>
<input type="text" name="username" />
{errors?.username && <div>{errors.username}</div>}
</label>
<label>
<input type="password" name="password" />
{errors?.password && <div>{errors.password}</div>}
</label>
<button type="submit">Login</button>
</Form>
);
}
我们可以使用 createRoutesStub
来测试这个组件。它接受一个对象数组,这些对象类似于带有加载器、操作和组件的路由模块。
import { createRoutesStub } from "react-router";
import {
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
test("LoginForm renders error messages", async () => {
const USER_MESSAGE = "Username is required";
const PASSWORD_MESSAGE = "Password is required";
const Stub = createRoutesStub([
{
path: "/login",
Component: LoginForm,
action() {
return {
errors: {
username: USER_MESSAGE,
password: PASSWORD_MESSAGE,
},
};
},
},
]);
// render the app stub at "/login"
render(<Stub initialEntries={["/login"]} />);
// simulate interactions
userEvent.click(screen.getByText("Login"));
await waitFor(() => screen.findByText(USER_MESSAGE));
await waitFor(() => screen.findByText(PASSWORD_MESSAGE));
});
值得注意的是,createRoutesStub
是为应用程序中依赖于上下文路由器信息(即 loaderData
、actionData
、matches
)的可重用组件进行单元测试而设计的。这些组件通常通过钩子(useLoaderData
、useActionData
、useMatches
)或从祖先路由组件传递下来的 props 获取这些信息。我们强烈建议您将 createRoutesStub
的使用限制在对这些类型的可重用组件进行单元测试。
createRoutesStub
并非设计用于(并且可以说不兼容)直接测试使用框架模式中可用的 Route.*
类型的路由组件。这是因为 Route.*
类型是从您的实际应用程序派生的——包括真实的 loader
/action
函数以及您的路由树结构(它定义了 matches
类型)。当您使用 createRoutesStub
时,您是根据传递给 createRoutesStub
的路由树为 loaderData
、actionData
甚至您的 matches
提供存根值。因此,类型将与 Route.*
类型不一致,您在路由存根中使用路由组件时会遇到类型问题。
export default function Login({
actionData,
}: Route.ComponentProps) {
return <Form method="post">...</Form>;
}
import LoginRoute from "./login";
test("LoginRoute renders error messages", async () => {
const Stub = createRoutesStub([
{
path: "/login",
Component: LoginRoute,
// ^ ❌ Types of property 'matches' are incompatible.
action() {
/*...*/
},
},
]);
// ...
});
如果您尝试这样设置测试,这些类型错误通常是准确的。只要您存根的 loader
/action
函数与您的实际实现匹配,那么 loaderData
/actionData
的类型就是正确的,但如果它们不同,您的类型就会欺骗您。
matches
更复杂,因为您通常不会存根出所有的祖先路由。在这个例子中,没有 root
路由,所以 matches
将只包含您的测试路由,而在运行时它将包含根路由和任何其他祖先。没有很好的方法可以在您的测试中自动将类型生成类型与运行时类型对齐。
因此,如果您需要测试路由级别的组件,我们建议您通过针对正在运行的应用程序进行集成/E2E 测试(Playwright、Cypress 等)来完成,因为当您测试整个路由时,您正在走出单元测试的范畴。
如果您确实需要针对路由编写单元测试,您可以在测试中添加一个 @ts-expect-error
注释来忽略 TypeScript 错误
const Stub = createRoutesStub([
{
path: "/login",
// @ts-expect-error: `matches` won't align between test code and app code
Component: LoginRoute,
action() {
/*...*/
},
},
]);