书接上文, 谈谈 code review: /t/693941
- 优点:保证工程质量。API 级别的 unit test 。
- 缺点:过量的 UT 只会导致 API 不好改动,浪费更多时间前期开发+后期维护
最近我的波兰“好兄弟”脑壳一抽,提出要加 UT,早不写,晚不写,非得 API 全都弄完了再写。本来就是谁写代码,谁就应该加 UT 的事情,结果还得先让我提一个 UT plan 上去。
然后我就改了七八版本吧。他有如下要求
写 UT 从来都是好事情,波兰“好东西”就是脑壳不转弯的,现在每天下午快下班了在那吵架,搞得大家都很烦,这个 UT plan 提了几周还在改,我真是佛了。
1
petercui 2021-01-28 11:25:36 +08:00 1
第一点我站“好兄弟”那边;第二个点我站你这边,第三个点我觉得 UT 本来就应该三倍于真正的业务代码,你这才“有的一比”,简直太少了。
|
2
guyeu 2021-01-28 11:41:18 +08:00 1
教条主义单元测试实践
|
3
xylophone21 2021-01-28 11:50:23 +08:00
同求这种 C/C++项目的 UT 实践,除了楼主说的打桩问题,makefile 问题不知道你们怎么解决的?比如你第二条里说的 A.c 在 makefile 里本来是依赖 B.o 或者 B.lib 的,但既然要 UT,那就只能依赖 MockB.o,甚至 MockB1.o,MockB2.o 等等(因为要 Mock 不同返回的情况),既然有了 MockB.o,那和 B.o 肯定就不能打包到一个 main 里了(除非全打成动态库,但动态库又有动态库的问题),于是又是一堆 main,一堆初始化……
以前我一直以为 UT 是三倍业务代码就是这些原因,直到我看了 Spring 里的 UT 框架,各种 Repository 、Service 、网络层的 Mock,运行时像注入哪个注入哪个。不想打桩时直接用真实实例也可以直接用(比如测 Service 时,Repository 不想打桩的话,可以直接调用真实的 Repository 准备各种数据——PS,我不知道这种方法是不是不够 UT,但确实好用,节省时间)。所以如果三倍工作量是 Spring 说出来的话,我简直怀疑在 C/C++界,这个工作量可能是 10 倍,30 倍? |
4
sockpuppet9527 OP @xylophone21 #3
C 的话,有两个办法,其一就是 MockB.o 的办法(正如你自己说的),第二个办法是替换掉 weak symbols,这个办法需要在 Makefile 中显示声明,并且可以让原有的.o 文件被 link 。 C++的话简单很多,如果你用 GTest,里面有 GMock,这是基于模板的,如果你想用 GMock 去替换掉 global 的函数,就得自己加点东西。 |
5
sockpuppet9527 OP @petercui #1 三倍的话,我其实很好奇你是如何做到后期维护的,假如你动了某个方法,那可能你需要改很多 UT 。并且你在改了 UT 的同时,有可能和原先 UT 的设计者想法背道而驰。
|
6
feather12315 2021-01-28 13:08:12 +08:00 via Android
动态注入,x86 用 call ( 0xe8 )、jmp ( 0xe9 )实现
|
7
feather12315 2021-01-28 13:09:02 +08:00 via Android
…当前就在写测试代码,利用内核实现的 livepatch 做注入
|
8
AndyAO 2021-01-28 13:09:45 +08:00
关于 UT 阻碍修改的问题.
在进行大范围的更改的时候,就应该舍去. 因为测试的很多都是实现细节,而不是行为本身实现,细节本来就可以改的. 这个问题自从 UT 诞生以来就有了,上世纪 90 年代还有人这么说. 要再写单元测试的同时也要写行为测试,当然有必要的话还可以写更高层的. 这样的话,如果更改已经明显越过测试了,那就应该舍弃. 而至于万一测试没问题,但上线之后有问题该怎么办,这个也是老问题了,关于测试的书里很多都谈到了. 有点类似于科学实验的意思,发个火箭造个飞机什么的,也都不可能完全按照现实情况来进行测试内容测试就不能叫测试. 至于论证的话比较偏哲学所以在这里我也就不写了,相关的书里都有. 我写测试也不太专业,我也在补课,感觉很多书都讲的不错,书比较老,也有很多的养分,如果不吸取前人的经验,那么就会重复的方案,90 年代大家都遇到过的错误. |
9
AndyAO 2021-01-28 13:11:52 +08:00
@AndyAO #8 更改上面一段,删除掉不小心打错了部分
而至于万一测试没问题,但上线之后有问题该怎么办,这个也是老问题了,关于测试的书里很多都谈到了. 有点类似于科学实验,发火箭造飞机都不是按照实际情况来进行测试的,也都不可能完全按照现实情况来进行测试. |
10
niubee1 2021-01-28 13:13:25 +08:00 1
其实很多人一开始都觉得单元测试浪费时间,但是很多时候回过头来看看,没有单元测试才是真把时间都喂狗了
|
11
AndyAO 2021-01-28 13:17:17 +08:00
@niubee1 #10
这不就是测试出动开发吗?目前研究表明这个方法还是能够提高效率和代码质量的,至少不会变差.[1] 软件业目前就是这样,很多很多上世纪都出现了概念,但很多程序员却根本就不知道,有时候让人感觉挺悲哀的.[2] 多读书怎么强调也不为过. [1] AndyOram, GregWilson. 软件之道[M]. 人民邮电出版社, 2012. [2] Barr A . The Problem with Software[C]// 2018. |
12
sockpuppet9527 OP @AndyAO #9 那问题来了,现在项目明明可以用实际环境来测试,为什么不用呢?还必须要 Mock 住环境,这不是多次一举?
“发火箭造飞机”这个例子,我的确认可,但拿来和我目前的情况类比,并不合适。 举个最实际的例子,我的依赖库中有大页管理的实现,那现在我在一台测试机上,是可以用到大页的,那我觉得就不需要用 Mock 去模拟,就很多此一举。 |
13
AndyAO 2021-01-28 13:29:16 +08:00
@sockpuppet9527 #12
这真是个常识问题,你可能不太了解,但是相关的资料应该不难找,关于测试的书基本上首先都会讲这个问题. 我有这样的建议,你先把使用 Mock 的好处找到然后给列出来,因为 Mock 是几乎所有单元测试框架中都要用的,可以说那是必备的东西. 我估计谷歌上的资料已经很多很多了,因为我看到的书首先都要讲这个. 当然你们在实践的过程中可能遇到的问题让你感觉特别的无奈,不想写这个东西,然后你对这个可恶的东西有了很强烈的情绪. 这个是人之常情,每个人都会这样,但你想一下 Mock,广泛存在于所有单元测试框架中,这个现实就知道,比起这个东西完全没用来说,更大的可能是用错了,有句话说垃圾是放错的资源,何况这个东西显然不是垃圾. 等你把好处列出来之后 "现在项目明明可以用实际环境来测试,为什么不用呢?还必须要 Mock 住环境,这不是多次一举?"这个问题自然而然就回答了,那么你也就知道了,Mock 真正要解决的问题,如果某些问题的确不适合它来解决,那么你也就不用再费心了. 我也把之前看到的资料稍微整理整理发到这上面来供你参考,也许会有点帮助我也复习复习. |
14
AndyAO 2021-01-28 13:33:19 +08:00
@sockpuppet9527 #12
还有我提到的那个行为测试和单元测试的区别,这个的专业术语应该叫测试的颗粒度问题. 当然软件工程的数据比较混乱,相同概念经常有各种各样的词,但我觉得这个好像还是比较普遍的吧. 我是在写行为测试的时候看到过关于这些问题的表述. 我现在都是行为测试和单元测试都要写. |
15
sockpuppet9527 OP @AndyAO
#14 和行为测试有什么关系,我这里谈的是单元测试。 #13 说实话,看了两遍我都没明白你到底想说什么。我再举个简单的例子,你 alloc 一块内存需要用 malloc,难道你在 UT 里面也要把 malloc 这个函数给 Mock 掉吗? |
16
AndyAO 2021-01-28 13:41:42 +08:00
@sockpuppet9527 #15
说行为测试是为了解释你关于单元测试的问题. 因为单元测试在有些修改的时候是需要舍弃的,你说的那种阻碍了修改的事情,本不应该由单元测试来承担.所以根本就是用错了地方. 如果你这都看不懂的话,那我就不说了,你自己多看点书.资料我也不给你发了. |
17
hitmanx 2021-01-28 13:49:15 +08:00 1
所以看下来自己给自己代码加测试是比较合适的,哪一些是黑盒的功能测试能覆盖到的,哪些需要白盒测试的,哪些需要 mock,写代码的人应该自己心里最有数,甚至哪些功能是不完整的,哪部分代码将来未来是要扩充的。这些可能都对测试的设计会有影响。我也觉得教条地 mock 每一个函数或者让每一行代码都测试到,进而大量的高度耦合然而并没有社么用的测试,其实只会浪费更多的时间
|
18
sockpuppet9527 OP @AndyAO #16
在#15 提的是#13 的问题,你并没有任何正面回答的意思。你“发不发资料”和你“有没有正面讨论”并无关系。 然后关于你#8 和#14 中的“单元测试在有些修改的时候是需要舍弃”的逻辑,我个人的经验是知识因项目而异。 |
19
sockpuppet9527 OP |
20
sockpuppet9527 OP @hitmanx #17 很认同您的观点,不同设计者总是会有不同的想法。 :)
|
21
xylophone21 2021-01-28 13:57:32 +08:00
@sockpuppet9527
weak symbols,这个我理解应该是指把 B.o 里的方法制定为 weak ?但这样 Mock 也只能有一个实现,也就是说对应用例 1,需要 b_foo(1)返回 0,而用例 2 需要 b_foo(1)返回 1 还是很难实现? GMock 大概放狗找了一下。 从 Google Mock 的原理里可以看到,Google Mock 只能对类的虚函数做 mock,并且要求被测代码不能够直接创建该类的对象,而是由外界传进去的。 所以这个可能得在开发阶段就定下规则,比如虚函数,不能直接创建对象等 @feather12315 这个办法倒是很有意思,有成熟的框架吗? |
22
sockpuppet9527 OP @xylophone21 #21
1. weak symbols 是的,正如你所说,但是默认函数都是 weak symbols 的 2. GMock 我只用来过 mock global 的方法,mock class 我没有试过。 |
23
yazoox 2021-01-28 15:03:01 +08:00
c/c++的测试写起来,那是真的痛苦!尤其是给以前的 legacy code 写…
|
24
namelosw 2021-01-28 15:17:46 +08:00 4
写测试是门艺术. 不写测试不好, 大部分项目写不好效果更差. 我觉得重要的就几个方面, 但是更多的需要实践和思考, 特别是在团队中的实践(因为很多写测试在所有人脑袋里的含义都不一样, 很多时候即便测试策略很好, 最终执行也会很差), 你可以结合你的经验思考一下:
一: 测试要和代码一起写. 好的测试都需要适合测试的代码配合(并发, 沙盒, 替换, 生成测试等等). TDD 虽然不是万能的, 但是很容易保证代码是可测的, 即使不 TDD 也要在开发过程中立刻写测试. 功能要一点一点加, 加一点功能写一点测试. 全写完了写测试就会导致各种问题, 因为写代码的时候根本没考虑测试. 二: 粒度相关 测试艺术就在粒度控制上. 粒度大能 cover 的业务多, 但是更慢更不稳定. 粒度小容易写, 跑得也快, 但是不能反应业务, 而且导致代码僵化没法重构. 表面上看起来怎么 trade off 都一样, 但是实际可以有很多 sweet spot. 粒度控制的核心目标就是尽量同时提高对业务的 coverage 且不明显牺牲速度: 提高对业务 coverage 就是增大粒度, 意义是你的测试是对着业务需求写的 - 很多 UT 是对着代码写, 而不是对着需求写, 这样改代码的时候就难免删测试重写, 而对着需求写的好处是 1) 业务不改的时候你也可以重构代码, 不需要改测试, 而且测试还能继续 cover, 这样你就知道重构的时候你没改错 2) 很容易理解和阅读, 标题本身都可以当文档了. 不明显牺牲速度, 即像上面说的提高粒度一般都会牺牲速度, 比如完全端对端就要把数据库之类的全连起来, 特别难搭建, 而且运行特别慢, 还没办法每个测试都清理, 非常不稳定. 但是很多通用组件其实不需要测试, 所以可以让代码 mock 掉这些. 典型的比如你测试一个 web server, 很多技术栈测试的时候会真的把 server 跑起来监听 socket, 那其实就非常浪费, 拖慢了速度还浪费了端口号. 正确的做法是, web server 一般就是经过 middlewares 然后打到 action 上, 那么理想的测试是只把 middlewares 和 actions 跑起来, 跳过真正的 socket 监听(因为真没啥好测的), 这样既能测所有逻辑, 也像所有内存代码一样快(因为 middlewares 和 actions 就是些普通函数). 除了 socket 之外, 最吃性能的就是数据库. 不过数据库比较 tricky… 如果是 Redis 这种其实非常容易 mock. 但是如果是 SQL 这种就很难 mock 了, 如果 mock 基本上等于重写一个 SQL, 一般有几个选项: 1) 硬着头皮 mock repository 或者 DAO, 不推荐, 很多存储和关联的逻辑都被 mock 掉了, 很容易写出错误的测试 2) 用真数据库, 每个测试之后删表, 但是并行没了 3) 用 SQLite 之类的, 但是必须得注意和真数据库的区别, 有时候要躲着某些功能. 或者像.Net Entity framework 之类的提供专用的的 SQL 内存实现就很爽. 4) 给某些数据库做沙箱机制, 比如 Elixir 的 Ecto 用 Postgres 但是速度非常快, 可以参考一下源码. 为什么速度这么重要呢? 因为测试跑一天也跑不完, 就没人跑了. 只在 CI 上跑的测试只能是半个测试, 对开发本身没有益处. 测试一个目的是验收, 另外一个目的是开发过程内协助开发(比如每改一段代码你就能立刻知道刚才写的对不对, 不对可以立刻 debug). 如果测试不能在开发过程里给 dev 帮助, dev 就会不想写测试. 总的目标就是以上, 我们内部把这个叫做写“集成风格的单元测试”. 效果就是测试数量相对少, 不用改, 跑得也不慢还可以并发. 三: 分层相关. 很多 UT 会分很多层, 比如 model 测试, service 测试, controller 测试, integration 测试等等. 很多专业的团队会提测试金字塔, 总得来说就是大量细粒度, 中量中粒度, 少量大粒度, 很多金字塔甚至会有六七层. 其实分太多层效果并不好 - 会出现很多你说的已经测过一遍, 结果又测一遍的情况. 但是反过来如果不测两边又不能保证合起来是对的. 而且分很多层之后, 同样的测试可能要写四五遍... 这样工作量太大, 一定会有人就会跳过某些层写测试, 这样每个人看代码库都不知道应该写哪些层了. 所以我的建议是一两层左右, 最多不超过三层. 这个要在组里面讨论好, 大家就定死一定写, 或者一定不写. PS: 有时候测试有驱动设计的效果, 比如所有人逻辑都写在 controller 里导致很多设计问题, 想把逻辑在 model 里面表达出来, 那么可以定死所有人一定要写 model 测试, 这样逻辑就会慢慢自动回到 model 层里. 但是注意像上面说的弄太多层. UI 或者表现层相关的最后一步可以省掉, 测给代码消费的最外层. 比如你在前端有个 view + redux store, 那只用 action 和 selector 测业务, 因为它们是. UI 可以跳过的原因是因为 UI 是给人用的, 所以很难测且不稳定, 比如发请求按钮变化, 测代码接 promise 就好了, 但是纯测 UI 就只能轮询, 一旦失败就要死等 N 秒 timeout. 四: Mock Mock 仅用于 web server 之类没有业务意义的基础设施, 不要用于业务代码. 不然 1) mock 是错的, 或者某些细节不够真导致测试根本是错的 2) 代码僵化 3) 被 mock 模块变了, 依赖的模块没改. |
25
YouLMAO 2021-01-28 15:33:36 +08:00 via Android
等你的代码一年创造 1 亿美金营收,你就知道 unittest 的含义
|
27
sockpuppet9527 OP @YouLMAO #19 我没任何觉得 UT 不好的意思,我觉得 UT 很好,这里只是谈“过度”这件事情。
|
28
YouLMAO 2021-01-28 16:31:06 +08:00
@sockpuppet9527 你的现状应该远远没达到过度阶段吧, 看看 Apache 顶级项目, 她们其实已经是业界知名但是覆盖率还是挺低的, 比我们家的低很多
|
29
ashuai 2021-01-28 16:42:00 +08:00
为什么我第一感觉觉得这是 2077 [狗头]
|
31
petercui 2021-01-28 17:30:05 +08:00
@sockpuppet9527 UT 不需要设计,只需要产生确定的输入,然后验证一个确定的输出就可以了,如果你动了某个方法,造成了 UT 失败,那么首先需要考虑的并不是去更改 UT,而是要考虑这个方法的改动会不会造成其它组件异常,因为这个方法的行为被改变了,而且对外部系统(调用方)也造成了影响(因为你的 UT 失败了)。
|
32
YouLMAO 2021-01-28 18:21:52 +08:00 via Android
楼主,你描述的硬件驱动,明显属于集成测试了,跟 unittest 没有一毛钱关系,单元测试是不需要任何驱动的
|
33
YouLMAO 2021-01-28 18:22:50 +08:00 via Android
C 建议你下载 gmock 试试,很简单的
|
34
lolding 2021-01-28 20:21:57 +08:00 via iPhone
我们专门做 c 语言自动化单元测试工具,自动生成测试用例,覆盖率基本上都能够达到 100%
|
35
pkookp8 2021-01-28 20:42:49 +08:00 via Android
我一直以为 ut 是用来调试的,出了问题方便调试
更大粒度的模块测试,系统级别的测试才是用来测功能的 有时候很难为了 x==(z+y)的结果返回 true 还是 false 而去重新编译一个非常大的工程,再去重新验证 # x y z 都是临时中间变量,且产生过程比较复杂,输入值很少也很确定 如果有 ut 就方便多了 单元测试发现的问题,通常 review 就能发现。发现不了的问题很多都是边界场景没有考虑全。即使写了 ut 也无法保证功能没有问题 想想 leetcode 动辄几百几千几万个测试用例来验证你的算法 |
36
johnsona 2021-01-28 22:37:22 +08:00 via iPhone
不写 明天就改 写什么写
|
37
petercui 2021-01-28 22:50:37 +08:00
@pkookp8 功能测试只是一个点,但还有更重要的一个点:“重构”,重构后的代码和重构前是否行为一致,就靠测试用例来保证,只要你原有的测试用例能通过,哪怕你这个类已经没有任何一行代码跟重构之前是一样的,也不用担心会影响到调用方。
|
38
ReferenceError 2021-01-28 22:56:53 +08:00
写测试也要有个可行的 plan 排期吧。怎么感觉你在赶测试?
单说期望效果的话,测试覆盖度越全越好只要有时间是没毛病的,无法保证哪个功能一定不会出错。 |