AI Test Ready:把 Tauri 应用改造成可被 AI Agent 测试的系统

Engineering Practices for Making Desktop Apps Testable by AI Agents

上周我让 Claude Code 帮我跑一遍我的 Tauri 笔记应用的回归测试。给它的任务很简单:「新建一条笔记,输入’hello’,保存,重启应用,验证笔记还在」。

它失败了。不是因为应用有 bug,而是因为它根本看不见这个应用——它能启动进程、能截屏,但屏幕里的「新建按钮」对它来说是一团像素;它知道「保存」这个动词,但不知道怎么把这个动词翻译成 IPC 调用;它甚至不知道「重启之后笔记是否还在」这件事该去哪里查证。

那一刻我意识到:让 AI 测试一个应用,瓶颈从来不在 AI 的能力,而在于这个应用本身是否「可被 AI 测试」。这是一种新的工程属性,我把它叫做 AI Test Ready。

为什么传统测试思路会失效

我们过去三十年建立的测试体系,核心假设是「人在写测试」:

  • 单元测试:人来想哪些边界条件
  • 集成测试:人来设计调用链路
  • E2E 测试:人来写 Selenium/Playwright 脚本,靠 CSS 选择器、固定坐标、显式等待

这套体系对人很友好——人有领域知识、有耐心、有判断力。但对 AI Agent 来说,它有三个致命问题:

  1. 脆弱(Fragile):CSS 选择器一改就全挂,AI 不擅长维护脆弱契约
  2. 不透明(Opaque):截屏 + DOM 是给人看的,AI 要从像素里反推语义成本极高
  3. 不可逆(Stateful):测试副作用污染了状态,AI 没办法干净地重来

更要命的是,AI Agent 的测试模式是探索式的——它不是按脚本走,而是「试试看,不行换一个」。这意味着应用必须能承受高频的状态变更、能快速重置、能给出结构化反馈。

传统应用没有这些设计。所以你看到的现象是:人类写的测试跑得好好的,一交给 AI 就处处碰壁。

思维框架:AI Test Ready 五要素(OCARD 模型)

我把「可被 AI 测试」拆解成五个工程属性,简称 OCARD

维度名称一句话定义没有它会怎样
OObservable(可观测)AI 能用结构化方式读到当前状态AI 在猜屏幕里有什么
CControllable(可控制)每个 UI 操作都有一个非 UI 等价入口AI 只能模拟点击,慢且不稳
AAssertable(可断言)应用副作用以数据形式暴露AI 不知道操作有没有真的生效
RResettable(可重置)一条命令能回到已知初始状态上一次失败污染下一次测试
DDiagnosable(可定位)失败时自动产出可读的现场信息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-api feature 关掉时整个文件不编译进生产构建
  • 结构化字段而不是字符串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 脚本。

一些坑和限制

实践中踩过的坑:

  1. 不要把 test-api 编进生产:用 Rust 的 #[cfg(feature = "test-api")] 严格门控。CI 上要有专门的检查,确保 release 构建里 grep 不到任何 test_ 命令
  2. 小心「测试接口」变成「绕过逻辑的后门」:业务命令应该走真实代码路径,只是入口不同。如果 test_create_note 跳过了真实的校验逻辑,那测出来的结果毫无意义
  3. State Snapshot 要稳定:字段命名一旦暴露给 AI 就要当成 API 来维护,随便重命名会让所有历史 Agent 测试失效
  4. 截图 + DOM 仍然有用:OCARD 不是要取消视觉测试,而是把它降级为辅助手段。结构化接口是主路,视觉是兜底

升维:AI Test Ready 是什么的预演?

最后退一步看,这件事真正的意义可能不在「测试」。

当你把一个 Tauri 应用改造成 AI Test Ready,你顺便做了一件更大的事:你把这个应用变成了一个可被 Agent 调用的系统。OCARD 五要素——可观测、可控制、可断言、可重置、可定位——本来就是任何「Agent 友好」系统的基本素质。今天是 AI 拿来测试,明天就是 AI 拿来直接用、拿来做自动化、拿来做 self-healing。

所以这个问题值得每个做产品的人想一想:

你现在写的应用,是只能被人用的,还是能被 Agent 用的?

如果答案是前者,那它的天花板就锁死在「人的注意力」上了。如果是后者,它就有机会成为更大系统里的一个能力节点。

你在自己的项目里做过哪些「让 AI 能看见、能操作」的工程改造?欢迎留言聊聊踩过的坑。