现代前端框架在测试方面表现不佳

发布: (2025年12月28日 GMT+8 03:47)
10 min read
原文: Dev.to

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 框架并未提供明确或有主见的做法。它们的文档通常落入以下三类:
  1. 完全缺失
  2. 仅略作提及
  3. 外包 给 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 的强大功能——于是决定以另一种方式使用它。

方法

我们没有在孤立环境中测试路由,而是:

  1. 添加了专用的测试路由
  2. 在真实应用中执行 TWD 测试
  3. 使用路由存根渲染页面
  4. 在每个测试中控制 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 测试策略能够更好地扩展。

Back to Blog

相关文章

阅读更多 »

修复 Next.js 中的水合错误

Hydration 错误的常见原因 浏览器/环境问题 - 浏览器扩展注入属性、密码管理器、广告拦截器、辅助功能工具 - Br...