现代前端框架在测试方面表现不佳
Source: Dev.to
请提供您希望翻译的完整文本(除代码块和 URL 之外),我将按照要求将其译成简体中文并保留原有的格式。
Source: …
发布 TWD 后的观察
在发布 TWD——一个旨在将测试直接集成到开发工作流中的工具——几周后,我开始为不同的前端框架编写测试配方。在此过程中,出现了一些非常明显的模式。
面向 SPA 的应用
- 仅前端(SPA)应用相对容易使用 TWD 以及传统的运行器(如 Vitest)进行测试。
- 你可以验证真实的用户交互,将测试紧贴 UI,并快速迭代。
SSR 框架
- Next.js、Astro、TanStack、Qwik、Solid 等框架几乎不提供可靠且可维护的测试指南。
- 大多数框架仅把测试工作交给外部工具(如 Playwright 或 Cypress),例如 Next.js 文档中所示,把测试当作事后考虑,而不是框架本身的一部分。
为什么 SSR 让测试变得混乱
- SSR 模糊了后端与前端的界限。理论上这很强大,实际操作中却会产生严重的测试问题。
- 你需要同时测试前端和后端行为,但 SSR 框架并未提供明确或有主见的做法。它们的文档通常落入以下三类:
- 完全缺失
- 仅略作提及
- 外包 给 E2E 工具(如 Playwright 或 Cypress)
“这里教你如何快速构建。测试是你的事。”
从公司角度——尤其是构建长期产品的公司——这是一大红旗。你需要:
- 易于编写 的测试
- 当真正出现问题时能够可靠地失败的测试
- 能帮助复现 bug的测试
- 能随产品演进的测试
大多数 SSR 框架并未提供这些。它们优化的是开发速度,但你会从第一天起就开始积累测试债务。
在 SSR 中对前端代码进行单元测试的现实
- 在技术上是可行的,但实际操作中单元测试往往沦为“为了测试而测试”。
- 常见陷阱:
- 对系统的一半进行 Mock
- 测试实现细节
- 未测试真实的用户交互
最终你会回到 Playwright,这又会带来一系列新问题:
- 测试位于应用之外
- 测试更慢
- 调试更困难
- 感觉不像是开发工作流的一部分
这种模式在 Next.js 应用中极为常见。至今我仍未见到真正稳健的 Next.js 测试方案,能够:
- 仅在真正有意义的错误出现时失败
- 避免实现细节
- 易于编写和维护
所谓“稳健”,指的是能够真正保护产品的测试——而不是仅仅提升覆盖率数字。
一个显著的例外:React Router
在 React 生态系统中,React Router(尤其是 createRoutesStub)脱颖而出。
createRoutesStub 为你提供的功能
- 带有 loader 和 action 的模拟路由
- 为每个页面创建不同的场景
- 以真实的方式测试前端行为
- 将 loader 和 action 作为纯函数单独测试
这种关注点分离是许多框架试图复制的,但 React Router 做到了。
对测试的好处
- 直接明了——API 简单易用。
- 明确清晰——你可以清楚地看到哪些内容被存根。
- 可预测——行为是确定性的。
更棒的是,从 SPA 数据模式迁移到框架(SSR)模式是 渐进且低风险 的。
在研究了多种替代方案并且考虑到测试是产品最关键的环节之一后,React Router 是我今天唯一会亲自选择的支持 SSR 的解决方案。
使用 createRoutesStub 与 TWD
在构建 TWD 时,我们注意到 createRoutesStub 的强大功能——于是决定以另一种方式使用它。
方法
我们没有在孤立环境中测试路由,而是:
- 添加了专用的测试路由
- 在真实应用中执行 TWD 测试
- 使用路由存根渲染页面
- 在每个测试中控制 loader、action 和场景
设置
// src/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("todos", "routes/todolist.tsx"),
route("testing", "routes/testing-page.tsx"),
] satisfies RouteConfig;
// app/routes/testing-page.tsx
let twdInitialized = false;
export async function clientLoader() {
if (import.meta.env.DEV) {
const testModules = import.meta.glob("../**/*.twd.test.{ts,tsx}");
if (!twdInitialized) {
const { initTWD } = await import('twd-js/bundled');
initTWD(testModules, {
serviceWorker: false,
});
twdInitialized = true;
}
return {};
} else {
return {};
}
}
export default function TestPage() {
return (
<div data-testid="testing-page">
<h1>TWD Test Page</h1>
<p>This page is used as a mounting point for TWD tests.</p>
</div>
);
}
// test-utils/setupReactRoot.ts
import { createRoot } from "react-dom/client";
import { twd, screenDomGlobal } from "twd-js";
let root: ReturnType<typeof createRoot> | undefined;
export async function setupReactRoot() {
if (root) {
root.unmount();
root = undefined;
}
// Navigate to the empty test page
await twd.visit('/testing');
// Get the container from the test page
const container = await screenDomGlobal.findByTestId('testing-page');
root = createRoot(container);
return root;
}
// example.test.tsx
import { createRoutesStub } from "@react-router/dev";
import { useLoaderData, useParams, useMatches } from "react-router-dom";
import { Home } from "../routes/home";
import { setupReactRoot } from "./test-utils/setupReactRoot";
describe("Hello World Test", () => {
// root instance to re‑render the stub
let root: ReturnType<typeof createRoot> | undefined;
beforeEach(async () => {
// preparing the root instance
root = await setupReactRoot();
});
it("should render home page test", async () => {
// mocking the component
const Stub = createRoutesStub([
{
path: "/",
Component: () => {
const loaderData = useLoaderData();
const params = useParams();
const matches = useMatches() as any;
return (
<div>
{/* Your test UI here */}
</div>
);
},
loader() {
return { title: "Home Page test" };
},
},
]);
// Render the Stub
root!.render(<Stub />);
await twd.wait(300);
// Check for the element within our test container
// We scope the search to the container to be safe,
// or just search globally since …
});
});
注意: 上面的测试演示了如何 渲染一个存根路由、控制其 loader 数据,并断言 UI 输出——所有这些都在真实的应用上下文中完成,使用 TWD 作为测试运行器。
TL;DR
- SSR 框架 常常把测试当作事后才考虑的事情,导致测试套件脆弱且难以维护。
- React Router 的
createRoutesStub提供了一种简洁、明确的方式,在保持在实际应用中运行的同时,能够孤立地测试路由逻辑和 UI。 - 通过 将 TWD 嵌入专用的测试路由,你可以获得快速、集成的工作流,感觉就像是开发过程的自然延伸。
如果你正在 SSR 堆栈上构建一个长期维护的产品,建议从第一天起就采用这种模式,以避免日后产生测试债务。
示例:集成 twd + React Router
// test file (e.g., HomePage.test.ts)
import { screenDom } from 'twd';
import { renderWithRouter } from './test-utils';
test('renders home page heading', async () => {
renderWithRouter();
// The harness is empty otherwise
const h1 = await screenDom.findByRole('heading', { level: 1 });
twd.should(h1, 'have.text', 'Home Page test');
});
一旦测试运行,路由将在真实的应用程序中渲染,具备完整的交互性,所有断言均通过。
选择合适的策略
- 继续使用优化速度但会累积测试债务的工具
- 或采用将测试融入日常开发的方式
为什么此方案非常适合 SSR 应用
- 真实 UI 测试 – 组件在真实浏览器环境中运行。
- 可维护性和更易调试 – 测试镜像实际用户流程。
- 长期产品 – 测试套件随代码库成长而不易脆弱。
- 快速反馈 – 失败能迅速显现,保持紧凑的开发循环。
如果你在处理大型、需求复杂的应用程序,这种方法相较于传统的 SSR 测试策略能够更好地扩展。