Skip to content

Instantly share code, notes, and snippets.

@felix021
Last active April 16, 2026 22:26
Show Gist options
  • Select an option

  • Save felix021/33f733c6502961577db992947969b5f9 to your computer and use it in GitHub Desktop.

Select an option

Save felix021/33f733c6502961577db992947969b5f9 to your computer and use it in GitHub Desktop.
分层测试框架实践:从单元测试到 E2E 的完整链路(Rust/JS/Python CDP)

分层测试框架实践:从单元测试到 E2E 的完整链路(Rust/JS/Python CDP)

分层测试框架实践:从单元测试到 E2E 的完整链路

基于一个 Rust 全栈项目(Server + CLI Client + Tauri Desktop)的真实经验总结。 包含工具链、踩过的坑、给 AI Agent 协作开发的建议。


一、分层测试架构

┌──────────────────────────────────────────────────┐
│  L4: E2E 测试 (55 tests)                         │
│  Python/CDP → 真实环境 → 全链路验证               │
├──────────────────────────────────────────────────┤
│  L3: Lint (cargo clippy -D warnings)             │
├──────────────────────────────────────────────────┤
│  L2: JS 测试 (32 tests, vitest)                  │
│  前端逻辑:压缩、编解码、CDP API                  │
├──────────────────────────────────────────────────┤
│  L1: 单元 + 集成测试 (271 tests, cargo test)     │
│  Rust 各 crate:crypto, storage, server, client  │
└──────────────────────────────────────────────────┘

执行时机

层级 命令 何时执行 耗时
L1 cargo test --workspace 每次提交前 ~5s
L2 cd web && npm test 每次提交前 ~2s
L3 cargo clippy --workspace -- -D warnings 每次提交前 ~3s
L4 E2E 脚本(远程环境) 部署到测试环境后 ~5min

铁律:L1-L3 必须全部通过后才能构建发布。绝不在单元测试挂掉的代码上跑 E2E。


二、各层工具与模式

L1: Rust 单元 + 集成测试

核心工具:

  • tempfile::TempDir — 自动清理的隔离 SQLite 数据库
  • tokio::test — 异步测试
  • 子进程集成测试 — 启动完整 server binary,随机端口 + 临时 DB

存储层测试模式:

fn new_test_store() -> (Storage, TempDir) {
    let dir = TempDir::new().unwrap();
    let db_path = dir.path().join("test.db");
    let store = Storage::new(&db_path).unwrap();
    (store, dir)  // dir 在作用域结束时自动清理
}

#[test]
fn test_device_crud() {
    let (store, _dir) = new_test_store();
    let device = store.create_device("test-device", "token-hash").unwrap();
    assert_eq!(device.name, "test-device");
    // 建 → 查 → 改 → 删,每步都断言
}

Server 集成测试模式:

fn test_server() -> (String, Child, TempDir) {
    let dir = TempDir::new().unwrap();
    let db_path = dir.path().join("test.db");
    pre_create_admin(&db_path);
    let port = TcpListener::bind("127.0.0.1:0").unwrap()
        .local_addr().unwrap().port();
    let child = Command::new("cargo")
        .args(["run", "-p", "my-server", "--",
               "--listen", &format!(":{}", port),
               "--test-token", "test123",
               "--enable-test-auth"])
        .spawn().unwrap();
    wait_for_port(port);
    (format!("http://127.0.0.1:{}", port), child, dir)
}

并发测试模式:

#[tokio::test]
async fn test_concurrent_relay() {
    let hub = Arc::new(RelayHub::new());
    let mut handles = Vec::new();
    for i in 0..20 {
        let hub = hub.clone();
        handles.push(tokio::spawn(async move {
            hub.register_client(format!("client-{}", i)).await;
        }));
    }
    // 超时保护,防止死锁导致测试永远挂起
    tokio::time::timeout(Duration::from_secs(5),
        futures::future::join_all(handles)
    ).await.unwrap();
}

L2: JS 测试 (vitest)

方法提取测试技术 — 从源码中提取单个方法进行隔离测试:

function extractMethod(source, methodName) {
    const regex = new RegExp(`\\b${methodName}\\s*\\(`);
    const match = source.match(regex);
    // 匹配括号找到参数列表和函数体,返回可独立调用的函数
}

test('showConfirm resolves on respond', async () => {
    const app = buildMockApp();
    const promise = app.showConfirm('Delete?');
    app.__omt_cdp.respond(true);
    expect(await promise).toBe(true);
});

Fake Timer 测试异步逻辑:

test('waitForModal times out', async () => {
    vi.useFakeTimers();
    const promise = cdp.waitForModal(5000);
    vi.advanceTimersByTime(5001);
    await expect(promise).rejects.toThrow('timeout');
    vi.useRealTimers();
});

L4: E2E 测试框架

架构:

Linux 测试主机
├── Groups A-F: curl/API 测试(直接 SSH 到测试服务器)
├── Groups G/H: CDP Python 脚本(上传到 Windows 运行)
└── Group V: 视觉验证(Xvfb + xfreerdp + Ollama Vision)

Windows 桌面机(Tauri 应用)
├── app.exe + start_cdp.bat(启用 CDP 调试端口)
├── cdp_helper.py + cdp_group_*.py
└── CDP port 9222

测试服务器(Linux)
├── server --test-token xxx --enable-test-auth
├── client daemon(守护进程模式)
└── SQLite 数据库

CDP 测试辅助类(核心):

class CDPSession:
    """通过 Chrome DevTools Protocol 控制 WebView"""
    
    async def js(self, expr, timeout=20):
        """执行 JS 表达式,返回值"""
        r = await self.evaluate(expr, timeout=timeout)
        return r.get("result", {}).get("result", {}).get("value")
    
    async def fire(self, expr):
        """执行 JS 但不等待 Promise(用于会阻塞的模态框调用)
        
        关键:awaitPromise=False,否则 showConfirm/showPrompt
        会阻塞 CDP 连接导致 ConcurrencyError
        """
        return await self.evaluate(expr, await_promise=False)
    
    async def screenshot(self, filename=None):
        """CDP 原生截图 — 无需 Xvfb/xfreerdp
        
        直接通过 DevTools Protocol 的 Page.captureScreenshot,
        返回 WebView 内容截图。比 RDP 截图更精确、更快。
        """
        # 发送 CDP 命令,返回 base64 编码的 PNG
        ...
    
    async def find_element(self, selector, description=None):
        """自愈选择器 — UI 重构时自动降级查找
        
        如果主选择器失败,依次尝试:
        1. ID 大小写模糊匹配
        2. 文本内容匹配(按钮、链接、标题)
        3. aria-label / placeholder 匹配
        """
        found = await self.js(f"!!document.querySelector('{selector}')")
        if found:
            return selector
        # 降级策略...
    
    async def cdp_wait_modal(self, timeout_ms=5000):
        """等待模态框出现"""
        return await self.js_json(
            f"window.__omt_cdp.waitForModal({timeout_ms})"
            f".then(m => JSON.stringify(m))"
        )
    
    async def cdp_respond(self, value):
        """响应模态框(替代人工点击确认/取消/输入)"""
        await self.js(f"window.__omt_cdp.respond({json.dumps(value)})")

E2E 测试运行器(含失败日志):

def run_tests(tests, group_name="G"):
    async def _run():
        setup_script_dir()   # 创建临时目录
        cdp = await connect()
        for test_id, desc, fn in tests:
            try:
                await fn(cdp)
                results[test_id] = "PASS"
            except CDPReconnectNeeded:
                cdp = await reconnect()  # 页面导航后重连
                results[test_id] = "PASS"
            except SkipTest as e:
                results[test_id] = "SKIP"
            except Exception as e:
                results[test_id] = f"FAIL: {e}"
        
        # 失败日志 → JSONL 回归追踪
        if failed > 0:
            _log_failures(results, group_name)
        
        cleanup_script_dir()  # 清理临时目录
    asyncio.run(_run())

三、提高可测试性的设计模式

Pattern 1: 用可编程模态框替换原生 alert()/confirm()/prompt()

问题: 浏览器原生的 alert()/confirm()/prompt() 会弹出系统级模态对话框,完全阻塞 JS 执行线程和自动化测试。无论是 Selenium、Playwright 还是 CDP,处理这些对话框都很痛苦:

  • CDP Runtime.evaluate 调用会被挂起直到对话框关闭
  • 在 Tauri/Electron 等桌面应用中表现更不稳定
  • 无法在自动化测试中可靠地检测和响应

方案: 将所有 alert/confirm/prompt 替换为自定义的 async 函数 + CDP Debug API:

// 替换前:
if (confirm("Delete this item?")) { deleteItem(id); }

// 替换后:
if (await app.showConfirm("Delete this item?")) { deleteItem(id); }

showConfirm 在普通用户模式下渲染自定义 UI 弹窗,在测试模式下通过 CDP Debug API 暴露给自动化:

// Debug API(仅在 debug 模式下注入)
window.__omt_cdp = {
    pending: null,          // 当前等待响应的模态框
    respond(value) { ... }, // 模拟用户点击
    waitForModal(ms) { ... }, // 等待模态框出现
    events: [],             // 事件日志
    state() { ... },        // 当前应用状态快照
};

测试代码:

# 触发删除(showConfirm 会阻塞 —— 用 fire() 不等待 Promise)
await cdp.fire("app.deleteItem()")
# 等待模态框出现
modal = await cdp.cdp_wait_modal(5000)
assert modal["type"] == "confirm"
assert "Delete" in modal["message"]
# 模拟用户确认
await cdp.cdp_respond(True)

关键细节: fire() 使用 awaitPromise=False,让 CDP 立刻返回而不等待 Promise resolve。否则 showConfirm() 会在等待用户响应时阻塞 CDP WebSocket,导致后续所有 evaluate 调用超时或触发 ConcurrencyError

收益:

  • 所有用户交互都可以在 E2E 中自动化
  • 支持验证弹框内容(确认消息文案正确)
  • 可以测试 "用户取消" 路径
  • 普通用户完全无感(自定义 UI 体验更好)

Pattern 2: 通过 Debug API 提高可观测性

问题: E2E 测试中经常需要知道应用的内部状态(登录了没有?当前在哪个页面?有几个会话?),但 DOM 查询只能看到 UI 表现,看不到应用状态。

方案: 在 debug 模式下注入状态查询 API:

window.__omt_cdp = {
    state() {
        return {
            loggedIn: !!app._jwtToken,
            currentTab: app._currentTab,
            sessionCount: app._sessions.length,
            isTauri: app._isTauri,
        };
    },
    events: [],  // 时序事件日志
    waitForEvent(name, timeoutMs) {
        // 等待特定事件发生(如 "session-created", "ws-connected")
    }
};

在关键操作点埋点:

// 会话创建
async createSession(opts) {
    const session = await this._api.post('/sessions', opts);
    this._cdpEmit('session-created', { id: session.id });
    return session;
}
// WebSocket 连接
ws.onopen = () => {
    this._cdpEmit('ws-connected', { sessionId });
};

测试中等待异步事件完成:

# 创建会话后等待 WebSocket 真正连上
await cdp.js("app.createSession({type: 'bash'})")
event = await cdp.cdp_wait_event("ws-connected", timeout_ms=10000)
assert event["sessionId"] is not None

相比轮询 DOM 的优势:

  • 精确等待特定状态变化,不需要 sleep(3) 猜时间
  • 事件日志可以回溯调试("到底什么时候断开的?")
  • 不依赖 UI 渲染时序

Pattern 3: 测试环境与生产环境严格隔离

问题: 测试需要免密登录、API 直调、数据清理等能力,但这些能力如果泄漏到生产 = 安全事故。

方案: 通过启动参数控制,不是通过配置文件:

# 测试环境 —— 独立端口、独立数据库、显式启用测试认证
server --listen :7443 \
  --db /test/data/sessions.db \
  --test-token <random-token> \
  --enable-test-auth

# 生产环境 —— 绝不出现 test 相关参数
server --listen :6443 \
  --db /prod/data/sessions.db

为什么用启动参数而非配置文件/环境变量:

  • 配置文件可能被复制或同步
  • 环境变量可能被 .env 文件意外注入
  • 启动参数在 ps aux 中可见,更容易审计
  • docker-compose.yml 里一眼能看到有没有 test 参数

Pattern 4: 进程守护与 E2E 环境稳定性

问题: E2E 测试需要客户端进程持续运行,但 SSH 断连、进程崩溃都会导致客户端掉线,测试基础设施不稳定。

方案: Watchdog 循环 + 分离进程:

# 客户端守护(SSH 断连不死)
nohup bash -c 'while true; do
  DANGER_ACCEPT_INVALID_CERTS=1 client terminal takeover
  sleep 2
done' > /tmp/client.log 2>&1 &

Desktop 应用在 SSH 中启动 GUI:

# 直接 SSH 执行 `start app.exe` 不行 —— 不在交互式桌面会话
# 用 schtasks /IT 解决:
schtasks /create /tn StartApp /tr "C:\path\start_app.bat" \
  /sc once /st 00:00 /IT /f
schtasks /run /tn StartApp

Pattern 5: 验证编译产物,而非源码

问题: 改了源码 → 跑了测试 → 声称 "修好了",但实际部署的 binary 可能是旧的(忘了 rebuild),或者 pre-commit hook 修改了源码。

方案:

# 验证 binary 包含修改
strings target/release/my-server | grep "expected-string"

# 验证线上版本
curl -sk https://server/api/v1/version | jq .project_version

# 在真实环境测试具体功能(不只是 health check)
# ❌ curl 返回 200 就收工
# ✅ 测试被修改的具体功能

Pattern 6: 自愈选择器(Self-Healing Selectors)

问题: UI 重构时元素 ID 改名、层级调整,所有 E2E 测试的 CSS 选择器集体失效。维护成本随测试数量线性增长。

方案: 选择器查找失败时自动降级到替代策略:

async def find_element(self, primary_selector, description=None):
    """主选择器失败 → 依次尝试替代策略"""
    # 1. 主选择器(精确匹配)
    if await self.js(f"!!document.querySelector('{primary_selector}')"):
        return primary_selector
    
    # 2. ID 大小写模糊匹配(处理 camelCase → kebab-case 重构)
    if primary_selector.startswith("#"):
        # 遍历所有 [id] 元素,case-insensitive 对比
        ...
    
    # 3. 文本内容匹配(按钮文字变了但功能没变)
    if description:
        # 搜索 button, a, h1-h3, label, [role="button"]
        ...
    
    # 4. aria-label / placeholder 匹配
    if description:
        # querySelector('[aria-label*="desc" i]')
        ...
    
    return None  # 所有策略都失败

使用方式:

# 即使 #settingsView 被改名为 #settings-panel,测试依然能找到
selector = await cdp.find_element("#settingsView", description="settings")
if selector:
    vis = await cdp.js(f"getComputedStyle(document.querySelector('{selector}')).display")

收益:

  • UI 重构时测试不会集体失效
  • 降级查找有日志,方便事后更新选择器
  • 遵循行业趋势:Playwright v1.56 的 Healer Agent 做的就是这件事

Pattern 7: CDP 原生截图(替代 RDP 截图)

问题: 视觉验证需要截图,但 Xvfb + xfreerdp + import -window root 的方案链条长、延迟高、受 RDP 压缩影响。

方案: CDP 的 Page.captureScreenshot 直接通过 WebSocket 返回 WebView 内容:

async def screenshot(self, filename=None):
    """CDP 原生截图 — 直接获取 WebView 渲染结果"""
    self._msg_id += 1
    await self.ws.send(json.dumps({
        "id": self._msg_id,
        "method": "Page.captureScreenshot",
        "params": {"format": "png"},
    }))
    # ... 等待响应,base64 解码 → PNG 文件

优势:

  • 不需要 Xvfb / xfreerdp(可以直接从远程 SSH 调用)
  • 截图质量不受 RDP 压缩影响
  • 速度更快(~100ms vs RDP 截图 ~2s)
  • 可以截取 WebView 内容(不含窗口标题栏、桌面背景)

限制:

  • 只能截取 WebView 内容,看不到原生窗口边框
  • 某些 Tauri/Electron 应用可能需要先调用 Page.enable

Pattern 8: 失败日志回归追踪

问题: 测试失败了,修了代码,过了。但不知道历史上哪些测试最容易坏、什么时候坏过、错误模式是否重复。

方案: 每次测试失败自动追加 JSONL 日志:

def _log_failures(results, group_name):
    log_path = os.path.join(SCRIPT_DIR, "failures.jsonl")
    with open(log_path, "a") as f:
        for test_id, result in results.items():
            if result not in ("PASS", "SKIP"):
                json.dump({
                    "group": group_name,
                    "test": test_id,
                    "error": str(result),
                    "ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
                }, f)
                f.write("\n")

长期价值:

  • 识别 flaky tests(同一个测试反复失败/通过)
  • 发现回归模式("每次改 auth 模块,G14 就挂")
  • 为 Eval-Driven Development(见下文)提供种子数据

四、踩过的坑(通用 Pattern)

🔴 自动化测试与 CDP

1. 异步阻塞导致 CDP 死锁

自动化测试通过 CDP Runtime.evaluate 调用 JS 函数,如果该函数内部 await 了一个需要用户交互才能 resolve 的 Promise(如确认弹窗),CDP 连接就会被挂住。后续任何 evaluate 调用都会超时或报 ConcurrencyError

通用规则: 对可能阻塞等待用户输入的 JS 函数,使用 awaitPromise: false 发送 evaluate,让 CDP 立即返回。然后在另一次调用中检查/响应弹窗状态。

2. 页面导航导致 WebSocket 断开

测试中触发了页面跳转(如 OAuth 回调、URL 变更),CDP WebSocket 断开,后续调用全部失败。

通用规则: 封装 reconnect() 方法,在页面导航后自动重连。设计测试框架时用异常类型(如 CDPReconnectNeeded)区分预期的导航断开和真正的错误。

3. 跨测试状态传递

测试 A 创建了资源(会话、设备),测试 B 需要操作它。但每个测试函数是独立的。

通用规则: 提供 shared 字典在测试间传递状态。但尽量减少依赖——如果 A 失败了,依赖 A 的 B/C/D 应该 SKIP 而非报莫名其妙的错。

🔴 E2E 环境管理

4. 代理干扰本地连接

系统配置了 HTTP 代理,localhost:9222 的 CDP 请求也被代理拦截 → 连接失败或返回 502。

通用规则: E2E 脚本启动时显式设置 no_proxy=localhost,127.0.0.1。对 Python 和 curl 都需要设置。

5. SSH 无法启动 GUI 应用

通过 SSH 远程执行 start app.exe,进程启动了但没有 GUI 窗口——因为 SSH 会话不在交互式桌面。

通用规则: Windows 上用 schtasks /IT 在交互式桌面会话中启动 GUI 进程。Linux 上用 DISPLAY=:99 + Xvfb。

6. 异步操作完成 ≠ UI 状态更新

API 调用返回成功,但前端缓存还是旧数据。E2E 测试读到的是缓存值而非最新值。

通用规则: E2E 验证优先查服务端 API(真实状态),而非前端 DOM(可能缓存)。或者在 Debug API 中提供 invalidateCache() 方法。

7. 测试产物污染应用目录

CDP 测试脚本、截图、临时 bat 文件直接放在应用目录下,积累 ~90 个文件。导致应用目录混乱,甚至影响应用启动。

通用规则: 所有测试产物(脚本、截图、临时文件)必须放在专用临时目录(%TMP%\omt-e2e / /tmp/omt-e2e),测试开始时创建、结束时自动清理。应用目录只保留应用本身的文件。

🔴 测试设计

8. "接口通" ≠ "功能通"

health check 返回 200 就宣布部署成功,但实际的 WebSocket 中继是坏的——两个完全不同的代码路径。

通用规则: E2E 测试必须覆盖到具体功能的完整链路。如果改了会话创建,就测会话创建到输出可见的全流程。

9. 模态框内复杂交互的时序问题

测试流程:点击重命名按钮 → 等待 prompt 弹窗 → 输入新名称 → 确认。但 CDP 的 waitForModalrespond 之间如果穿插了其他 CDP 调用,时序容易乱。

通用规则: 对复杂的模态框交互,把整个流程写在一段 JS 表达式里执行,而非多次 CDP 调用:

result = await cdp.js_json("""(async () => {
    const cdp = window.__omt_cdp;
    // 同时启动操作和等待弹窗
    const modalPromise = cdp.waitForModal(10000);
    const actionPromise = app.renameItem(id, oldName);
    // 等弹窗出现
    const modal = await modalPromise;
    // 响应弹窗
    cdp.respond("new-name");
    // 等操作完成
    await actionPromise;
    // 在 JS 侧验证结果
    const resp = await fetch('/api/items/' + id);
    const data = await resp.json();
    return JSON.stringify({ name: data.name, modalType: modal.type });
})()""")

五、Ollama Vision 本地视觉验证

什么场景需要视觉验证

CDP 可以检查 DOM 状态,但无法验证:

  • 元素是否真的可见(被遮挡、透明度为 0、overflow hidden)
  • 布局是否正确渲染(CSS 错误导致元素重叠或错位)
  • 整体 UI 是否符合预期(主题、配色、是否有视觉回归)

视觉验证是 DOM 断言的补充,而非替代。

架构

方案 A(RDP 截图 — 看到完整桌面)
  Xvfb :99 (虚拟显示)
    └── xfreerdp (RDP 连接到 Windows 桌面)
          └── import -window root screenshot.png (截图)

方案 B(CDP 原生截图 — 只看 WebView 内容,更快更精确)
  CDPSession.screenshot("filename.png")
    └── Page.captureScreenshot → base64 PNG → 文件

两种方案均可 → Ollama Vision API (本地推理) → 结构化 Yes/No 判断

本地 9B 模型的选择与限制

我们使用的是量化后的 9B 参数 Vision 模型(如 Qwen2.5-VL 系列的 abliterated 变体),在消费级 GPU(RTX 3090/4090, 24GB VRAM)上可以流畅运行。

能做的事:

  • ✅ 二分类问题:"这是不是登录页面?" → Yes/No
  • ✅ 存在性检查:"页面上有没有侧边栏?" → Yes/No
  • ✅ 主题检查:"这是深色主题还是浅色主题?"
  • ✅ 粗粒度布局:"左边是导航,右边是内容区?"

做不到的事:

  • ❌ 像素级比对(用 pixelmatch/resemble.js 更适合)
  • ❌ 精确读取小字文本(9B 模型 OCR 能力有限,尤其是非英语文本)
  • ❌ 复杂计数("页面上有几个按钮"——经常数错)
  • ❌ 细粒度颜色判断("这个按钮是 #00ff41 还是 #00ff42"——无法区分)
  • ❌ 稳定的长文本提取(偶尔会幻觉出不存在的文字)

提问技巧(Prompt Engineering for Vision)

核心原则:把问题设计成 Yes/No 二分类,用描述性语言而非精确引用。

# ✅ 好的提问 —— 二分类、描述性
"Is this a terminal application showing a dark-themed UI with a sidebar? Answer yes or no."
"Does this show a settings page with navigation tabs in a left panel? Answer yes or no."
"Is this a login page with username and password fields? Answer yes or no."

# ❌ 差的提问 —— 要求精确阅读、计数、对比
"What is the text of the third button from the left?"  # OCR 不可靠
"Are there exactly 5 menu items in the sidebar?"       # 计数不可靠
"Does the header say 'Oh My Term v2.1.3'?"              # 精确文本匹配不可靠

提问模板:

Is this [页面类型描述] with [关键视觉特征]? Answer yes or no.
Does this show [UI 组件描述] in [位置描述]? Answer yes or no.

强制格式约束: 末尾加 Answer yes or no.Only answer yes or no. 约束输出格式。9B 模型如果不加约束,容易输出长段分析文本,增加后处理复杂度。

调用封装

def vision_check(image_path, question):
    """调用本地 Ollama Vision 模型,返回 True/False"""
    result = subprocess.run(
        ["python3", VISION_SCRIPT, image_path, question],
        capture_output=True, text=True, timeout=60
    )
    answer = result.stdout.strip().lower()
    return "yes" in answer

# 使用
screenshot = capture_rdp_screenshot()  # Xvfb + import
assert vision_check(screenshot,
    "Is this a dark-themed terminal with a session list on the left? "
    "Answer yes or no."
), "Terminal view not showing correctly"

截图质量对结果影响很大

# ❌ 默认分辨率可能太小
DISPLAY=:99 import -window root screenshot.png

# ✅ 确保 Xvfb 和 RDP 分辨率足够
Xvfb :99 -screen 0 1400x900x24 &
xfreerdp /v:$HOST /u:$USER /p:$PASS /w:1400 /h:900 /cert:ignore &
sleep 10  # 等 RDP 连接稳定
DISPLAY=:99 import -window root screenshot.png
  • 分辨率低于 1024x768 → 9B 模型识别率显著下降
  • 截图时机很重要 → 页面加载/动画完成后再截
  • RDP 压缩伪影 → 比本地截图质量差,尽量用 24-bit 色深

与 CDP DOM 断言的配合策略

功能验证("能不能用")     → CDP DOM 断言(精确、快速、确定性)
视觉验证("好不好看")     → Ollama Vision(模糊匹配、慢、概率性)
回归检测("有没有变化")   → 像素级 diff 工具(pixelmatch)

在 E2E 流程中,先跑 CDP 断言(99% 的测试用这个),最后跑 Vision(兜底检查整体视觉是否合理)。Vision 测试 SKIP 不应阻塞部署——它是预警,不是门禁。


六、给 AI Agent 的建议

通用原则

  1. 验证闭环是铁律

    改完代码 → 构建 → 部署 → 运行验证命令 → 贴输出。缺任何一环都不算完成。

  2. 先读再写

    修改任何文件之前先读。代码即规范——grep 常量、路径、命名模式。盲改等于赌运气。

  3. 一次修一个,每次都验证

    多个修复堆叠部署 = 出了问题不知道哪个改坏的。每个修复独立部署、独立验证。

  4. 画图再修并发 Bug

    不要上来就改代码。先 grep 所有锁、所有共享状态访问点,画出依赖关系。根因通常不在报错的地方。

  5. 区分 "接口通" 和 "功能通"

    HTTP 200 只代表服务活着。WebSocket 能连上不代表消息能正确中继。每层都要测到具体功能。

AI Agent 专项建议

  1. 永远不要声称 "我无法解决"

    你有工具:搜索引擎、源码阅读、多种调试手段。说 "无法解决" 之前,确保你:

    • 读了错误信息的每个字
    • 搜索了错误原文
    • 读了报错位置上下 50 行代码
    • 验证了所有假设(版本、路径、权限)
    • 试过反向假设("如果问题不在我以为的地方呢?")
  2. 子 Agent 需要足够的上下文

    spawn 子 agent 时,不要假设它知道任何上下文。必须在 prompt 里包含:

    • 文件路径
    • 具体要做什么
    • 成功标准
    • 相关的约束条件
  3. 用 TDD 指导开发

    先写测试(哪怕是一个 curl 命令),确认它 fail,再写代码让它 pass。这个流程同样适用于 bug fix:先写能复现 bug 的测试。

  4. 日志和证据是你的盟友

    • strings binary | grep xxx 验证编译产物
    • curl -v 看完整 HTTP 交互
    • git log --oneline -5 确认提交历史
    • 截图验证 UI 变更 这些不是可选项,是交付标准。
  5. 知道何时切换思路

    同一个方向尝试 3 次还不行 = 方向错了。强制自己切换到本质不同的方案:

    • 从 "调参数" 切到 "换算法"
    • 从 "改代码" 切到 "改架构"
    • 从 "修症状" 切到 "查根因"

七、测试覆盖总览

┌─────────────────┬───────┬────────────────────────────────┐
│ 模块            │ 测试数│ 关键覆盖                       │
├─────────────────┼───────┼────────────────────────────────┤
│ common (Rust)   │   54  │ 加密、版本、序列化、敏感字段  │
│ storage (Rust)  │   35  │ CRUD、审计日志、迁移          │
│ server (Rust)   │  101  │ 认证、JWT、限流、中继、集成   │
│ client (Rust)   │   81  │ 文件操作、PTY、WebSocket      │
│ frontend (JS)   │   32  │ LZ4、protobuf、CDP API        │
│ E2E (Python)    │   59  │ 9 组全链路测试                │
├─────────────────┼───────┼────────────────────────────────┤
│ 合计            │  362  │                                │
└─────────────────┴───────┴────────────────────────────────┘

八、行业趋势与参考(2025-2026)

基于对行业最新实践的调研,以下是与本文讨论的测试框架相关的趋势和经验。

Eval-Driven Development (EDD)

Anthropic 工程博客提出的核心理念:像 TDD 对待代码一样对待 AI Agent 的评估。

  • 在构建能力之前先定义成功标准(eval 先行)
  • 从 20-50 个真实失败案例开始构建评估数据集
  • 每次 bug fix = 新增一个 eval case,数据集随 agent 一起成长
  • 对非确定性输出,每个任务跑多次 trial
  • 三层评判:确定性检查(代码断言)、LLM-as-Judge(模型评分)、人工复核

与本文的关联: 我们的 JSONL 失败日志(Pattern 8)本质上就是 EDD 中的"从真实失败构建评估集"。差距在于还没有形成自动化的回归评估流水线。

专项化 Agent > 通用 Agent

两个生产级案例的共同结论:

OpenObserve 的 "Council of Sub Agents" — 8 个专项化 Claude Code agent(Analyst 分析需求、Sentinel 审计安全、Healer 修复 flaky test 等),每个 agent 有明确边界。效果:380→700+ tests,flaky tests 减少 85%,分析时间从 45min 降到 5min。关键教训:早期用单个 "super agent" 的尝试失败了,专项化才是关键。

Metropolis 的多 Agent E2E 生成器 — 坦诚的教训:他们花在调优 prompt 上的时间比人工写测试还多。每次运行都暴露新的必要调整。测试创建的并行化在实践中不可行。代码库特定上下文是测试质量最大的驱动因素。

Playwright v1.56 的三 Agent 架构

Playwright 在 v1.56 引入了三个专项化 Agent,专为 AI 辅助测试设计:

Agent 职责 对应本文能力
Planner 探索应用,生成 Markdown 测试计划 cdp_state() + waitForEvent() 的可观测性
Generator 将计划转为可执行的 Playwright 测试 我们的 cdp_group_*.py
Healer 重跑失败测试,自动修复选择器 我们的 find_element() 自愈选择器

这三个 Agent 通过 MCP(Model Context Protocol)与 Claude Code / VS Code Copilot 集成。

与本文的关联: 我们的 find_element() 自愈选择器做的和 Healer Agent 类似——在主选择器失败时自动降级到替代策略。差别在于 Playwright 的方案更成熟,支持截图比对和 AI 重定位。

Self-Healing Tests(自愈测试)

行业中越来越多的团队采用这种模式减少"维护税":

  1. 缓存上一次成功运行时的选择器(known-good locators)
  2. 选择器失效时,降级到 AI 驱动的元素识别
  3. 找到元素后自动更新缓存,后续运行恢复确定性

核心度量: 一个工具如果创建省了 10 小时但维护多花了 20 小时 = 净亏损。维护税比创建成本更重要。

多层评估策略

层级 评估方法 适用场景 工具
工具调用级 确定性断言 单个 API 返回值对不对 pytest, vitest
轨迹级(Trace) LLM-as-Judge 评分 Agent 的推理路径合不合理 Langfuse, DeepEval
会话级 端到端成功率 整个任务完成了没有 自定义 runner
生产级 持续监控 + 回归检测 线上有没有退化 Arize Phoenix, Braintrust

关键洞见: 会话级成功可以掩盖工具级问题——"测试通过了但推理路径有隐患"。需要在多个层级同时评估才能得到完整图景。

LLM-as-Judge 模式

我们的 Ollama Vision 验证(第五节)本质上就是 LLM-as-Judge 模式的视觉变体:

  • 用 AI 模型对另一个系统的输出做判断
  • 输出格式化为结构化的 Yes/No(降低后处理复杂度)
  • 非确定性,需要统计多次结果

行业趋势是将这种模式从视觉扩展到全部评估维度:代码质量、推理完整性、安全合规性。


参考资料


2026-04 实践总结。私有信息已替换为占位符。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment