Remix测试策略 #

一、测试概述 #

Remix 应用测试包括单元测试、集成测试和端到端测试。

二、测试环境配置 #

2.1 安装依赖 #

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom

2.2 Vitest配置 #

创建 vitest.config.ts

typescript
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./test/setup.ts"],
  },
});

2.3 测试设置 #

创建 test/setup.ts

typescript
import "@testing-library/jest-dom";

三、组件测试 #

3.1 基本组件测试 #

tsx
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Button } from "~/components/Button";

describe("Button", () => {
  it("renders with text", () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText("Click me")).toBeInTheDocument();
  });
  
  it("handles click", async () => {
    let clicked = false;
    render(<Button onClick={() => (clicked = true)}>Click</Button>);
    
    await userEvent.click(screen.getByText("Click"));
    expect(clicked).toBe(true);
  });
});

3.2 表单组件测试 #

tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { SearchForm } from "~/components/SearchForm";

describe("SearchForm", () => {
  it("submits search query", async () => {
    const onSearch = vi.fn();
    render(<SearchForm onSearch={onSearch} />);
    
    await userEvent.type(screen.getByPlaceholderText("搜索..."), "test");
    await userEvent.click(screen.getByText("搜索"));
    
    expect(onSearch).toHaveBeenCalledWith("test");
  });
});

四、路由测试 #

4.1 Loader测试 #

tsx
import { describe, it, expect } from "vitest";
import { loader } from "~/routes/posts.$id";

describe("Post loader", () => {
  it("returns post data", async () => {
    const response = await loader({
      params: { id: "1" },
      request: new Request("http://localhost/posts/1"),
      context: {},
    });
    
    const data = await response.json();
    expect(data.post).toBeDefined();
  });
  
  it("throws 404 for non-existent post", async () => {
    await expect(
      loader({
        params: { id: "non-existent" },
        request: new Request("http://localhost/posts/non-existent"),
        context: {},
      })
    ).rejects.toThrow("文章不存在");
  });
});

4.2 Action测试 #

tsx
import { describe, it, expect } from "vitest";
import { action } from "~/routes/posts.new";

describe("Create post action", () => {
  it("creates a post", async () => {
    const formData = new FormData();
    formData.append("title", "Test Post");
    formData.append("content", "Test content");
    
    const request = new Request("http://localhost/posts/new", {
      method: "POST",
      body: formData,
    });
    
    const response = await action({
      request,
      params: {},
      context: {},
    });
    
    expect(response.status).toBe(302);
    expect(response.headers.get("Location")).toMatch(/\/posts\/\d+/);
  });
});

五、集成测试 #

5.1 使用createRemixStub #

tsx
import { render, screen } from "@testing-library/react";
import { createRemixStub } from "@remix-run/testing";
import { describe, it, expect } from "vitest";
import { Post } from "~/routes/posts.$id";

describe("Post page", () => {
  it("renders post", async () => {
    const RemixStub = createRemixStub([
      {
        path: "/posts/:id",
        Component: Post,
        loader: () => ({
          post: {
            id: "1",
            title: "Test Post",
            content: "Test content",
          },
        }),
      },
    ]);
    
    render(<RemixStub initialEntries={["/posts/1"]} />);
    
    expect(await screen.findByText("Test Post")).toBeInTheDocument();
  });
});

5.2 表单提交测试 #

tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRemixStub } from "@remix-run/testing";
import { describe, it, expect } from "vitest";
import { NewPost } from "~/routes/posts.new";

describe("New post page", () => {
  it("creates a post", async () => {
    const RemixStub = createRemixStub([
      {
        path: "/posts/new",
        Component: NewPost,
        action: () => redirect("/posts/1"),
      },
    ]);
    
    render(<RemixStub initialEntries={["/posts/new"]} />);
    
    await userEvent.type(screen.getByLabelText("标题"), "Test Post");
    await userEvent.type(screen.getByLabelText("内容"), "Test content");
    await userEvent.click(screen.getByText("发布"));
    
    await waitFor(() => {
      expect(window.location.pathname).toBe("/posts/1");
    });
  });
});

六、端到端测试 #

6.1 Playwright配置 #

typescript
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  use: {
    baseURL: "http://localhost:3000",
  },
  webServer: {
    command: "npm run dev",
    port: 3000,
  },
});

6.2 E2E测试示例 #

typescript
import { test, expect } from "@playwright/test";

test("user can login", async ({ page }) => {
  await page.goto("/login");
  
  await page.fill('input[name="email"]', "test@example.com");
  await page.fill('input[name="password"]', "password");
  await page.click('button[type="submit"]');
  
  await expect(page).toHaveURL("/dashboard");
  await expect(page.locator("h1")).toContainText("欢迎");
});

test("user can create post", async ({ page }) => {
  await page.goto("/posts/new");
  
  await page.fill('input[name="title"]', "Test Post");
  await page.fill('textarea[name="content"]', "Test content");
  await page.click('button[type="submit"]');
  
  await expect(page).toHaveURL(/\/posts\/\d+/);
});

七、测试工具函数 #

7.1 Mock数据 #

tsx
export function createMockPost(overrides = {}) {
  return {
    id: "1",
    title: "Test Post",
    content: "Test content",
    author: {
      id: "1",
      name: "Test User",
    },
    createdAt: new Date().toISOString(),
    ...overrides,
  };
}

export function createMockUser(overrides = {}) {
  return {
    id: "1",
    email: "test@example.com",
    name: "Test User",
    ...overrides,
  };
}

7.2 测试工具 #

tsx
export function renderWithRemix(
  component: React.ReactElement,
  options = {}
) {
  const RemixStub = createRemixStub([
    {
      path: "/",
      Component: () => component,
      ...options,
    },
  ]);
  
  return render(<RemixStub initialEntries={["/"]} />);
}

八、最佳实践 #

8.1 测试命名 #

tsx
describe("Component", () => {
  it("should do something when condition", () => {
    // ...
  });
});

8.2 测试覆盖 #

bash
vitest run --coverage

8.3 测试脚本 #

json
{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test"
  }
}

九、总结 #

本章我们学习了:

  1. 测试环境:Vitest配置
  2. 组件测试:单元测试
  3. 路由测试:Loader和Action测试
  4. 集成测试:createRemixStub
  5. 端到端测试:Playwright

核心要点:

  • 使用Vitest进行单元和集成测试
  • 使用createRemixStub测试路由
  • 使用Playwright进行E2E测试
最后更新:2026-03-28