当前位置: 首页 > news >正文

Go 表驱动测试实战:用 t.Run 子测试组织可维护的单元测试

Go 表驱动测试实战:用 t.Run 子测试组织可维护的单元测试

你给一个函数写单测,第一版是复制粘贴:测输入 A 断言一段,测输入 B 再抄一遍断言,测边界值又抄一遍。三个用例还好,等长到十几个,某天函数签名改了,你得挨个改十几段几乎一样的代码。更糟的是,一个断言t.Fatal挂掉,后面的用例根本没跑,你不知道到底错了几个。Go 社区的标准答案是表驱动测试:把用例做成一张表,循环跑,配合t.Run拆成独立子测试。这篇手把手带你从「复制粘贴」重构到「一张表搞定」。

被测函数

拿一个简单的场景:把秒数格式化成人类可读的时长。

packagetimeutilimport"fmt"// FormatDuration 把秒数格式化成 "1h2m3s" 这样的字符串funcFormatDuration(secint)(string,error){ifsec<0{return"",fmt.Errorf("秒数不能为负: %d",sec)}h,m,s:=sec/3600,(sec%3600)/60,sec%60returnfmt.Sprintf("%dh%dm%ds",h,m,s),nil}

朴素写法:复制粘贴,又臭又长

第一版新手写法,每个用例抄一遍:

funcTestFormatDuration_Naive(t*testing.T){got,_:=FormatDuration(0)ifgot!="0h0m0s"{t.Fatalf("0 秒: 期望 0h0m0s, 得到 %s",got)}got,_=FormatDuration(3661)ifgot!="1h1m1s"{t.Fatalf("3661 秒: 期望 1h1m1s, 得到 %s",got)}_,err:=FormatDuration(-1)iferr==nil{t.Fatal("负数应该报错")}}

三个问题:断言逻辑重复三遍;t.Fatalf一旦触发,后面的用例直接不跑了;加用例只能继续往下抄。

表驱动重构:用例是数据,断言只写一次

核心思路是把「输入 + 期望输出」抽成一个结构体切片(就是那张「表」),断言逻辑写一遍,循环套用:

funcTestFormatDuration(t*testing.T){// 这张表就是全部用例,加用例 = 加一行tests:=[]struct{namestring// 用例名,出错时能一眼看出是哪个inputintwantstringwantErrbool}{{name:"零秒",input:0,want:"0h0m0s",wantErr:false},{name:"一分钟",input:60,want:"0h1m0s",wantErr:false},{name:"时分秒齐全",input:3661,want:"1h1m1s",wantErr:false},{name:"负数报错",input:-1,want:"",wantErr:true},}for_,tt:=rangetests{got,err:=FormatDuration(tt.input)if(err!=nil)!=tt.wantErr{t.Errorf("%s: 是否报错 = %v, 期望 wantErr = %v",tt.name,err!=nil,tt.wantErr)continue}ifgot!=tt.want{t.Errorf("%s: 得到 %q, 期望 %q",tt.name,got,tt.want)}}}

注意这里用t.Errorf而不是t.Fatalf——Errorf标记失败但继续跑,所以一次能看到所有失败的用例,而不是碰到第一个就停。加新用例只需在表里加一行,断言逻辑一个字都不用动。

升级:t.Run 拆成独立子测试

上面的版本还有个小缺点:所有用例挤在一个TestFormatDuration里,测试输出看不清哪个子用例过了、哪个挂了,也没法单独跑某一个。用t.Run给每个用例开一个子测试就解决了:

funcTestFormatDuration(t*testing.T){tests:=[]struct{namestringinputintwantstringwantErrbool}{{name:"零秒",input:0,want:"0h0m0s"},{name:"一分钟",input:60,want:"0h1m0s"},{name:"时分秒齐全",input:3661,want:"1h1m1s"},{name:"负数报错",input:-1,wantErr:true},}for_,tt:=rangetests{// 每个用例一个子测试,名字就是 tt.namet.Run(tt.name,func(t*testing.T){got,err:=FormatDuration(tt.input)if(err!=nil)!=tt.wantErr{t.Fatalf("是否报错 = %v, 期望 %v",err!=nil,tt.wantErr)}ifgot!=tt.want{t.Errorf("得到 %q, 期望 %q",got,tt.want)}})}}

t.Run的好处:

  • 输出里每个子测试独立显示,--- FAIL: TestFormatDuration/负数报错一眼定位。
  • 可以只跑某一个:go test -run 'TestFormatDuration/时分秒齐全'
  • 子测试之间隔离,某个子测试t.Fatalf只终止它自己,不影响其余子测试继续跑。

跑一下看输出:

$ gotest-v-runTestFormatDuration===RUN TestFormatDuration===RUN TestFormatDuration/零秒===RUN TestFormatDuration/一分钟===RUN TestFormatDuration/时分秒齐全===RUN TestFormatDuration/负数报错 --- PASS: TestFormatDuration(0.00s)--- PASS: TestFormatDuration/零秒(0.00s)--- PASS: TestFormatDuration/一分钟(0.00s)--- PASS: TestFormatDuration/时分秒齐全(0.00s)--- PASS: TestFormatDuration/负数报错(0.00s)PASS

一个必须知道的坑:循环变量与 t.Parallel

如果你想让子测试并行跑(t.Parallel()),在Go 1.22 之前有个经典陷阱:所有子测试闭包共享同一个循环变量tt,并行执行时它们读到的都是循环结束后的最后一个值。老代码里你会看到这种「重新赋值」的写法来规避:

for_,tt:=rangetests{tt:=tt// Go 1.22 前必须这行,给每次迭代一份独立副本t.Run(tt.name,func(t*testing.T){t.Parallel()// 标记为并行,和其他并行子测试一起跑got,_:=FormatDuration(tt.input)ifgot!=tt.want{t.Errorf("得到 %q, 期望 %q",got,tt.want)}})}

Go 1.22 起循环变量每次迭代都是新的,tt := tt这行可以删掉了。但如果你的项目还跑在老版本,或者你不确定别人的环境,留着它最保险——它无害。不加t.Parallel()时子测试是串行的,不受这个坑影响,但加了并行又忘了处理循环变量,就会得到「所有用例都在测最后一行数据」的诡异结果。

小结

  • 别复制粘贴断言:把用例抽成结构体切片(表),断言逻辑只写一遍,循环套用。
  • 表里给每个用例一个name字段,出错能一眼定位是哪个用例。
  • t.Run(tt.name, ...)拆成子测试:输出清晰、可单独跑、子测试之间隔离。
  • 收集所有失败用t.Errorf(继续跑),需要立即终止当前子测试才用t.Fatalf
  • 并行子测试注意循环变量陷阱:Go 1.22 前要写tt := tt,1.22 起可省;不确定就留着。
  • 一句话记忆:用例是数据、断言是代码,一张表 +t.Run就是 Go 测试的标准姿势。
http://www.jsqmd.com/news/1309023/

相关文章:

  • AI系统重构迁移全链路拆解(含TensorFlow→PyTorch迁移Checklist):217个真实故障点+修复代码片段
  • 如何判断数学建模质量好坏
  • Qwen图像编辑快速一体化解决方案:4步生成专业AI图片的完整指南
  • 5分钟掌握ROCm性能分析:rocProfiler从入门到精通指南
  • 终极指南:如何用QuickRecorder轻松搞定macOS屏幕录制
  • 智能刀具柜哪些品牌值得选?2026高性价比产品型号推荐清单! - 匠言榜单
  • 2026年安康AIGC应用工程师怎么报名?中山优才教育报考指南 - 人工智能报名机构推荐
  • Textractor终极指南:3分钟掌握游戏文本提取技术,轻松突破语言障碍!
  • 文心一言插件市场准入白皮书(2024Q2最新版):从零通过审核的12个硬性指标与3个隐藏门槛
  • 深度解析:Bad Apple病毒项目如何实现Windows窗口实时动画渲染
  • 长春芬尼服务商哪家专业:【芬尼】持证运维 - 松梢月冷
  • 武汉男生学什么技术前景广?武汉万通汽车学校地址,新能源、智能制造专业详解 - 湖北找学校
  • 开发者必看:Flask-Security核心组件解析与自定义配置技巧
  • 企业私有化与云端部署AI快速开发工具选型:全场景最好用
  • USB转串口转换器:工业通信的桥梁与实战指南
  • 突破性LiDAR-IMU时空参数联合标定:面向自动驾驶的高精度传感器融合初始化方案
  • 猫抓浏览器扩展:三步轻松下载网页视频的完整指南
  • 解决MaxMind-DB-Reader-php常见问题:128位整数支持与数据库兼容性处理
  • We0.ai:重新定义AI原生开发范式的架构革命
  • 长春芬尼安装公司哪家专业:【芬尼】因地制宜 - 云溪自乐
  • 2026 年更新:青羊正规的电解厂房槽体淤泥打捞回收供应商找哪家,电解厂房里积了多年的硬疙瘩,居然能变废为宝捞回实在的收益? - 行业甄选官
  • AI项目落地卡在“最后一公里”?(闭环断裂诊断工具包正式开源)
  • 扫描21,988部动漫,揭秘现实职业在动漫中的提及情况,成本仅34美元!
  • 洛雪音乐音源完全指南:10分钟打造你的专属音乐库
  • 商用人工智能快速开发工具选择建议:零基础到私有化部署全解析
  • 如何合法合规地使用视频下载工具:从DownKyi案例看开源项目法律边界
  • 别急着上LangGraph,先把成本、边界和失败兜底算清楚
  • Steam创意工坊模组免费下载终极指南:如何用WorkshopDL跨平台获取海量游戏资源
  • 2026年微信商城小程序选型:右以云、有赞、微盟谁更适合零售商家 - 右以云小右
  • 2026西宁食品行业GEO优化服务商大盘点:正规合规机构甄选指南+全流程合作避坑FAQ - 商业大观