记得以前曾经和一个tw出来的老兄一起共事过一小段时间。当时问他们组的测试情况,据说都是100%的覆盖率。说实话,心里挺惊讶的。

我不是一个懒于写测试的人。实际上,通过是否便于测试来判断一段代码的设计优劣已经几乎是本能了。可是,我发现连九成的覆盖率对我来说都是几乎难以企及的目标。

你的代码不管怎么重构,总有那么一些角落要连数据库,写文件,从信用卡里面划钱这些恶心吧唧的东西吧?这些应该都可以集成测试,但是单元测试我基本上就是绕过了。

这也罢了。那些java bean的getter/setter你难道也要测?一行的throw new UnsupportedOperationException()难道也要测?

最近和同事有一个有趣的讨论。原因是这么一个简单的函数:
void copy(Source source, Dest dest) {
  dest.setFoo(source.foo());
  dest.setBar(source.baz());
  // 不拷贝baz,baz的处理在别的地方做。
  // 还有其他的乱七八糟的拷贝
}


我以为这么一个简单地几乎代码本身就相当于需求描述的东西没必要费劲写单元测试了。但是发现很多同事不是这么看,不管从tdd的角度还是纯朴地希望测试所有能测的东西的愿望都促使大家异口同声地说:测!

那么,怎么测呢?这里面除了要测试foo和bar拷贝了,还要测试baz没有拷贝。大概是这么写吧:
Source source = new Source();
source.setFoo("foo");
source.setBar("bar");
source.setBaz("baz");
Dest dest = new Dest();
copy(source, dest);
assertEquals("foo", dest.getFoo());
assertEquals("bar", dest.bar());
assertNull(dest.baz());


可是那么简单的原始代码就要用这么一堆杂乱的测试么?怎么想怎么不值啊。


我自己有这么一个小理论:
引用

所谓“测试”,就是给定一个生产系统P,我们制造一个测试系统T,T的复杂度小于P,而T和P的关系满足以下式子:

T passes + T is correct => P is correct

根据“bug永远存在定律”,我们永远无法宣称一个给定系统是完全正确的。但是,我们却可以猜测,一个简单的系统会比一个复杂的系统更容易正确。

而T passes是自动运行的,所以我们就把保证P的正确性这个难题转化为保证T的正确性这个相对简单的问题了。


我这个小理论的前提是,T比P简单。所以对getter/setter这种,因为我无法写出比生产代码更简单地测试代码了,所以测试的有效范围到此结束;而对一些仍然比较复杂的测试代码,这个迭代会继续下去,写额外的测试代码来测试这个测试代码,直到我可以把P递减到T1, T2, ..., Tn,最后无法写出一个更简单的T来测试Tn,循环结束。

用这个理论来衡量,上面的那个测试代码明显比生产代码复杂,可读性也差。所以在我看来属于不经济的。

当然,测试什么“没有作”目前最好的方式其实不是这种state-based testing,而是interaction-based,比如用EasyMock。当然,EasyMock的expectation语法写出来会是象咳嗽一样地:
expect(source.getFoo()).andReturn("foo");
dest.setFoo("foo");
expect(source.getBar()).andReturn("bar");
dest.setBar("bar");
// 不用管baz了,所有我们没说的一概不允许发生!

还是不如生产代码看着舒服,其复杂度大致是原来的两倍上下。

好吧,这只是EasyMock个人的问题。让我们来乐观一下,假设EasyMock可以被改进,支持直接用
dest.setFoo(source.getFoo());

这种更直观的方式来写expectation,那么上面的测试代码就可以简化许多了,理想情况下,我们就可以把它写成:

dest.setFoo(source.getFoo());
dest.setBar(source.getBar());
// do not copy baz as it is taken care of separately
// other set(get())


嗯。漂亮许多。不再那么唧唧歪歪了。可是,你发现了什么没有?这个测试代码可以直接从生产代码拷贝过来!

也就是说,最最好的情况下,我们不过是在重复小时候淘气被老师惩罚的惨无人道的抄课文!

记得我是一个老实孩子,作弊都不会,老师让抄五遍的,我偷了妈妈的复写纸还是老老实实地写了三遍

不过现在不同了,我们都是聪明的程序员,既然可以直接copy-paste的,那也就没什么麻烦的了。不管多少字,都是两下按键潇洒搞定。可是,这样一来,测试就没有意义了呀,搞什么搞嘛。

你也许会说:你用复写纸当然起不到作用了,老师是让你用人肉来写的,这样才能锻炼书法,记忆力,毅力,恒心,专心,责任心,爱心等等的嘛。

或者比如说你登录一个帐号,要输入密码的时候,不是要同样的密码用人肉敲两遍,而不是拷贝的么?

是哈,看来EasyMock不做得象复写纸那么方便还是有道理的,头悬梁椎刺骨,吃得苦中苦,方为人上人,设计的真是人性化啊。

不过,我还是总觉得有那么一点别扭。能(假设能)拷贝的非要装作不能,拿手去重新写一遍总是觉得有点象苏哥,一边说自己是救世主,一边人家随便一弄就搞死了那么的不通。要说多写一边能帮助避免错误,那为啥不写5遍?(恩,也许NASA这种关键部门的关键程序就是让程序员默写50遍的──这些程序员肯定是小学留级10年以上的问题学生来的)

今天,又遇到一个问题,想了想,觉得我要是提出来,肯定还是要被大家批判得体无完肤的。生产代码里面需要调用某个Service,需要传递一个固定的map进去:

Map<String, String> params(int id) {
  return ImmutableMap.of(
    "m", "Get",
    "id", id
  );
}

void doFoo(int id) {
  service.call(params(id));
}


然后在mock测试doFoo的时候,我就想,我是这样呢?
service.call(ImmutableMap.of(
    "m", "Get",
    "id", id
  ));

replay();

doFoo(id);


还是这样呢?

service.call(Foo.params(id));

replay();

doFoo(id);


后者如果Foo.params()写错了,测试也就错了。

又或者,我应该写一个Foo.params()的测试?这样?
assertEquals(ImmutableMap.of(
    "m", "Get",
    "id", id
  ), Foo.params(id));


也许,通过抄课文,重复输入密码这些高科技手段,我的程序质量会不知不觉地提高?或者至少下次别人维护我的程序的时候,不小心改错一个地方,测试会报错。而且,当他看到我的测试代码和生产代码长得一模一样,就会发出会心的微笑:根据ajoo的人格,他肯定不是用复写纸作弊地。看来他的目的确实是这个了,写了两遍都是一模一样的。闹得闹得。
评论
liusong1111 2008-06-01
andy54321 写道
明白了一点了

即是说:在测试中,我们应该写出复杂度小于被测代码复杂度的测试代码,
否则,可能在测试代码中可能出错概率大于原来,可是有个问题,测试代码
是否也需要测试代码呢,这岂不是子子孙孙无穷尽矣?


测试代码的复杂度有多高,不应该类比被测代码的复杂度,要从“必要复杂度”上分析。
当然,最终可能引起的出错概率,也应该计入成本。
赞同ajoo老大前一个回复中提到的,编写测试的价值,更多体现在维护阶段,它自动、高频度以及可量化的保证了变更中的质量,从而降低了维护成本。但,多出了测试代码的维护成本。又是个权衡。
如果仅仅是重复,增加这种“非必要复杂度”,不会带来任何价值,何必呢?

第二个问题,测试代码是否也需要被测试呢?
看假定。有测试的代码,其质量假定由测试代码保证,测试代码的质量由人来保证 -- 直观推论:保证测试代码正确性的成本 必须低于 保证真实代码正确性的成本,或者在大的角度有一个复杂的公式解释它。
好像与andy54321前面提的测试代码复杂度有关系,没想清楚,请各位释疑。

之前没有测试的代码,其正确性就是由人保证。

有没有不需要人承担责任的可能呢?
不现实。
人需要在哪个层面承担责任最佳呢?
俺木想明白。。。

--
跑题的:
程序中的Exception,是程序员能预期到的,更像是一种更自由的控制语句-返回值。
程序的bug是程序员预期不到的,不能用Exception表达,而应该用testcase避免。
andy54321 2008-05-31
明白了一点了

即是说:在测试中,我们应该写出复杂度小于被测代码复杂度的测试代码,
否则,可能在测试代码中可能出错概率大于原来,可是有个问题,测试代码
是否也需要测试代码呢,这岂不是子子孙孙无穷尽矣?
ajoo 2008-05-31
Elminster 写道


这个事,放我手里就把单元测试跳过了。这一块我的观点和你的理论接近,如果测试代码比产品代码还要复杂难懂,那我们只是在做无用功。而且我也不相信同样的东西手工输入两遍能够提高什么见鬼的软件质量,如果这个逻辑成立的话,我们就应该去写和具体代码详细程度差不多的文档,应该给每一行代码都加上详细的注释,象这样:“这里是一个 if 语句,它判断条件 XXX 是否成立,如果成立则 ……”。

还是有区别的。parity test的手工输入两遍是有自动化测试作为保障的。如果你两遍输入的不一致,是要报错的,就像输入两遍密码一样。

所以要说能否提高质量,检查错误,我觉得还是有那么一点的。只是我觉得:
1,这个纠错的好处比较微小,不值得付出的额外代价(额外的更复杂难懂的测试代码,你总要维护的吧?)
2,增加以后修改的代价,本来只要改一个地放的,现在要改两个地方。重复代码不管放在哪都是坏味道,即使是parity test。
Jacky-Q 2008-05-30
这事闹得,确实烦死人.测试代码写得比源码还复杂,极其伤害情绪.
JerryZheng 2008-05-30
不过呢,要求在这种地方加上单元测试还是有一个很具说服力的理由的:测试覆盖率指标没法区分哪些地方是应该覆盖的,哪些地方是上面这种无用功。如果允许在这类地方跳过单元测试,你很可能需要降低覆盖率达标的标准,同时你必须仔细地分析没有覆盖到的地方究竟是“啊,让我们合理地跳过无用功”,还是“靠,这个重要的 case 居然都没有测试”。如果你要求即使是这种地方都要补上单元测试,那么你可能只要简单地看一下覆盖率有没有过 90% 就知道是不是存在大问题了。


这好像和把代码行数做为一个很重要的考核指标类似。。。有可能鼓励团队做这种无意义的ut。我觉得这种指标毫无意义,应该把重点放在设计可自动执行的功能完备性测试脚本上。而这种脚本的维护成本不应超过code成本的50%。
Elminster 2008-05-30
ajoo 写道
记得以前曾经和一个tw出来的老兄一起共事过一小段时间。当时问他们组的测试情况,据说都是100%的覆盖率。说实话,心里挺惊讶的。


我就更惊讶了。在微软的时候,周围接触到的几个项目测试覆盖率都在 75% 到 85% 之间,这是指所有测试加在一起之后的code block 覆盖率情况。100% …… 很惊讶。你有没有问他这 100% 是指对函数的覆盖?还是对 code block 的覆盖?还是对 branch 的覆盖?JE 上 TW 的朋友不少,进来说说?

ajoo 写道
最近和同事有一个有趣的讨论。原因是这么一个简单的函数: ……


这个事,放我手里就把单元测试跳过了。这一块我的观点和你的理论接近,如果测试代码比产品代码还要复杂难懂,那我们只是在做无用功。而且我也不相信同样的东西手工输入两遍能够提高什么见鬼的软件质量,如果这个逻辑成立的话,我们就应该去写和具体代码详细程度差不多的文档,应该给每一行代码都加上详细的注释,象这样:“这里是一个 if 语句,它判断条件 XXX 是否成立,如果成立则 ……”。

不过呢,要求在这种地方加上单元测试还是有一个很具说服力的理由的:测试覆盖率指标没法区分哪些地方是应该覆盖的,哪些地方是上面这种无用功。如果允许在这类地方跳过单元测试,你很可能需要降低覆盖率达标的标准,同时你必须仔细地分析没有覆盖到的地方究竟是“啊,让我们合理地跳过无用功”,还是“靠,这个重要的 case 居然都没有测试”。如果你要求即使是这种地方都要补上单元测试,那么你可能只要简单地看一下覆盖率有没有过 90% 就知道是不是存在大问题了。

PS:收站内短信。
JerryZheng 2008-05-29
引用



我一直以来有两个困扰:
1. 测试编写方式 - 怎么测
理想情况下,testcase应该用类自然语言,"描述/表达"而不是"编写"。一个强于描述的语言或框架会更有力。
另外,执行环境的模拟也是麻烦事,比如数据库数据的准备。一个强于模拟、强于对环境依赖抽象的语言或框架会更有力。
现实有障碍,归结起来还是实现限制思路。象Eiffel宣称的Design by Contract,没准以后会出现一个真正"Design for 靠谱"的那啥。

2. 指导方法 - 测什么,目标
如ajoo提出的。
我提另一种情况,比如,一个方法中除了正常逻辑之外,有一行额外代码,向一个八杆子打不着的数据库表中插了条记录 - 从而有可能影响其它功能,但不破坏自身功能。
如何用testcase保证这种情况不出现?或者,是不是它应该保证的?
现实是,异常情况不是个封闭区间。

java好久不用了,就不参与具体情况的讨论了。

小盆友,闹得闹得。


闹得,环境的模拟是大问题,有时候还不如放在集成环境里测,我有一个方法,先连数据库,再调了一把corba服务,最后往一个socket server发了个消息,这个怎么模拟? 。。。
“测什么”源于设计,如果一个方法没有满足或超出设计目标,ut也不大能检测的出来,因为ut是来自于design的。
JerryZheng 2008-05-29
TDD:设计一个测试来自动化地保证一个功能的正确性?
nihongye 2008-05-28
ajoo的困惑,读后感觉很爽.这些细节,测试很烦,不测呢,魔鬼往往就出现在被忽略的地方。
对get set的测试,测试与源代码互为镜像,也是个不错的纠错手段,就是人肉了点。
Quake Wang 2008-05-28
我觉得这是单元测试的粒度问题,假设copy这个函数被另外一个功能(函数名是foo)调用,那么你在做foo的单元测试时候有2种选择:
1. mock copy函数的调用
2. 实际使用copy函数的调用

对于情况1,需要ajoo文中提到的测试代码来保证覆盖率
对于情况 2,也可以覆盖到copy函数

在相同的测试覆盖率下面,方法2的测试代码会比1简单,成本要小,我会选择2
liusong1111 2008-05-28
引用
你也许会说:你用复写纸当然起不到作用了,老师是让你用人肉来写的,这样才能锻炼书法,记忆力,毅力,恒心,专心,责任心,爱心等等的嘛。

引用
也许,通过抄课文,重复输入密码这些高科技手段,我的程序质量会不知不觉地提高?或者至少下次别人维护我的程序的时候,不小心改错一个地方,测试会报错。而且,当他看到我的测试代码和生产代码长得一模一样,就会发出会心的微笑:根据ajoo的人格,他肯定不是用复写纸作弊地。看来他的目的确实是这个了,写了两遍都是一模一样的。闹得闹得。


课文里的东西,不管对的还是不对的,人肉抄5遍(不论当时有没有过大脑),回头再看也都又顺眼又亲切了。
也对得起含辛茹苦把我们培养成听话好孩子的老师了。

ajoo老大的离经叛道或称"富想像力创造力",与大量使用代表当时最先进生产力的复写纸不无关系吧?

TDD值得夸耀的是把人肉测试变成自动测试,如果编写过程本身又变成体力活,不得不重提这个怀疑:是成本的降低,还是成本的转移。
毕竟没有技术含量、充斥重复代码的活,对技术人员来说,是要遭BS的;如果由此造成敏而不捷,管理层也不会高兴。

我一直以来有两个困扰:
1. 测试编写方式 - 怎么测
理想情况下,testcase应该用类自然语言,"描述/表达"而不是"编写"。一个强于描述的语言或框架会更有力。
另外,执行环境的模拟也是麻烦事,比如数据库数据的准备。一个强于模拟、强于对环境依赖抽象的语言或框架会更有力。
现实有障碍,归结起来还是实现限制思路。象Eiffel宣称的Design by Contract,没准以后会出现一个真正"Design for 靠谱"的那啥。

2. 指导方法 - 测什么,目标
如ajoo提出的。
我提另一种情况,比如,一个方法中除了正常逻辑之外,有一行额外代码,向一个八杆子打不着的数据库表中插了条记录 - 从而有可能影响其它功能,但不破坏自身功能。
如何用testcase保证这种情况不出现?或者,是不是它应该保证的?
现实是,异常情况不是个封闭区间。

java好久不用了,就不参与具体情况的讨论了。

小盆友,闹得闹得。
发表评论

提醒: 该博客已发表在公共论坛,博客所有留言会成为论坛回贴,留言请注意遵守论坛发贴规则

您还没有登录,请登录后发表评论

ajoo
搜索本博客
最近加入圈子
存档
最新评论
  • SQL 小技巧
    第三个问题,先写出代码来吧。等有点时间再解释一下。第四个问题其实可以照猫画虎的: ...
    -- by ajoo
  • SQL 小技巧
    第一个问题是我在维护一个金融分析软件的时候碰到的。原来的那位老兄正儿八经地用一个 ...
    -- by ajoo
  • SQL 小技巧
    效率没问题。实际上一般的query效率都在查询上,至于对查询结果的计算,代价基本 ...
    -- by ajoo
  • Not Convinced about Java ...
    最讨厌所谓的魔法了,调试的时候能让人吐血。
    -- by aninfeel
  • SQL 小技巧
    ajoo 写道Readonly 写道问题一,经过google得到一用sum,lo ...
    -- by Readonly