编写可测试的代码
软件功能的实现绝不唯一;不同实现会有不同的效率和效果。效率是获取结果的付出(时间、空间);效果是指结果的好坏(更加符合用户期望)。
本文主要关注如何获得好的效果。'可测试的代码'并不是之前所讲的'功能',而且任何可运行的代码都是可测试的。
之所以提出'可测试的代码',是因为有些代码虽然可以测试,但是对于结果却无法预知,也就是无法评估效果;那么这个代码实现在作者看来就是不可测试的。
也就是:'可测试'的衡量标准是测试所使用的断言;可以断言的代码就是可以测试的代码;那么衡量效率和效果的依据即是代码的测试是否满足作为效率、效果指标的断言。
下面是我在新员工培训中所使用的一个样例:
这是一个填(数)字游戏的核心功能,设计一个类,实现如下的功能:
1,这个类记录了一个n*n的方格棋盘上的数字;
2,每个方格可以填入一个数字,数字的取值范围:不小于1,不大于n的正整数;每个方格中的数字与同行、列的格子中的其他数字不同;
3,随机填充n*n / 2个方格;且这些方格的位置和方格中的数字需要具备随机性
4,未自动填充的方格可以填入数字
该类被设计由游戏界面类调用。
提供单元测试,所有该类非private方法都要至少有一个测试方法。
随机填充的方法的测试方法至少要测试n=5,10,99三种情况。
注意,本次训练的目的:编写可以测试的代码
衡量'可以测试'的原则:
*代码的逻辑分支都可以到达
*代码的结果是可以预知的
*算法的执行效率是稳定的,执行时间是可以估算的。
假设需求就这么多,除此之外的功能可以不考虑。我设计这个测试训练的目的有以下几点:
A,先填充完整个棋盘,再随机置空方格--逆向思维
B,观察一个可行的填充方案,然后寻找解决的路径 -- 数学归纳法
C,如何构造类来包装算法 --类设计
D.通过单元测试,验证算法的严谨 --编写可测试的代码
E.编写测试用例,合理使用断言 -- 编写可测试的代码
F.单元测试可以执行任意次数,执行前不需要其他手工辅助 --测试示例的设计合理
G.枚举所有的组合,从而得到填充方案--优化算法
H.通过搜索找到已有算法实现,并看懂、理解 -- 学习能力
本文的主题是'可测试的代码',因此对于上面的目的,本文仅通过这个'数独'游戏核心类的实现,说明作者认为的可测试代码是什么?
第一个实现:通过搜索引擎,查询现有的实现算法,这个是对能力要求最低的实现,也是我设计的环节,所谓的'引子';具体实现请参考附件ShuDu.java。感谢我曾经的一位同事找到并优化了该实现。它的主旨就是随机生成一个尚未尝试过的数,填入当前非空的格子,判断是否合适,尝试过所有的数字后,则更换前一格中的数字。这里不引述全文(请通过搜索引擎自己查找),贴一段核心代码:
//生成数字
for(int i = 0;i < this.size;i++){
//尝试填充的数字次数
int time = 0;
//填充数字
for(int j = 0;j < this.size;j++){
//产生数字
n[i][j] = generateNum(time);
//如果返回值为0,则代表卡住,退回处理
//退回处理的原则是:如果不是第一列,则先倒退到前一列,否则倒退到前一行的最后一列
if(n[i][j] == 0){
//不是第一列,则倒退一列
if(j > 0){
j-=2;
continue;
}else{
//是第一列,则倒退到上一行的最后一列
i--;
j = this.size-1;
continue;
}
}
//填充成功
if(isCorret(i,j, 0)){
//初始化time,为下一次填充做准备
time = 0;
}else{ //继续填充
//次数增加1
time++;
//继续填充当前格
j--;
}
}
}
这个实现在n=5,10时,都表现非常出色,但是到n=99时,基本上就歇菜了。原因是该实现的算法会出现'憋死'的情况,即算法决定了并不是每次都能获得正确的结果。
这里'正确的结果'就是本文开始所提到的'效果'的一个衡量指标。我不会选择一个不能每次提供正确结果的算法。因为玩该游戏的用户不能接受运行该游戏,却不知道这次运行能不能玩的尴尬。所以这是一个效率尚可、效果较差的实现。之所以称该实现为'引子',因为它恰巧为我提供了一个反例。该实现优美而直接,但却不是个好选择--这个实现是个不可测试的实现,因为我完全无法断言运行结果。
第二个实现,针对算法'憋死'的问题,一种解决办法是罗列所有的可能,然后生成合适的结果。对于本例,罗列所有1到n个数字的可能序列,共有n!个。然后随机选择一个序列作为第一行,遍历序列,找到符合条件的第二行,依次类推,从序列中找出满足要求的n-2行即可。这个算法的实现是简单而直接的,而且保证每次运行都会有结果。从这个角度看,是一个'可测试'的实现。但是通过测试发现,当n=30时,默认配置的32位JVM就会出现内存溢出;而且运行时间也长的难以忍受。显然,这是一个效率奇差、效果一般的实现。虽然可测试,结果可预期,但是其效率显然不能满足测试要求,所以这个实现是个'虚拟的'可测试。因为我完全无法断言多久可以获得n=99的结果。
第三个实现,针对执行效率奇差的问题,可以减少循环的次数。按照这个思路,只有当数字变化是有规律的,通过规律性减少循环判断的次数。寻找规律性的办法,我认为就是归纳法。下面描述一下我使用归纳法的过程:当n=2时,给用户的展现只有如下的情况
1 2 或者 2 1
2 1 1 2
这两者是同一种组合。所以,假设第一行是1, ... n,那么第二行是2,..n,1,直到第n行是n,1, ... n -1;其满足规则。
那么当n + 1时,第一行变成1,...n, n + 1,第二行是2, ...n, n + 1, 1,那么第n行是n, n+1,1,... n- 1;第n+1行是n+1,1,...n,从任意一列m看,其序列是m,m+1,...n, 1, ... m-1,不存在重复的数字,所以这组序列符合规则。这个算法中,相邻数字间的差值可以是从1到n-1之间的与n没有除了1之外的公约数的任意数字。
OK,通过使每行的两个数之间的差值(绝对值)随机以及每个序列出现在行数随机,也具有一定的可玩性。这个实现的测试表现优异,即使是n=99时。当然每次运行都获得了正确的结果。所以这个实现是'可测试'。
这个算法有什么缺点吗?还是很明显的,只要用户发现了规律性,那么可玩性即丧失了。那么还有什么更好的方法吗?
我能力所限,无法给出效率也优、效果更好的实现。追求更优的算法超出了该练习的范围。在工作中,有些最优问题并不是经典问题。如何求解这类问题,如何分析问题,寻找解决方案是需要程序员的创造力。这是我设计该练习的初衷。它给予我的反馈也让我受益良多。
我的素养有限,设计的练习可能也不全面或者不对症,欢迎指正。