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(连模型的思考过程都推)/ timestampRunFinished: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(测试计划批量执行)。
七、写完这个项目,我最大的几点体会
- 桌面端 Agent 的难点不在 LLM,而在"手" 。模型再聪明,UIAutomation 定位不准、弹窗处理不了,全是白搭。一大半精力都花在
ElementFinder这种脏活上。 - 业务语义化工具 >> 原子操作工具 。给模型
set_unit_price(row, price)比给它click + type让它自己拼,成功率高一个数量级。 - 双重验证是必须的。Agent 自评不可信,加一层独立的视觉判卷,假阳性大幅下降。
- 把 token 当钱花 。已知 id 直接喂、
scan_ui严格限制、思考过程精简------成本能差好几倍。
目已开源(MIT),后端 ASP.NET Core 9 + 前端 Vue 3,仓库里有架构设计、需求设计、详细设计文档和 TODO,想了解实现细节或二次开发都比较好上手:
👉 GitHub:github.com/SmileL1/Tes...
如果这套思路对你有启发,欢迎点个 ⭐ Star,也欢迎提 Issue / PR 一起把桌面端自动化测试做得更好。
桌面端测试这块太冷了,多一个人折腾,生态就好一点。共勉。