上周我让 Claude Code 帮我跑一遍我的 Tauri 笔记应用的回归测试。给它的任务很简单:「新建一条笔记,输入’hello’,保存,重启应用,验证笔记还在」。
它失败了。不是因为应用有 bug,而是因为它根本看不见这个应用——它能启动进程、能截屏,但屏幕里的「新建按钮」对它来说是一团像素;它知道「保存」这个动词,但不知道怎么把这个动词翻译成 IPC 调用;它甚至不知道「重启之后笔记是否还在」这件事该去哪里查证。
那一刻我意识到:让 AI 测试一个应用,瓶颈从来不在 AI 的能力,而在于这个应用本身是否「可被 AI 测试」。这是一种新的工程属性,我把它叫做 AI Test Ready。
为什么传统测试思路会失效
我们过去三十年建立的测试体系,核心假设是「人在写测试」:
- 单元测试:人来想哪些边界条件
- 集成测试:人来设计调用链路
- E2E 测试:人来写 Selenium/Playwright 脚本,靠 CSS 选择器、固定坐标、显式等待
这套体系对人很友好——人有领域知识、有耐心、有判断力。但对 AI Agent 来说,它有三个致命问题:
- 脆弱(Fragile):CSS 选择器一改就全挂,AI 不擅长维护脆弱契约
- 不透明(Opaque):截屏 + DOM 是给人看的,AI 要从像素里反推语义成本极高
- 不可逆(Stateful):测试副作用污染了状态,AI 没办法干净地重来
更要命的是,AI Agent 的测试模式是探索式的——它不是按脚本走,而是「试试看,不行换一个」。这意味着应用必须能承受高频的状态变更、能快速重置、能给出结构化反馈。
传统应用没有这些设计。所以你看到的现象是:人类写的测试跑得好好的,一交给 AI 就处处碰壁。
思维框架:AI Test Ready 五要素(OCARD 模型)
我把「可被 AI 测试」拆解成五个工程属性,简称 OCARD:
| 维度 | 名称 | 一句话定义 | 没有它会怎样 |
|---|---|---|---|
| O | Observable(可观测) | AI 能用结构化方式读到当前状态 | AI 在猜屏幕里有什么 |
| C | Controllable(可控制) | 每个 UI 操作都有一个非 UI 等价入口 | AI 只能模拟点击,慢且不稳 |
| A | Assertable(可断言) | 应用副作用以数据形式暴露 | AI 不知道操作有没有真的生效 |
| R | Resettable(可重置) | 一条命令能回到已知初始状态 | 上一次失败污染下一次测试 |
| D | Diagnosable(可定位) | 失败时自动产出可读的现场信息 | AI 看到「失败」但不知道原因 |
这五个维度不是平行的,它们有依赖关系:
┌──────────────────┐
│ Diagnosable (D) │ ← 失败时能解释自己
└────────┬─────────┘
│ 依赖
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Observable │ │ Assertable │ │ Resettable │
│ (O) 看得见 │ │ (A) 验得了 │ │ (R) 回得去 │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
└────────────────┼────────────────┘
▼
┌──────────────────┐
│ Controllable (C) │ ← 一切的基础
└──────────────────┘
# generated by hugo AI
Controllable 是地基——没有「能操作」,其他都无从谈起。Observable 和 Assertable 是双臂——一个负责「输入侧」(看清状态),一个负责「输出侧」(验证结果)。Resettable 是隔离机制,Diagnosable 是失败时的回路。
下面用 Tauri 应用作为例子,看每个维度具体怎么落地。
技术实现:以 Tauri 为例
先快速对齐:Tauri 的架构是 Rust 后端 + Web 前端,通过 IPC 通信。这给 AI Test Ready 带来一个天然优势——前端和后端有清晰的边界,IPC 命令本身就是结构化接口。我们要做的,是把这个内部接口暴露给测试 Agent。
O:Observable —— 提供 State Snapshot 接口
不要让 AI 从 DOM 或截图反推状态。直接给它一个「读应用全部状态」的命令。
// src-tauri/src/test_api.rs
use serde::Serialize;
use tauri::State;
#[derive(Serialize)]
pub struct AppStateSnapshot {
pub current_route: String,
pub notes_count: usize,
pub selected_note_id: Option<String>,
pub unsaved_changes: bool,
pub last_error: Option<String>,
pub modal_stack: Vec<String>,
}
#[tauri::command]
#[cfg(feature = "test-api")]
pub fn snapshot_state(state: State<AppState>) -> AppStateSnapshot {
let s = state.lock().unwrap();
AppStateSnapshot {
current_route: s.router.current().to_string(),
notes_count: s.notes.len(),
selected_note_id: s.selected.clone(),
unsaved_changes: s.dirty,
last_error: s.last_error.clone(),
modal_stack: s.modals.iter().map(|m| m.name()).collect(),
}
}
// generated by hugo AI
关键设计:
- 用 feature flag 隔离:
test-apifeature 关掉时整个文件不编译进生产构建 - 结构化字段而不是字符串:
unsaved_changes: bool比「检查标题栏有没有星号」可靠 100 倍 - 包含「负面信号」:
last_error让 AI 一眼看到出问题了
前端补一层 data-testid 是必要的,但应该是语义级别的,不是 UI 级别的:
<!-- 错误示范:耦合 UI 实现 -->
<button class="btn-primary-large">保存</button>
<!-- 正确示范:语义稳定 -->
<button data-testid="action:save-note" class="btn-primary-large">保存</button>
<!-- generated by hugo AI -->
C:Controllable —— 每个 UI 操作都暴露 IPC 入口
这是最容易被忽视、也最关键的一条。任何 AI 必须能做到「不点击 UI 也能完成完整业务流程」。
// src-tauri/src/test_api.rs
#[tauri::command]
#[cfg(feature = "test-api")]
pub async fn test_create_note(
state: State<'_, AppState>,
title: String,
body: String,
) -> Result<String, String> {
// 直接调底层服务,不走 UI 事件
let note = state.note_service
.create(title, body)
.await
.map_err(|e| e.to_string())?;
Ok(note.id)
}
#[tauri::command]
#[cfg(feature = "test-api")]
pub async fn test_navigate(window: tauri::Window, route: String) -> Result<(), String> {
window.emit("test:navigate", route).map_err(|e| e.to_string())
}
// generated by hugo AI
设计原则:
- 业务命令而不是 UI 命令:
test_create_note而不是test_click(x, y)。前者表达「我要做什么」,后者表达「我要点哪里」——AI Agent 关心前者 - 返回有意义的句柄:返回新建笔记的
id,让后续操作能引用它 - 错误用 Result 暴露:不要 panic,不要弹窗——把错误作为数据返回
A:Assertable —— 副作用必须可查询
应用做了什么不能只靠 UI 反馈。每个有副作用的操作(写文件、发请求、改 DB),都要能从外部查询。
#[tauri::command]
#[cfg(feature = "test-api")]
pub fn test_get_persisted_notes(state: State<AppState>) -> Vec<NoteRecord> {
// 直接读磁盘/DB,绕过缓存
state.storage.list_all_from_disk()
}
#[tauri::command]
#[cfg(feature = "test-api")]
pub fn test_get_outbound_requests() -> Vec<RequestLog> {
// 测试模式下记录所有出站请求
REQUEST_RECORDER.lock().unwrap().drain()
}
// generated by hugo AI
注意 test_get_persisted_notes 故意绕过缓存直读磁盘——AI 测试「重启后笔记还在」时,这是唯一可信的来源。
R:Resettable —— 一条命令回到初始状态
#[tauri::command]
#[cfg(feature = "test-api")]
pub async fn test_reset(state: State<'_, AppState>) -> Result<(), String> {
let mut s = state.lock().unwrap();
s.notes.clear();
s.storage.wipe_test_db().await.map_err(|e| e.to_string())?;
s.router.go("/").await;
s.modals.clear();
s.last_error = None;
s.dirty = false;
Ok(())
}
// generated by hugo AI
更进阶:让应用启动时支持 --profile=test 参数,自动切换到独立的数据目录。这样测试和生产数据物理隔离,AI 可以放心地 reset、重启、删库。
fn main() {
let profile = std::env::args()
.find(|a| a.starts_with("--profile="))
.map(|a| a.replace("--profile=", ""))
.unwrap_or_else(|| "default".into());
let data_dir = match profile.as_str() {
"test" => std::env::temp_dir().join("myapp-test"),
_ => default_data_dir(),
};
tauri::Builder::default()
.manage(AppState::new(data_dir))
.run(tauri::generate_context!())
.expect("error");
}
// generated by hugo AI
D:Diagnosable —— 失败时自动产出现场
AI 失败时最贵的成本是「不知道为什么失败」。给它一个「拍快照」命令,把所有可能有用的现场信息打包:
#[derive(Serialize)]
pub struct FailureReport {
pub timestamp: String,
pub state: AppStateSnapshot,
pub recent_logs: Vec<LogEntry>,
pub screenshot_path: String,
pub dom_snapshot: String,
}
#[tauri::command]
#[cfg(feature = "test-api")]
pub async fn test_capture_failure(
window: tauri::Window,
state: State<'_, AppState>,
label: String,
) -> Result<FailureReport, String> {
let path = format!("/tmp/failure-{}.png", label);
window.screenshot(&path).await.map_err(|e| e.to_string())?;
let dom = window.eval("document.documentElement.outerHTML").await?;
Ok(FailureReport {
timestamp: chrono::Utc::now().to_rfc3339(),
state: snapshot_state(state.clone()),
recent_logs: LOG_BUFFER.lock().unwrap().tail(200),
screenshot_path: path,
dom_snapshot: dom,
})
}
// generated by hugo AI
关键是让 AI 一次调用就拿到所有诊断信息,而不是让它去猜「我现在是不是该截个图」。
框架 × 技术:把 OCARD 装进 MCP Server
零散的 IPC 命令对 AI 还不够友好。最后一步,是把这些测试接口包装成一个 MCP Server,让任何 MCP-aware 的 Agent(Claude Code、Cursor、自己写的 Agent)都能直接调用。
┌─────────────────────────────────────────────────────────────┐
│ AI Agent (Claude Code) │
└─────────────────────────────┬───────────────────────────────┘
│ MCP Protocol
▼
┌─────────────────────────────────────────────────────────────┐
│ Tauri Test MCP Server (Node.js) │
│ │
│ Tools exposed: │
│ • app.snapshot → Observable │
│ • app.invoke → Controllable │
│ • app.assert_persisted → Assertable │
│ • app.reset → Resettable │
│ • app.capture_failure → Diagnosable │
└─────────────────────────────┬───────────────────────────────┘
│ Tauri IPC (--profile=test)
▼
┌─────────────────────────────────────────────────────────────┐
│ Tauri App (built with --features test-api) │
└─────────────────────────────────────────────────────────────┘
# generated by hugo AI
MCP Server 端的 Tool 定义示例:
import { Server } from "@modelcontextprotocol/sdk/server";
import { invoke } from "./tauri-bridge";
const server = new Server({ name: "tauri-test", version: "1.0.0" });
server.tool({
name: "app.snapshot",
description: "Get full structured snapshot of current app state",
inputSchema: { type: "object", properties: {} },
handler: async () => {
return await invoke("snapshot_state");
},
});
server.tool({
name: "app.invoke",
description: "Invoke a business-level operation (e.g. create_note, save, navigate)",
inputSchema: {
type: "object",
properties: {
command: { type: "string" },
args: { type: "object" },
},
required: ["command"],
},
handler: async ({ command, args }) => {
return await invoke(`test_${command}`, args ?? {});
},
});
// generated by hugo AI
有了这层封装,AI 测试一个 Tauri 应用就变成了一段自然语言:
「用 app.reset 重置,用 app.invoke 创建一条笔记 title=hello,重启应用,用 app.assert_persisted 检查笔记还在。」
而不是一段 200 行、每次 UI 改动就要重写的 Playwright 脚本。
一些坑和限制
实践中踩过的坑:
- 不要把 test-api 编进生产:用 Rust 的
#[cfg(feature = "test-api")]严格门控。CI 上要有专门的检查,确保 release 构建里 grep 不到任何test_命令 - 小心「测试接口」变成「绕过逻辑的后门」:业务命令应该走真实代码路径,只是入口不同。如果
test_create_note跳过了真实的校验逻辑,那测出来的结果毫无意义 - State Snapshot 要稳定:字段命名一旦暴露给 AI 就要当成 API 来维护,随便重命名会让所有历史 Agent 测试失效
- 截图 + DOM 仍然有用:OCARD 不是要取消视觉测试,而是把它降级为辅助手段。结构化接口是主路,视觉是兜底
升维:AI Test Ready 是什么的预演?
最后退一步看,这件事真正的意义可能不在「测试」。
当你把一个 Tauri 应用改造成 AI Test Ready,你顺便做了一件更大的事:你把这个应用变成了一个可被 Agent 调用的系统。OCARD 五要素——可观测、可控制、可断言、可重置、可定位——本来就是任何「Agent 友好」系统的基本素质。今天是 AI 拿来测试,明天就是 AI 拿来直接用、拿来做自动化、拿来做 self-healing。
所以这个问题值得每个做产品的人想一想:
你现在写的应用,是只能被人用的,还是能被 Agent 用的?
如果答案是前者,那它的天花板就锁死在「人的注意力」上了。如果是后者,它就有机会成为更大系统里的一个能力节点。
你在自己的项目里做过哪些「让 AI 能看见、能操作」的工程改造?欢迎留言聊聊踩过的坑。