偶然想起@jeffz_cn在twitter上问:“私有方法真的不应该单元测试吗?为什么?我觉得有的组件只是逻辑复杂一些,因此会提取私有方法,并且测试这些私有方法的逻辑。如果把这些内容统统从外部“注入”,这样私有的逻辑就变公开了……但是这样难道没有过渡设计的味道吗?”。
然后就想起来我在项目中推动单元测试的经过。觉得还是应该总结一下比较好。
先说现状 (下面的数据我现在无法核实,但是,应该和实际值误差不大)
我目前负责的项目,有代码200K+,控件产品,尤其是Grid控件产品的代码复杂度远比应用程序的产品复杂度高。因为功能级的耦合度就很高。因此,我认为我的产品的复杂度应该相当于普通应用程序500K+的水平。
目前单元测试有1300+。这些单元测试主要是自5.1和6.0阶段引入的。对遗留代码的单元测试很少。这两个阶段添加和修改的代码应该在130K+。(呵呵,看到这里你一定觉得数据有问题。呵呵,确实看起来有问题。但是,细节这里就不能多说了。)
目前的单元测试代码覆盖率应该在20%~25%之间。
目前单元测试集成在每日构建中。至今没有发现单元测试失败的情况。(这一点很费解,目前归结为狗屎运)
再说经验
1. 单元测试应该在物理设计阶段进行规划,而不是完成代码后补单元测试。
2. Mock类库一般情况下是鸡肋
3. 对已有代码编写单元测试的难度非常高
4. 当单元测试很多的时候,组织和命名会比较有挑战。
5. 目前很少遇到单元测试影响重构的情况。
6. 单元测试对重构的帮助不如预期
7. 目前的现状下,很多平台的限制,使能够单元测试的部分很少。
再说想法
1. 单元测试可以作为开发Leader掌控设计的一种工具
2. 单元测试可以帮助开发人员设计出更好的结构
3. 单元测试不需要对private成员进行。
-=-=-=-=-=-=-=-=-=-=-=-不明真相围观的分割线-=-=-=-=-=-=-=-=-=-=-=-=-=-
好,接下来在一个一个展开来说。
1. 单元测试应该在物理设计阶段进行规划,而不是完成代码后。
实践告诉我,单元测试是需要良好的设计来支撑的。一个耦合度很高的模块几乎没有办法进行单元测试。我曾经几次相对已有的代码进行一些重构来支持单元测试。最终都放弃了。因为对这些耦合度很好的模块的重构总是会引入一些不可预期的问题。最终投入都要远远超过我的预计。因此,我得出的经验是:单元测试需要在物理设计时期就思考所涉及模块的可测试性,为了可测试性,需要对设计进行一些调整。往往这种调整都会使设计更好。因为,耦合性和可测试性是成反比的,因此可测试性越高,也就证明耦合性越低。低耦合是目前大家已经公认的良好设计的标准。
2. Mock类库一般情况下都是鸡肋
我在开始推动单元测试的时候就详细的研究了Rhino.Mocks类库。当时也被它强大语法能力所折服。并且实际将该类库应用在了我们项目的单元测试中。可是,过了一段时间后,当我再次需要使用Mock对象的时候。我才发现,我自己写一个Mock对象的成本其实非常低。远低于学习Rhino.Mocks抽象的语法的成本低。因此,我建议你除非能够确认你每天(至少每周)都要用到Mock对象。否则,建议不要使用Mock类库。
因为,Mock类库的接口设计往往和我们开发人员(尤其是静态类型语言开发人员)的思维方式不一致。一段时间不用这些类库的时候,你就会忘记他们抽象的语法。就需要再付出时间去学习他们的语法。但是,对于一些特定的测试场景,编写简单的Mock对象的成本本身就非常低的。往往5分钟就可以写出来自己用着很爽的Mock对象。
但是,不推荐使用Mock类库,不等于你不需要学习和了解Mock类库。因为学习他们的接口会对你自己设计Mock对象非常有帮助。
3. 对已有代码编写单元测试的难度非常大
因为我们做的是控件产品,在兼容性方面的要求很多时候会很苛刻。有时候一个产品发布之后,发现了Bug,下一个版本也要保证这个Bug原封不动的表现在那里(这时候,我们会说这是我们产品的一个Design)。因此,对代码的重构就会成本很高。因此,要想在不破坏原有结果的情况下进行单元测试的难度就非常大了。这一点,也许有我们产品的特殊性所在。但是,我觉的目前现实中的很多项目其实和我们的项目的要求还是很像吧。
4. 当单元测试很多的时候,组织和命名会比较有挑战。
我一直没有建立起来一套好的单元测试命名体系。目前在项目中的组织方式是:两个平行的工程,产品工程使用InternalVisibleToAttribute为测试工程提供Internal成员的访问权限。两个工程保持相似的组织方式。但是,当一个被测类型很庞大的时候,测试代码就很难组织好了。
5. 目前很少遇到单元测试影响重构的情况
不好的单元测试或过度测试都会对重构带来不好的影响,在我参与的上一个项目中就出现过这种情况。当时,项目突击了一段时间的单元测试。硬任务,每人必须写nnn个单元测试。后来,产品升级的过程中,就不断的删除原有的单元测试。那是因为后来没有人将单元测试作为指标了。否则,可能很多有价值的重构都会不做了。因为修改单元测试太费劲了。这一点对我造成了阴影。以至于我在当前项目的前期没有很高调的推动单元测试。这是这个项目过程中我最大的遗憾之一。
也许是因为,单元测试覆盖率较低;也许是因为我们没有拿单元测试再作为指标,因此大家写的单元测试的质量更高了。总之在当前这个项目的升级中,似乎很少发现维护单元测试付出较大的情况。
6. 单元测试对重构的帮助不如预期
正如前面所说,我目前负责的项目中,较大的重构发生次数并不多。小规模的重构中确实有单元测试帮助我发现问题的情况。但是,远不像我的预期那么多。这一点,应该说和单元测试的覆盖率较低有关。
7. 目前的现状下,很多平台的限制,使能够单元测试的部分很少。
虽然我很有意识的推动单元测试,并且在实际开发中使用单元测试。但是,目前的情况,在WinForm平台下的开发中进行单元测试的桎梏还是很多。也许和我们的产品特性有关,实际过程中,我经常发现,能够测试的代码不是那些经常出问题的代码。而经常出问题的代码,往往因为和平台关系太密切,而无法切割出来进行单元测试。
ASP.NET MVC在一开始设计的时候就考虑了可测试性,因此,这一方面应该更好一些。但是,至少我目前没有看到微软在其他平台下的可单元测试方面的努力。这是我在使用单元测试过程中最郁闷的地方。
-=-=-=-=-=-=-=-=-=-=-=-不明真相围观的分割线-=-=-=-=-=-=-=-=-=-=-=-=-=-
以下最后两点属于我的想法,目前的项目中因为前面的一些约束,导致我还无法证明我的想法是正确的或错误的。列在这里也仅供大家参考了。
8. 单元测试可以作为开发Leader掌控设计的一种工具
和聪明的人在一起工作的最大困难就是你没有办法控制项目的设计。聪明人并不一定出好的设计。但是,你又没有办法说服他采用你的设计。我觉得,单元测试是一个开发Leader掌控设计质量的很好的工具。因为它可以成为一个简单的指标:“你别给我说你的设计有多么好,如果你的设计不可测试,那么抱歉,你不能放入产品代码。”,反过来说,如果你的设计可测试,那么意味着,即使你的设计再烂,它也是可以替换的。总有一天,我会把它从产品里面干掉的。
说说而已,其实大多时候,我也不确定我的设计是好的。但是,我相信,可测试≈低耦合≈好的设计。我相信,当项目复杂到一定程度的时候,建立一些这样简单粗暴可测量的规矩,对产品的健康发展很有帮助。
9. 单元测试可以帮助开发人员设计出更好的结构
因为那个简单粗暴可测量的规矩,迫使开发人员降低自己设计的耦合度。从而产生更好的设计。
-=-=-=-=-=-=-=-=-=-=-=-不明真相围观的分割线-=-=-=-=-=-=-=-=-=-=-=-=-=-
最后,来和老赵探讨一下他的问题。我的观点是:
10. 单元测试不需要对private成员进行。如果需要,那么抽象Strategy类。并对Strategy类进行测试。这个不属于过度设计。
因为,我认为需要测试的方法一定具有以下几个特点中的至少一个:
- 它有出错的可能。
- 它具有的复用的可能。
- 它具有变化的可能。
对于第一点,我认为应该是可以通过对public成员的测试来完成对该private方法的测试的。而二三两点,正是抽象的用武之地。抽象的重要目的就是在封装变化和复用。如果这个函数具有了变化和复用的可能性,我们就应该将它抽象为一个独立的对象,并且对他进行测试。这是一个更好的设计,而不应该归入过度设计的范畴。
如果不符合上面的二三两点,我觉的对这个private成员的测试就属于过度测试的范畴了。是应该杜绝的。因为,你的测试代码很可能没有起到保证质量的作用,而是成为了将来重构的桎梏。因为,理论上重构的过程不需要保持私有成员的行为不变。但是,你的单元测试又要求私有成员的行为不变。这个其实就是我上文中提到的“单元测试影响了重构进行”的情况。