在TDD开发中,私有的方法怎么办?需要测试吗?如果测怎么处理?不测又怎么办?来讨论一下这个问题。
先看一下范围。
如果反过来问,可以澄清楚一下这个问题的范围。如果私有方法是,简单的“小函数”, 比如为表达代码意图而提取的小函数,那么显然不需要测。 如果私有方法是,复杂的大函数上百行, 那肯定会提取一个工具类,然后再去测试。当然这种情况同时需要考虑 类的职责是不是单一。 如果对这个问题还在有犹豫,那么暗示这个函数没有像“小函数”那么简单,也没有像“复杂函数“那么控制不了,复杂度在二者之间。对于这样的中等复杂度的私有函数如何测试?
再看看,如果要测试私有函数,我们应该有哪些方法?
私有方法直接变成公有方法。
这个是最常用的方法,但是副作用用比较大。
- 使的类的接口抽象层次不一致。是的代码和测试代码可读性不好。
- 将里面的实现暴露出去,为后面代码重构和维护带来副作用。
- 测试代码由于 要测试意图,又测实现,使的意图与实现混合,代码不清晰。通过一些奇技淫巧方法来调用私有方法。
比如通过反射方式调用,语言的特性(C# particial, C++ 条件宏)。 这些的确都可以调用私有方法,但是会带来测试代码的复杂度和维护的难度。
这些方法带来的好处与坏处来比,性价比不高,不推荐。 下面看一种如何通过公有接口来测试私有方法的方式。
再看之前Diamond的那个例子, 当前的已经写2个测试用例:
@Test
public void test_characte_A() {
String[] expected = new String[] { "A" };
selfAssert(expected, Diamond.answer('A'));
}
@Test
public void test_characte_B() {
String[] expected = {
" A ",
"B B",
" A ",
};
selfAssert(expected, Diamond.answer('B'));
}
实现代码
public static String[] answer(char c) {
if( c == 'B') {
return new String[] {
" A ",
"B B",
" A "
};
}
return new String[] { String.valueOf(c) };
}
这时候再加第三个case, 测试字母C:
@Test
public void test_characte_C() {
String[] expected = {
" A ",
" B B ",
"C C",
" B B ",
" A "
};
selfAssert(expected, Diamond.answer('C'));
}
对应硬编码的实现代码:
public static String[] answer(char c) {
if( c == 'C'){
return new String[]{
" A ",
" B B ",
"C C",
" B B ",
" A "
};
}
if( c == 'B') {
return new String[] {
" A ",
"B B",
" A "
};
}
return new String[] { String.valueOf(c) };
}
现在发现规律,这个Diamond 是上下对称的,然后进行“merge”,那么我们可以对代码变形。 下面为减少代码,只将 if 里面变化代码列出。
public static String[] answer(char c) {
if( c == 'C'){
String[] top = new String[]{
" A ",
" B B ",
"C C"
};
String[] down = new String[]{
"C C",
" B B ",
" A "
};
return merge( top, down);
}
//....
}
下面是私有方法merge的实现:
private static String[] merge(String[] top, String[] down){
String[] result = new String[5];
System.arraycopy( top, 0, result, 0, top.length );
System.arraycopy( down, 1, result, top.length, down.length-1 );
return result;
}
这个时候测试代码没有变化,还是3个测试用例; 但是代码经过重构变化,提取merge私有方法。这个私有方法是通过公共接口测试了。
下面可以继续对代码进行重构,去除重复。 Diamond 是上下对称的,那么有了上部分(top), 下部分(down)可以根据对称计算出来。继续变形。
public static String[] answer(char c) {
if( c == 'C'){
private static String[] merge(String[] top, String[] down){
String[] top = new String[]{
" A ",
" B B ",
"C C"
};
String[] down = getSymmetry(top);
return merge( top, down) ;
}
// ...
}
下面是私有方法downSymmetry的实现:
private String[] getSymmetry(String[] origin){
String[] reverse = new String[origin.length];
for(int i = 0; i< origin.length; i++){
reverse[i] = origin[origin.length - i - 1];
}
return reverse;
}
这个时候测试代码没有变化,还是3个测试用力; 但是代码经过重构变化,提取downSymmetry私有方法。这个私有方法是通过公共接口测试了。
下面还可以接着去重构每一个行去寻找规律,继续重构。
public static String[] answer(char c) {
if( c == 'C'){
String[] top = new String[]{
space(2) + "A" +space(2),
space(1) + "B" + space(1) + "B" + space(1),
space(0) + "C" + space(2) + "C" + space(0)
space(0) + "C" + space(3) + "C" + space(0)
};
String[] down = getSymmetry(top);
return merge( top, down) ;
}
// ...
}
提取私有函数space(), 同样也在测试不变的情况下,通过公共接口测试该私有方法space。
发现每一行是有规律的,继续将重构,提取一个getLine的方法。
public static String[] answer(char c) {
if( c == 'C'){
int width = 3;
String[] top = new String[]{
space(2) + "A" +space(2),
getLine(2),
space(0) + "C" + space(3) + "C" + space(0)
};
String[] down = getSymmetry(top);
return merge( top, down) ;
}
// ...
}
private static String getLine(int lineNumber){
return space(1) + "B" + space(1) + "B" + space(1);
}
继续重构,一直到所有的行都被替换,真正的getLine 方法就提取成功了。 中间的步骤暂且就略过,感兴趣的可以代码演化同样测试用例没有修改,但是私有方法 getLine 通过公有方法被测试了。
public static String[] answer(char c) {
if( c == 'C'){
int width = 5;
int height = 3;
String[] top = new String[]{
getLine(1, width, height),
getLine(2, width, height),
getLine(3, width, height)
};
String[] down = getSymmetry(top);
return merge( top, down) ;
}
// ...
}
private static String getLine(int lineNum, int width, int height){
String border = space(height - lineNum);
if( lineNum == 1)
return border + "A" + border;
String lineChar = new Character( (char)('A' + lineNum - 1)).toString();
String middleSpace = space(width - 2 - 2*(height - lineNum) );
return border + lineChar +
middleSpace +
lineChar +border;
}
在这小步迭代中提取getLine 方法,这个方法还是稍微复杂的。 但是我们每一次都有测试用例构建的安全网,在这个重构的过程中还是比较安全的。尽管没有显示的单独测试getLine 这个方法,但是一直有测试用例通过公有的接口覆盖其逻辑。编码过程中还是比较有信心的。
后面重构还继续,明显的引入循环,getLine 方法的后面两个参数都可以去除掉等等,继续重构下去,一直到自己感觉良好没有坏味道。 最终的代码在这个地方.
现在复盘,私有方法是如何被通过公有方法来测试的。 可以简单概况为先定义测试用例,然后基于重构一步一步的提取方法。在这个过程中,提取的私有方法,是小步迭代的,每一步都有测试验证的,所以也是安全放心的。 再看一下测试用例, 一开始就有3个测试用例一直没有变化,但是一直持续提供及时的反馈。
如果反过来, 同样的一个思路,先计算上面一部分(top),然后根据先上下对称得到下面部分(down),最后再合并。分别对应三个方法, getLine,getSymmetry,和 merge。 如果同样的思路从下往上去实现(先实现底层方法,然后再集成),这个时候这些私有方法是没办法直接通过公共接口来测试的。 这个又陷入刚开始的问题,私有方法怎么测试?
所以说基于公开接口来测试私有方法,是从上到下,小步迭代基于重构来实现的。
总结
对于私有方法如何测试,思路是多种多样, 下面比较这几种对私有方法的测试方法的优劣。
类型 | 可读性 | 可维护性 | 代码量 | 测试的定位准确性 |
---|---|---|---|---|
将私有方法改为公有 | x | x | v | v |
奇技淫巧 | xx | xx | - | - |
提取工具类 | v | v | x | v |
公有方法来 | v | v | - | x |
对于复杂私有方法如果太过于复杂,需要考虑是不是职责清晰,需要提取一个工具类来做测试。
对于类实现特有的一些私有方法,可以考虑考虑通过公有方法,从上到下基于重构来测试。但是出错,定位没有基于提取工具类的方法的可读性好。这个折中需要根据情况来权衡。
其他两种,会带来代码的可读性和维护行的额外复杂度,不建议。
NOTE:
对于这个题目还有一种叫做 [“Test recycle“的方法](http://claysnow.co.uk/recycling-tests-in-tdd/),也就是持续修改已有的测试用例,使的测试代码和产品代码共同演进的方法。当然也可以用来测试私有方法,有时候还是挺有诱惑力,但是不推荐。修改已有的测试用例,使的测试代码作为最终的安全网没有了。即使最终有了漂亮的实现,中间的过程的测试用例却没有留下来。
如果测试用例看做产品代码的一部分,作为财富一部分,那么这种做法相当于财富被挥霍掉。TDD开发过程中的测试用例是逐步增加的,对应的功能也是逐步增强,也就是一个增量开发的过程, 如同软件逐步长大的一个成长过程。而“test recycle“ 是一个测试用例,持续来刺激代码的演化,如果代码出现regression 很难出提供及时反馈。