《Wails 测试入门:Go 侧与前端各管一段》
要点
Wails v2 应用是两半拼起来的:Go 后端管采集与逻辑,WebView 前端管展示。测试策略因此不能一刀切——Go 侧该测的用 go test 测到位,前端纯逻辑抽成模块用 Vitest 测,而 WebView 渲染、窗口行为这些”长在系统里”的部分,诚实的做法是手测清单而不是硬造自动化。这篇把分层、每层的工具和各自的边界说清,最后对着剪贴板历史工具实战补一组真测试。
本文不做:v3 的测试特性(v3 仍是预发布线,生产口径继续用 v2.x,写作时最新为 2025 年 2 月的 v2.10.1)、WebView 内部渲染的像素级验证、跨平台 UI 自动化(桌面端没有浏览器测试那种成熟生态,谁劝你上重型 E2E 都先掂量投入产出)。
分层:什么用什么测
先把整张表立起来,后面逐层展开:
- Go 纯逻辑包(历史管理、去重、截断):
go test -race,自动化的主战场; - Go 绑定方法(App 结构体上暴露给前端的 GetHistory/Restore/ClearAll):单测价值低——方法体大多一行转发,真正的坑在参数序列化与错误语义,手测覆盖;
- 前端纯逻辑模块(时间格式化、文本截断):Vitest 3 跑单测;
- 前端 DOM 与事件(点击回填、列表渲染):
wails dev手测清单; - 系统集成面(剪贴板读取、窗口失焦、常驻托盘行为):手工场景验证,自动化收不进来。
这张表的原则一句话:离系统越远越值得自动化,离系统越近越靠手测。绑定层看着薄,但它背后站着整个 WebView 与运行时,单测要 mock 的东西比要验证的东西多,性价比是负的。
还有一件常被忽略的工具:wails generate module 或 wails dev 启动时自动生成的 wailsjs 目录。它把每个绑定方法包装成带 JSDoc 与 TS 声明的 JS 模块,前端调用 GetHistory 时参数类型、返回类型一目了然。别小看这份生成物——绑定层的多数 bug 不是逻辑错,而是前端传了字符串后端收不了:生成物里的类型声明等于给这层接口挂了静态检查,编辑器里就能拦掉一批,比事后在运行时抓错误便宜得多。测试覆盖不到的接缝,让类型系统先站一岗。
Go 侧:把逻辑抽成包再谈测试
实战篇反复强调过一件事:历史管理从 App 结构体里抽成独立包,才能单独跑 go test -race。这篇就按抽好的形态写——包的构造函数收容量上限,Add 负责去重与置顶:
func TestAddDeduplicates(t *testing.T) {
h := history.New(100)
for i := 0; i < 5; i++ {
h.Add("同一段文本")
}
items := h.List()
if len(items) != 1 {
t.Fatalf("去重失败:期望 1 条,实际 %d 条", len(items))
}
if items[0].Text != "同一段文本" {
t.Fatalf("重复复制应置顶到最新,实际首条 %q", items[0].Text)
}
}
预期输出:
PASS
ok clipboard/internal/history 0.21s
这里故意写的是 t.Fatalf 风格而不是断言库——Go 1.22 起 t.Fatal 系列已经够顺,testify 值不值得引取决于团队口味,标准库先行不会错。多条输入组合就上 table-driven:
func TestAddBoundary(t *testing.T) {
tests := []struct {
name string
capacity int
adds int
want int
}{
{"未满容量如实计数", 100, 10, 10},
{"超容量封顶", 100, 150, 100},
{"容量 1 只留最新", 1, 3, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := history.New(tt.capacity)
for i := 0; i < tt.adds; i++ {
h.Add(fmt.Sprintf("text-%d", i))
}
if got := len(h.List()); got != tt.want {
t.Fatalf("期望 %d 条,实际 %d 条", tt.want, got)
}
})
}
}
并发用例与 -race
历史切片是采集 goroutine 和前端绑定方法并发读写的,单线程测试测不出真问题。开 8 个 goroutine 边写边读,并让 -race 全程盯着:
func TestConcurrentAddAndList(t *testing.T) {
h := history.New(100)
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
h.Add(fmt.Sprintf("g%d-%d", g, i))
_ = h.List()
}
}()
}
wg.Wait()
if got := len(h.List()); got != 100 {
t.Fatalf("并发写入后应封顶 100 条,实际 %d 条", got)
}
}
go test -race ./internal/history/
go vet ./...
验收口径沿用实战篇:两个 goroutine 并发跑几百次增删查,-race 报告干净才算过。注意 -race 只是检测器不是保险箱——它只能发现”这次跑到了”的竞态,把这条测试挂进每次提交的 CI 才有长期价值。t.Parallel 和 -race 不冲突,但并发安全验证要的是”同一包内互相较劲”的显式 goroutine,别把两者混为一谈。
前端:纯逻辑抽模块,DOM 靠手测
vanilla 模板没有组件框架,也没有自带测试设施,这正是抽模块的好时机。把截断、时间格式化这类函数从渲染代码里拎出来,放 frontend/src/lib/:
// frontend/src/lib/format.js
export function truncate(text, max = 5000) {
return text.length > max ? text.slice(0, max) + '…' : text;
}
export function formatTime(ms) {
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
前端目录初始化 Vitest(写作口径 Vitest 3.x,2025 年 1 月 GA):
cd frontend
npm install --save-dev vitest
npx vitest run
import { describe, it, expect } from 'vitest';
import { truncate } from '../src/lib/format.js';
describe('truncate', () => {
it('超长截断并补省略号', () => {
expect(truncate('a'.repeat(5001))).toHaveLength(5001);
expect(truncate('a'.repeat(5001)).endsWith('…')).toBe(true);
});
it('未超长原样返回', () => {
expect(truncate('hello')).toBe('hello');
});
});
预期输出:vitest run 打印 2 passed。这里的取舍要说透——没有 jsdom 也能测这类纯函数,所以先别急着把 jsdom 拉进来;一旦某段逻辑不得不摸 DOM,先问一句能不能把 DOM 依赖变成参数传进来,能变就回到纯函数,不能变就进手测清单。
wails dev 下的手测清单
渲染层的验证工具就是 wails dev 本身:应用跑起来之外还起了开发服务器(默认 http://localhost:34115),带 -browser 能在浏览器里打开同一套页面,Go 侧改动自动重编译、前端资产改动自动刷新,Ctrl/Cmd+Shift+F12 唤出 WebView devtools 看绑定调用与 console。手测清单照搬实战篇的验收指标,逐条打勾:
- 连续复制 10 次不同文本,每次 1 秒内条目出现、无漏记;
- 连续复制同一文本 5 次,历史里只有 1 条且时间最新;
- 粘一整篇长文进去,确认按 5000 字符截断、省略号在位;
- 点条目,内容回填剪贴板、窗口收起(Restore 的错误语义也在这一步顺带验:清空历史后前端调 Restore(0),应得到错误提示而不是白屏);
- 点关闭窗口消失、进程仍在,再双击可执行文件窗口唤回;
- 记录 3 条后杀进程重启,历史还在。
这六条没有一条能被 Go 单测或 Vitest 覆盖:它们全踩在”WebView + 系统剪贴板 + 窗口生命周期”的交界面上。自动化测试工程师看到这种清单会想录 Playwright,桌面 WebView 里这条路在 v2 口径下并不通——接受手测,把它写进 README 当回归清单,每次发版过一遍。执行记录也要留:哪天做的、跑在哪个系统、每条是否通过,一份 markdown 勾选就够。手测最大的敌人不是麻烦,是”凭感觉说测过了”——留痕让手测从口头印象变成可追溯的验收记录,出问题时也能快速锁定是哪个版本引入的。
什么测什么不测
边界收拢成三句。第一,剪贴板读取本身不测:OS 剪贴板是外部世界,单测里伪造不了它的行为,能测的是”读到的内容如何进历史”,这一段在 history 包里。第二,绑定方法不单测:App.Restore(index) 这种一行业务加一行错误包装的方法,单测只能证明转发成立,证明不了前端真的按类型调用——wails dev 里看一次真实的调用往返,比五个 mock 测试有用。第三,性能不测数字:启动耗时、列表滚动流畅度这类只能在目标硬件上定性观察,测试环境里量出来的毫秒数没有意义。
版本升级也划在这张边界表里。Wails 的补丁版本升级后,受影响最大的是渲染层与系统交互面——恰是手测清单管辖的范围,Go 单测和前端单测基本感知不到。所以升级 Wails 或换 Go 小版本的节奏是:先跑全量自动化(快,十几秒),再过一遍六条手测清单(慢,十几分钟),两层都绿再提交。省掉后一半的代价是 WebView 行为悄悄变了,用户先于你发现。
常见坑
- 逻辑留在 main 包里没法测:Go 的 main 包测试别扭,历史管理没抽包之前一切都是空谈。先抽包、后写测试,顺序不能反。
- 并发测试用 sleep 凑数:goroutine 循环里加 time.Sleep 让 -race”偶尔过”,正确姿势是 WaitGroup 等齐再断言。
- 前端函数和渲染耦合:formatTime 里顺手 innerHTML 更新列表,Vitest 没法在 Node 里跑。模块只做计算,副作用留给调用方。
- 手测清单不落文档:凭记忆手测,发版前必漏。清单写进仓库,打勾走完才允许出包。
- 只跑 go test 不带 -race:数据竞争在无 -race 的构建里可能长期潜伏,桌面应用一常驻就是几小时几天的运行时长,爆出来时用户已经踩过。
验收清单
- internal/history 包至少 5 条用例:去重置顶、容量封顶、容量边界、并发读写、空历史查询,
go test -race全绿且报告无竞态。 - 故意去掉 Add 里的锁再跑 -race,并发用例必须红——验证测试真的在检测,而非永远绿。
- frontend 下 npx vitest run 通过,纯函数模块与渲染代码零耦合。
- 六条手测清单全部执行并留档(截图或勾选记录),Restore 的错误路径至少验一次。
- 绑定方法层没有任何 mock 出来的单测——这条是”没有”才是对的。
接着走路线:Wails 学习路线里测试排在完整实战之后,本文正好补上这一站;测试中发现绑定层的类型或错误处理不对劲,回实战篇对应小节核对设计;下一步把这套产物变成三平台的安装包,见打包分发篇。
本文数据核对时间:2025-03-14:Wails v2.10.1(2025-02-24 发布,v2.10 起支持 Go 1.24)+ Go 1.23(Go 1.24 于 2025-02-11 GA,两者均可跑本文示例)+ Vitest 3.x(3.0 于 2025-01-17 GA);wails dev 端口与 devtools 行为以 v2 官方 CLI 文档为准。