首页
/ {Module} 模块 E2E 测试覆盖

{Module} 模块 E2E 测试覆盖

2026-09-07 12:40:02作者:姚月梅Lane

模块概述

路由: /module, /module/[id]

功能清单与测试覆盖

1. 功能分组名称

功能点 描述 优先级 状态 测试文件
功能A xxx P0 xxx.feature
功能B xxx P1

测试文件结构

测试执行

已知问题

更新记录


其中状态图例约定:✅ 已完成、⏳ 待实现、🚧 进行中。

### 阶段 C:功能探索与场景设计(Step 4–5)

**Step 4:探索模块功能**。手册建议用 Task/Explore 子代理深入模块代码,覆盖四个探查面:

- 路由结构:`src/app/[variants]/(main)/{module}/`
- 功能组件:`src/features/`
- Store actions:`src/store/{module}/`
- 所有用户交互(按钮、菜单、表单)

然后按用户旅程区域(如 Sidebar、Editor Header、Editor Content 等)把全部功能点登记进 README。

**Step 5:按功能区域编写 Feature 文件**。文件位于 `e2e/src/features/{module}/{area}.feature`,命名约定示例:`crud.feature`(基础 CRUD)、`editor-meta.feature`(标题/图标等元数据)、`editor-content.feature`(富文本)、`copilot.feature`(AI Copilot 交互)。

Feature 文件模板(BDD 三段式,中文描述贴近产品需求):

```gherkin
@journey @P0 @{module-tag}
Feature: {Feature Name in Chinese}

  作为用户,我希望能够 {user goal},
  以便 {business value}

  Background:
    Given 用户已登录系统

  # ============================================
  # 功能分组注释
  # ============================================

  @{MODULE-AREA-001}
  Scenario: {Scenario description in Chinese}
    Given {precondition}
    When {user action}
    Then {expected outcome}
    And {additional verification}

仓库内的真实实现可对照 e2e/src/features/page/editor-meta.feature:它声明了 @journey @P0 @page 模块标签,用 6 个场景覆盖标题编辑(含回车保存、点击外部保存、清空显示占位符)与 Emoji 图标的添加/更换/删除,每个场景都带 @PAGE-TITLE-xxx / @PAGE-EMOJI-xxx 的用例级唯一标签,便于单独回放调试。

标签约定是全套体系的核心约定,分为四类:

# 测试类型
@journey      # 用户旅程测试(体验基准线)
@smoke        # 冒烟测试(快速验证)
@regression   # 回归测试
@skip         # 跳过此测试(已知问题)

# 优先级
@P0           # 最高优先级(CI 必跑)
@P1           # 高优先级(Nightly)
@P2           # 中优先级(发版前)

# 模块
@agent        # Agent 模块
@agent-group  # Agent Group 模块
@page         # Page/Docs 模块
@knowledge    # 知识库模块
@memory       # Memory 模块
@settings     # Settings 模块
@home         # Home sidebar 模块

阶段 D:Step 定义与 hooks 扩展(Step 6)

Step 6:实现 Step Definitions,文件位于 e2e/src/steps/{module}/{area}.steps.ts。模板示意如下:

import { Given, When, Then } from '@cucumber/cucumber';

import { CustomWorld } from '../../support/world';

// ============================================
// Given / When / Then Steps
// ============================================

Given('用户打开一个文稿编辑器', async function (this: CustomWorld) {
  console.log('   📍 Step: 创建并打开一个文稿...');
  // Implementation
});

When('用户点击标题输入框', async function (this: CustomWorld) {
  console.log('   📍 Step: 点击标题输入框...');
  // Implementation
});

Then('文稿标题应该更新为 {string}', async function (this: CustomWorld, title: string) {
  console.log(`   📍 Step: 验证标题为 "${title}"...`);
  // Assertions
});

注意几个实现细节:

  • 步骤方法通过 async function (this: CustomWorld) 拿到共享的 pagebrowserContext 与断言上下文。CustomWorlde2e/src/support/world.ts 中定义:默认 viewport 为 1280×720,浏览器按 HEADLESS !== 'false' 决定是否显示,expect 断言超时与页面默认超时统一为 30 秒,还封装了 modKey(macOS 返回 Meta,其余平台返回 Control)用于跨平台快捷键测试,以及把全页截图写入 screenshots/takeScreenshot(name)
  • 每个关键步骤写 console.log 便于定位(手册明确要求 DO add console logs in step definitions for debugging)。

如果新增了用例级标签前缀,需要同步更新 e2e/src/steps/hooks.ts 中识别测试 ID 的列表。该文件目前识别的前缀为 @COMMUNITY-@AGENT-@HOME-@OIDC-@PAGE-@ROUTES-

const testId = pickle.tags.find(
  (tag) =>
    tag.name.startsWith('@COMMUNITY-') ||
    tag.name.startsWith('@AGENT-') ||
    tag.name.startsWith('@HOME-') ||
    tag.name.startsWith('@PAGE-') || // 新模块在此追加
    tag.name.startsWith('@ROUTES-'),
);

hooks 文件还承担了三个关键职责,值得作为样板复用:

  1. 登录会话缓存:在 BeforeAll 中通过 /api/auth/sign-in/email 完成一次 API 登录,把 session cookies 缓存后写入每个新 browser context,从而让所有 Given 用户已登录系统 免去重复走登录 UI;
  2. Community 模块 API Mock:带 @community 标签的场景在导航前通过 mockManager.setup(page) 挂载列表/搜索/详情 fixtures,原因是线上市场会限流匿名的 CI 流量,而 E2E 目标是「Community UI 流程的用户体验基准线」而非真实市场可用性探测;
  3. 失败即取证:在 After 中对 FAILED 场景自动保存全页截图(screenshots/)并 attach HTML 页面与收集到的 JS 错误,方便快速定位。

阶段 E:Mock 与运行验证(Step 7–8)

Step 7:为 LLM 相关测试配置 Mock。所有依赖真实 LLM API 的用例都必须使用 Mock 框架,禁止走真实请求。基础用法:

import { llmMockManager, presetResponses } from '../../mocks/llm';

// 在页面导航之前设置
llmMockManager.setResponse('user message', 'Expected AI response');
await llmMockManager.setup(this.page);

LLM Mock 的源码细节与 SSE 格式见本文第六节。

Step 8:运行与验证测试。先启动本地环境,再做 dry-run 校验,最后正式运行。

启动本地环境(从项目根目录执行):

# 仅设置数据库(启动 PostgreSQL + 运行迁移)
bun e2e/scripts/setup.ts

# 设置数据库并启动服务器
bun e2e/scripts/setup.ts --start

# 完整设置(数据库 + 构建 + 启动服务器)
bun e2e/scripts/setup.ts --build --start

# 清理环境
bun e2e/scripts/setup.ts --clean

setup.ts 支持的选项如下(对照 e2e/docs/local-setup.md):

选项 说明
--clean 清理现有容器和进程
--skip-db 跳过数据库设置(使用已有的)
--skip-migrate 跳过数据库迁移
--build 启动前构建应用
--start 设置完成后启动服务器
--port <port> 服务器端口(默认 3006)
--help 显示帮助信息

运行测试的完整命令序列:

# Step 8.1:启动本地环境(见上)

# Step 8.2:先跑 dry-run 验证 step definitions 齐全
cd e2e
BASE_URL=http://localhost:3006 \
  DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
  pnpm exec cucumber-js --config cucumber.config.js --tags "@{module-tag}" --dry-run

# Step 8.3:按用例 ID 运行单个新测试(非 headless,便于观察)
HEADLESS=false BASE_URL=http://localhost:3006 \
  DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
  pnpm exec cucumber-js --config cucumber.config.js --tags "@{TEST-ID}"

# 运行整模块测试(排除已知不稳定的 @skip 用例)
HEADLESS=true BASE_URL=http://localhost:3006 \
  DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
  pnpm exec cucumber-js --config cucumber.config.js --tags "@{module-tag} and not @skip"

Step 8.4:修复失败。出现失败时按顺序排查:查看 e2e/screenshots/ 中的失败截图 → 调整选择器与等待 → 对于不稳定(flaky)的用例@skip 标签并在 README 已知问题中记录 → 确保测试稳定通过。运行前的重要前提(来自 e2e/docs/local-setup.md):

  • Docker Desktop 正在运行,Node.js 18+,pnpm 已安装且项目已 pnpm install
  • 数据库必须使用 paradedb/paradedb:latest 镜像(支持 pgvector 扩展),映射到宿主机 5433 端口;
  • 应用服务器必须在项目根目录启动,绝不能 cd e2e 后执行 next start,否则会报 Cannot find module './src/libs/next/config/define-config'
  • S3 环境变量是必需的(即使不测试文件上传),脚本已自动处理。

阶段 F:文档同步与 PR 提交(Step 9–10)

Step 9:更新文档。一是更新模块 README:已完成功能标 ✅、更新统计、补充已知问题;二是回写 .claude/prompts/e2e-coverage.md:把目标模块表中的状态刷新为最新覆盖情况,并把新学到的最佳实践沉淀回手册,形成团队知识的正向循环。

Step 10:创建 Pull Request

  • 分支名:test/e2e-{module-name}
  • Commit 格式:✅ test: add E2E tests for {module-name}
  • PR 标题:✅ test: add E2E tests for {module-name}
  • PR body 使用给定模板,包含 Summary、Test Coverage 勾选清单与可直接复制的运行命令:
## Summary
- Added E2E BDD tests for `{module-name}`
- Feature files added: [number]
- Scenarios covered: [number]

## Test Coverage
- [x] Feature area 1: {description}
- [ ] Feature area 2: {pending}

## Test Execution
# Run these tests
cd e2e && pnpm exec cucumber-js --config cucumber.config.js --tags "@{module-tag} and not @skip"

四、标签系统与 CI 执行策略

标签与优先级、执行时机一一对应,这是把「体验基准线」落到 CI 的关键。执行策略命令如下(源自 e2e/CLAUDE.md):

# CI - P0 冒烟测试(每次 PR 必跑)
pnpm exec cucumber-js --config cucumber.config.js --tags "@smoke and @P0"

# Nightly - 所有用户旅程
pnpm exec cucumber-js --config cucumber.config.js --tags "@journey"

# 发版前 - 完整回归
pnpm exec cucumber-js --config cucumber.config.js --tags "@P0 or @P1"

# 完整测试
pnpm exec cucumber-js --config cucumber.config.js

测试设计原则(与执行策略配合的四条铁律):

  1. 按 CRUD + 核心交互覆盖:每个模块必须覆盖创建、读取、更新、删除及核心交互流程;
  2. LLM 响应必须 Mock:保证测试稳定性和可重复性;
  3. 中文描述场景:Feature 文件用中文,贴近产品需求、便于产品与测试对齐;
  4. 优先级分层:合理分配 P0/P1/P2,控制 CI 执行时间。

五、Hard Rules:编写时的 DO / DO NOT 清单

手册中的「Important Rules」是每个模块落地前必须逐条核对的红线:

必须做(DO) 禁止做(DO NOT)
Feature 文件用中文编写 依赖真实 LLM API 调用
添加合适的标签(@journey、@P0/@P1/@P2、模块名) 制造 flaky 测试(PR 前必须保证稳定)
对 LLM 响应做 Mock 保证稳定 修改生产代码(除非仅添加 data-testid 属性)
Step definitions 中加 console.log 便于调试 本地未跑通就提交 PR
处理 desktop/mobile 双组件下的元素可见性问题
page.waitForTimeout() 等待动画/过渡
同时兼容中英文文案(如 `/^(无标题 Untitled)$/`)
用时间戳生成唯一测试数据避免用例间冲突

六、LLM Mock 框架源码解析

LLM Mock 是保证 E2E 稳定性的核心设施,实现位于 e2e/src/mocks/llm/index.ts

6.1 拦截原理

Mock 目标是对所有形如 /webapi/chat/* 的请求(OpenAI 等各 provider 通用)返回预设的 SSE 流式响应。实现没有用 page.route(),原因是 Playwright 的 route.fulfill() 会一次性缓冲整个响应体,无法模拟逐 token 的 SSE 流式下发。因此框架采用两层配合:

  1. 通过 page.exposeFunction('__lobehubE2ELLMMock', ...) 暴露一个构造「流计划」的回调:LLMMockManager.createStreamPlan() 解析请求体中的 messages,匹配预设响应并切分成 SSE chunk,返回 { chunks, responseDelay, streamDelay }
  2. 通过 page.addInitScript 注入一段原生 JavaScript 覆写 window.fetch:仅当请求路径以 /webapi/chat/ 开头时接管,用真实的 ReadableStreamresponseDelaystreamDelay 逐 chunk 下发,并正确响应 AbortSignal(支持测试中断言流)、返回 Content-Type: text/event-stream

注入脚本刻意使用原始 JS 字符串而非函数参数,注释中说明了原因:tsx 会给命名函数装饰一个模块级 helper,在浏览器页面中并不存在。

6.2 SSE 响应格式(必须严格匹配)

LobeHub 使用特定 SSE 协议,buildSSEChunks(content, chunkSize) 按如下序列组装(默认 streamChunkSize=10,即每 10 字符一个 text 事件):

# 1. 初始 data 事件
id: msg_xxx
event: data
data: {"id":"msg_xxx","model":"gpt-4o-mini","role":"assistant","type":"message",...}

# 2. 文本内容分块(text 事件,每 chunk 一条)
id: msg_xxx
event: text
data: "Hello"

# 3. 停止事件
id: msg_xxx
event: stop
data: "end_turn"

# 4. 使用量统计
id: msg_xxx
event: usage
data: {"totalTokens":100,...}

# 5. 最终停止
id: msg_xxx
event: stop
data: "message_stop"

6.3 Manager API

LLMMockManager(单例导出为 llmMockManager)提供如下方法:

  • setResponse(userMessage, response):为某条完整匹配的用户消息设置响应(内部做小写 + trim 归一化);
  • setResponseContaining(fragment, response):为包含片段的消息设置响应,适用于系统包装了用户内容的 prompt;
  • setConfig(partial) / resetConfig():临时覆盖流式参数(如需要更慢的流来模拟流中交互),resetConfig() 应放在 After hook 中防止参数泄漏到下一场景;
  • clearResponses():清空全部自定义响应;
  • enable() / disable():全局开关;
  • setup(page):在页面导航之前调用,注册 fetch 拦截(常见问题之一就是「路由拦截设置在导航之后导致 Mock 未生效」)。

配置项 LLMMockConfig 含默认值:defaultResponse(无匹配时的兜底文案)、enabled(默认 true)、responseDelay(默认 100ms)、streamChunkSize(默认 10)、streamDelay(默认 20ms)。

6.4 预设响应

presetResponses 提供了多种开箱即用的预设:greetingcodeHelperrornameIntro / nameRecall(多轮对话记忆验证)、regenerated(重新生成场景)、以及两个长文预设 longArticlelongScrollArticle——后者由 60 段拼接而成,注释说明它用于 @AGENT-SCROLL-* 场景,保证回复必然超出视口以便观察滚动行为。

七、元素定位与常见测试模式

7.1 富文本编辑器(contenteditable)

LobeHub 的输入类编辑器是 contenteditable 富文本,不能用 locator.fill()(对 contenteditable 不生效)。正确姿势是先点击容器获得焦点,再逐字键入:

const editor = this.page.locator('[contenteditable="true"]').first();
await editor.click();
await this.page.waitForTimeout(500); // 等待焦点
await this.page.keyboard.type(message, { delay: 30 });
await this.page.keyboard.press('Enter'); // 发送

7.2 斜杠命令(Slash Command)

斜杠菜单出现需要时间,输入后必须等待:

await this.page.keyboard.type('/', { delay: 100 });
await this.page.waitForTimeout(800); // Wait for slash menu

await this.page.keyboard.type('h1', { delay: 80 });
await this.page.keyboard.press('Enter');

7.3 i18n(中英文)兼容

默认文案要同时兼容两种语言,例如无标题文稿的正则 /^(无标题|Untitled)$/;按钮可用 getByRole('button', { name: /choose.*icon|选择图标/i })

7.4 唯一测试数据

用时间戳生成标题,避免测试运行间相互冲突:

const uniqueTitle = `E2E Page ${Date.now()}`;

7.5 多元素匹配

同一页面 desktop/mobile 双组件并存时,选择器常命中多个元素,触发 strict mode violation。处理手法:用 .first() / .nth(n) 定位,或遍历所有匹配并按可见性过滤后点击。必要时应给生产组件补 data-testid

<Component data-testid="unique-identifier" />

7.6 高频场景模板

手册沉淀了五类可直接套用的场景模板:导航测试(点菜单 → 断言 URL 与标题)、CRUD 测试(创建 / 编辑 / 删除三连)、编辑器标题测试(点击标题框 → 输入 → 回车 → 断言更新)、富文本测试/h1 斜杠插入一级标题)、LLM 交互测试Given LLM Mock 已配置 → 发送消息 → 断言 AI 回复与历史记录)。以 CRUD 为例:

Scenario: 创建新项目
  Given 用户已登录系统
  When 用户点击创建按钮
  And 用户输入名称 "{name}"
  And 用户点击保存
  Then 应该看到新创建的项目 "{name}"

Scenario: 编辑项目
  Given 用户已创建项目 "{name}"
  When 用户打开项目编辑
  And 用户修改名称为 "{new-name}"
  And 用户保存更改
  Then 项目名称应更新为 "{new-name}"

Scenario: 删除项目
  Given 用户已创建项目 "{name}"
  When 用户删除该项目
  And 用户确认删除
  Then 项目列表中不应包含 "{name}"
登录后查看全文
热门项目推荐
相关项目推荐