我给一个仓库系统写了个"会自己点界面"的 AI 测试 Agent,踩平了 WPF 自动化的所有坑

WPF 桌面端自动化测试有多难做,做过的人都懂。这次我把 DeepSeek 的 tool-calling 塞进了 UIAutomation,让 AI 自己看界面、自己点按钮、自己填表格,最后还让多模态模型截图给整个测试"判卷"。这篇把架构和最痛的几个坑全摊开讲。

先说结论:它现在能干什么

被测对象是一套仓库管理系统 (WPF 桌面端)。我做的这个平台能做到:

  • 你用自然语言写测试目标,比如"新建,选择、填信息、提交,验证发行成功";
  • AI Agent 自己 scan_ui 看界面 → click / set_text / select_item 一步步操作 → 每点一下 check_dialog 处理弹窗;
  • 跑完之后,再截一张结果界面的图,丢给多模态模型做二次验证,Agent 自报成功 + 截图验证通过,才算真的通过;
  • 全程通过 SignalR 把每一步的"思考 / 工具 / 参数 / 结果"实时推给前端,像看直播一样看 AI 测试。

技术栈:ASP.NET Core 9(net9.0-windows)+ Windows UIAutomation + Vue 3 + Element Plus + SignalR + DeepSeek

下面进正题,全是干货。


一、为什么不用 Selenium / Playwright 那一套?

因为被测的是 WPF 原生桌面应用 ,不是网页。浏览器自动化那套 DOM 选择器在这里完全不适用,桌面端能用的只有 Windows 的 UIAutomation API。而 UIAutomation 的体验......只能说"能用,但处处是坑"。

我设计了三种执行模式,落在一个 RunService 里调度:

  • structured(结构化回放) :先用录制器录下鼠标键盘操作,回放时按 AutomationId 精确重放,快且稳;
  • ai(AI 推理):没有录制脚本时,让 LLM 通过 tool-calling 逐步推理操作;
  • auto(默认):场景有录制步骤就走结构化,否则自动降级到 AI。

录制和 AI 回放共用同一套定位基础设施 ElementFinder,这点很关键------录的时候怎么找元素,AI 跑的时候就怎么找,行为一致。


二、核心:让 LLM 通过 tool-calling 操作 WPF

AI 模式的本质是一个 tool-calling 循环 :LLM 返回它想调用的工具,我执行后把结果回传,它再决定下一步。直到它调用 done 报告结果,或者超过最大步数。

把工具集设计得贴合业务,是 Agent 好不好用的分水岭。我没有只给 click/type 这种原子操作,而是把仓库系统里高频又易错的动作封装成了语义化工具

csharp 复制代码
switch (call.Name)
{
    case "scan_ui":        return OpResult.Ok(_driver.ScanDescription());
    case "click":          return _driver.Click(a["element_id"]);
    case "set_text":       return _driver.SetText(a["element_id"], a["text"]);
    case "select_item":    return _driver.SelectItem(a["element_id"], a["item_text"]);
    case "click_cell":     return _driver.ClickCell("DgVoucherInItem", row, column); // 表格单元格
    case "set_unit_price": return _driver.SetUnitPrice(row, a["unit_price"]);        // 填単価
    case "select_product": return _driver.SelectProduct(a["product_code"]);          // 商品弹窗选品
    case "check_dialog":   return OpResult.Ok(_driver.TryGetDialog()?.ToString() ?? "无弹窗");
    case "done":           /* 报告 success + summary */
    // ...
}

System Prompt 里我做了两个关键优化,直接决定了 token 成本和成功率:

1. 把已知的 AutomationId 直接喂给模型 ,让它别动不动就 scan_ui(全树扫描既慢又烧 token):

text 复制代码
## 已知 AutomationId
voucher_branch, voucher_voucherType, voucher_customer,
btn_add, btn_voucherIssue, btn_measurement,
detail_{N}_preWeight, detail_{N}_afterWeight, detail_{N}_product, detail_{N}_unitPrice,
dialog_message, dialog_btn_yes, dialog_btn_no,
product_search, product_list, unitprice_settingPrice ...

## 规则
1. 已知 AutomationId 直接用,不必 scan_ui
2. 表格:get_row_count → click_cell 激活 → set_text 填值
3. 点击后必须 check_dialog 处理弹窗
4. 商品选完后调用 set_unit_price 填単価

2. 一轮可能返回多个 tool_call,必须全部执行完、逐个回传结果,再请求下一轮------这是 tool-calling 协议很容易写错的地方:

csharp 复制代码
foreach (var call in response.ToolCalls)
{
    var result = ExecuteTool(call, testName, step);
    await Notify(step, call.Name, call.RawArgs, result.Message, result.Success, response.Text);
    _llm.AddToolResult(call.Id, result.Message); // 每个 tool_call 都要回传对应结果
}
response = await _llm.ContinueAsync(SystemPrompt);

经验:scan_ui 一定要标注"谨慎使用,仅首次/未知界面/失败时调用"。否则模型会每一步都先扫一遍界面,几千 token 哗哗地烧。


三、最硬的坑:UIAutomation 的坐标命中

这部分是整个项目最折磨人、也最有价值的地方。WPF 自定义窗体 + 悬浮层的场景下,AutomationElement.FromPoint(x, y) 经常返回一个覆盖整个窗口的空 Window/Pane 层,根本不是你点的那个按钮。

我用两个手段解决:向下钻 + 向上提

1. 命中空容器 → 按坐标手动下钻

每一层都挑"包含该点、面积最小"的子元素,一直钻到最深的真实控件,同时跳过那些没名字没 id 的悬浮层壳:

csharp 复制代码
public static AutomationElement? DescendToPoint(AutomationElement root, int x, int y)
{
    var point  = new Point(x, y);
    var walker = TreeWalker.ControlViewWalker;
    var cur    = root;

    for (int depth = 0; depth < 25; depth++)
    {
        AutomationElement? best = null;
        double bestArea = double.MaxValue;

        var child = walker.GetFirstChild(cur);
        while (child != null)
        {
            var cc = child.Current;
            bool isOverlayShell = cc.ControlType == ControlType.Window
                                  && string.IsNullOrEmpty(cc.Name)
                                  && string.IsNullOrEmpty(cc.AutomationId);
            var r = cc.BoundingRectangle;
            if (!isOverlayShell && !r.IsEmpty && r.Contains(point))
            {
                var area = r.Width * r.Height;
                if (area < bestArea) { bestArea = area; best = child; } // 选最小面积
            }
            child = walker.GetNextSibling(child);
        }
        if (best == null) break;
        cur = best;
    }
    return cur;
}

2. 命中按钮内部文字 → 向上提升到可交互祖先

你点按钮,FromPoint 命中的往往是按钮里那个 TextBlock,拿不到 btn_xxx 这个 id。所以要向上爬最多 4 层,找到最近的带 AutomationId 的交互祖先:

csharp 复制代码
public static AutomationElement? LiftToInteractive(AutomationElement el)
{
    var walker = TreeWalker.ControlViewWalker;
    var cur = walker.GetParent(el);
    for (int i = 0; i < 4 && cur != null; i++)
    {
        var c = cur.Current;
        if (c.ControlType == ControlType.Window) return null;
        if (!string.IsNullOrEmpty(c.AutomationId)) return cur; // 找到 id,提升成功
        if (c.ControlType == ControlType.Button) return cur;
        cur = walker.GetParent(cur);
    }
    return null;
}

3. 用"轮询等待"取代散落的 Thread.Sleep

界面切换、弹窗弹出都有延迟。早期代码里到处是 Sleep(500),又慢又不稳。后来统一改成在 ById 里内建轮询:

csharp 复制代码
public AutomationElement? ById(string automationId, int timeoutMs = 0)
{
    var deadline = Environment.TickCount64 + timeoutMs;
    while (true)
    {
        try
        {
            var el = Window.FindFirst(TreeScope.Descendants,
                new PropertyCondition(AutomationElement.AutomationIdProperty, automationId));
            if (el != null) return el;
        }
        catch { /* 窗口切换中,重试 */ }

        if (Environment.TickCount64 >= deadline) return null;
        Thread.Sleep(150);
    }
}

还有个隐蔽坑:录制时挂了全局鼠标钩子,执行测试前必须先卸载钩子,否则回放的鼠标事件会被自己的录制器再次捕获,形成死循环。


四、画龙点睛:让多模态模型给测试"判卷"

Agent 自己报"测试通过"是不够可信的------它可能漏看了一个错误弹窗。所以我加了一层 AI 截图验证:测试结束后对结果界面截图,连同测试目标一起发给多模态模型,让它判断到底成没成。

判定逻辑很"较真":Agent 自报成功 + 截图验证通过,二者都满足才算通过

csharp 复制代码
// 综合判定:Agent 自报成功 AND(未启用截图验证 OR 截图验证通过)
bool finalOk = ok && (!aiChecked || aiPass);

更妙的是反过来也成立------如果 Agent 因为超步数没显式完成,但结果界面截图验证是通过的,可以据此翻盘判通过

csharp 复制代码
if (endChecked && endPass)
    return new AgentResult { Success = true,
        Summary = "AI 截图验证通过(Agent 未显式完成,按结果界面判定)" };

为了让结论可解析,我强制模型首行只输出结论

text 复制代码
请结合截图判断本次测试是否成功完成。重点关注:
是否有错误/失败提示弹窗、是否跳转到预期结果界面、关键数据是否正确显示。
**第一行只输出**:`结论:通过` 或 `结论:不通过`
然后另起一行简要说明理由(2-4 句)。
csharp 复制代码
var firstLine = answer.Split('\n').FirstOrDefault()?.Trim() ?? "";
bool pass = firstLine.Contains("通过") && !firstLine.Contains("不通过");

视觉验证走的是 OpenAI 兼容的 /v1/chat/completions 多模态格式,所以通义、智谱、DeepSeek 的视觉模型可以随便换。我还写了个自适应拼地址的小工具,省得每家厂商 baseUrl 结尾不一样要改代码:

csharp 复制代码
// 已含 /chat/completions 直接用;以 /vN 结尾补 /chat/completions;否则补 /v1/chat/completions
if (b.Contains("/chat/completions")) return b;
if (Regex.IsMatch(b, @"/v\d+$"))     return b + "/chat/completions";
return b + "/v1/chat/completions";

五、实时进度:SignalR 把测试跑成"直播"

每一步执行完,都通过 SignalR 推给前端,客户端按 run_{runId} 分组订阅:

  • StepLog:step / tool / args / result / success / thinking(连模型的思考过程都推)/ timestamp
  • RunFinished:runId / status / summary

前端在 /run/:runId 监控页实时滚动,AI 每点一下、每想一步你都看得见,调试体验非常爽。


六、架构全景

scss 复制代码
WPF 被测应用
        ▲
        │ Windows UIAutomation
        │
┌───────┴───────────────────────────────────┐
│  ASP.NET Core 9 (net9.0-windows)           │
│                                            │
│  RunService ── auto / structured / ai      │
│      ├─ StepPlayer   (录制回放)            │
│      └─ AiAgent      (LLM tool-calling)    │
│            ├─ DeepSeekClient   (推理)       │
│            ├─ WpfDriver/ElementFinder (操作) │
│            └─ VisionVerifier   (截图判卷)   │
│                                            │
│  TestHub (SignalR) ──► 实时推送每一步        │
└───────┬────────────────────────────────────┘
        │ REST + SignalR
        ▼
   Vue 3 + Element Plus 前端
   场景管理 / 录制 / 实时监控 / 历史 / 测试计划

数据落 PostgreSQL(SqlSugar ORM),启动自动建库建表。核心实体:Scenario(场景)、TestRun(执行记录)、RunLog(每步日志)、TestPlan(测试计划批量执行)。


七、写完这个项目,我最大的几点体会

  1. 桌面端 Agent 的难点不在 LLM,而在"手" 。模型再聪明,UIAutomation 定位不准、弹窗处理不了,全是白搭。一大半精力都花在 ElementFinder 这种脏活上。
  2. 业务语义化工具 >> 原子操作工具 。给模型 set_unit_price(row, price) 比给它 click + type 让它自己拼,成功率高一个数量级。
  3. 双重验证是必须的。Agent 自评不可信,加一层独立的视觉判卷,假阳性大幅下降。
  4. 把 token 当钱花 。已知 id 直接喂、scan_ui 严格限制、思考过程精简------成本能差好几倍。

目已开源(MIT),后端 ASP.NET Core 9 + 前端 Vue 3,仓库里有架构设计、需求设计、详细设计文档和 TODO,想了解实现细节或二次开发都比较好上手:

👉 GitHub:github.com/SmileL1/Tes...

如果这套思路对你有启发,欢迎点个 ⭐ Star,也欢迎提 Issue / PR 一起把桌面端自动化测试做得更好。

桌面端测试这块太冷了,多一个人折腾,生态就好一点。共勉。

相关推荐
未秃头的程序猿1 小时前
别再重复适配了!用MCP给AI配个"万能工具箱",Java项目接入新能力再也不改代码
后端·ai编程·mcp
Python私教1 小时前
002 Pandas 的流行原因
人工智能·后端·机器学习
Jul1en_1 小时前
【SpringCloud】SkyWalking 链路追踪知识详解及部署教程
java·后端·spring·spring cloud·skywalking
宸津-代码粉碎机1 小时前
Spring AI 企业级实战|智能记忆摘要+自动遗忘机制落地,彻底解决上下文爆炸与Token冗余
java·大数据·人工智能·后端·python·spring·云计算
摸摸芋1 小时前
Django框架(1)
后端·python·django
摇滚侠2 小时前
SSM 框架实战教程 SpringBoot 自动配置 176-179
java·spring boot·后端
ywl4708120872 小时前
spring单列bean之循环依赖核心源码解读
java·后端·spring
苏三说技术2 小时前
推荐一个牛逼的企业知识库系统
后端
大刚测试开发实战2 小时前
TestHub数据工厂发布!附更新指南
前端·后端·github