单元测试有毒

最近想对我们的单元测试做一下总结,楼主在日常工作中写了不少单元测试,但有些概念和用法并没有刨根问题的去追寻,研究。于是把一些不清晰的概念输入到google中来寻找答案,发现了几个不错的帖子,从中学到了东西,也发现了问题,于是忍不住又翻译了一把,和大家分享,如有错误,敬请指正。

我们所做的产品测试包括了下文所说的软件测试词汇表中的大部分,也就是“单元测试”,组件测试,系统测试,集成测试,压力测试和验收测试。开发团队成员做的

者参与的是“单元测试”,集成测试。这里的单元测试我加了引号是因为看完下面的文章,我发现我们所做的单元测试并不是严格意义上的单元测试,叫功能测试比较恰当。下文所说的功能测试遇到的问题在我们的实际项目中也遇到了。希望日后有机会改进。

另外这篇帖子的标题叫做evil unit testing,这里的evil是有害的意思,但是通读这篇博客,并没有讲单元测试是有害的,可能作者的意思是把功能测试当作单元测试的想法是有毒的吧。

好了,原文链接:

http://www.javaranch.com/unit-testing.jsp

1. 你做的是单元测试么?

我看到过至少6个公司因为他们有“单元测试(unit test)”而满脸自豪。而我们看到的是这种“单元测试”结果会是一个麻烦。其他人讨论单元测试有多么伟大,但是它确实变得让人痛苦不堪。这种测试需要45分钟才能跑完,还有你对代码只做了一点改动,但却破坏了7个测试用例”。

这些家伙用的是一堆功能测试(functional test)。他们掉入了一个流行的思维陷阱,认为只要是使用Junit来运行的测试用例,就必须是单元测试。你只需要一点点词汇量,90%的问题就都能解决。

2. 软件测试词汇表

  • 单元测试(unit test**)**:

可测试代码的最小的一部分。通常是一个单一的方法,不会使用其它方法或者类。非常快!上千个单元测试能够在10秒以内跑完!单元测试永远不会使用:

  1. 数据库
  2. 一个app服务器(或者任何类型的服务器)
  3. 文件/网络 I/O或者文件系统
  4. 另外的应用
  5. 控制台(System.out,system.err等等)
  6. 日志
  7. 大多数其他类(但不包括DTO‘s,String,Integer,mock和一些其他的类)

单元测试几乎总是回归测试套件(regression suite)的一部分。

  • 回归测试套件(Regression Suite**)**:

能够立刻被运行的测试用例的集合。一个例子就是放在一个特定文件夹中的能够被Junit运行的所有测试用例。一个开发人员能够在一天中把一个单元测试回归套件运行20次或者他们可能一个月跑两次功能测试回归套件

  • 功能测试(Functional Test**)**:

比一个单元要大,比一个完整的组件测试要小。通常为工作在一起的的几个方法/函数/类。上百的测试用例允许运行几个小时。大部分功能测试是功能测试回归套件的一部分。通常由Junit来运行。

  • 集成测试(Integration Test**)**:

测试两个或者更多的组件一起工作的情况。有时候是回归套件的一部分。

  • 组件测试(Component Test**)**:

运行一个组件。经常由QA,经理,XP客户等等来执行。这种类别的测试不是回归套件的一部分,它不由Junit来执行。

  • 组件验收测试(Component Acceptance Test C.A.T.**)**:

作为正常流程的一部分,它是在众多人面前运行的一个组件测试。由大家共同决定这个组件是不是满足需求标准。

  • 系统测试(system Test**)**:

所有的组件在一起运行。

  • 系统验收测试(System Acceptance Test S.A.T.**)**:

作为正常流程的一部分,它是在众多人面前运行的一个系统测试,由大家来共同决定这个系统是不是满足需求标准。

  • 压力测试(Stress Tests**)**:

另外一个程序加载一个组件,一些组件或者整个系统。我曾经看到过把一些小的压力测试放到回归功能测试中来进行——这是测试并发代码的一个很聪明的做法。

  • Mock:

在单元测试或者功能测试中使用的一些代码,通过使用这些代码来确保你要测试的代码不会去使用其它的产品代码(production code)。一个mock类覆盖了一个产品类中的所有public方法,它们用来插入到尝试使用产品类的地方。有时候一个mock类用来实现一个接口,它替换了用来实现同样接口的产品代码。

  • Shunt:

有点像继承(extends)产品代码的mock类,只是它的意图不是覆盖所有的方法,而只是覆盖足够的代码,所以你能够测试一些产品方法,同时mock剩余的产品方法。如果你想测试一个可能会使用I/O的类它会变得尤为有用,你的shunt能够重写I/O方法同时来测试非I/O方法。

3. 使用太多功能测试(functional test)会有麻烦

不要误解我的意思。功能测试有很大的价值。我认为一个测试良好的app将会有一个功能测试的回归套件和一个非回归功能测试的集合。通常情况下对于一磅产品代码,我都想看到两磅单元测试代码和两盎司(注:1磅=16盎司)功能测试代码。但是在太多的项目中我看到的现象是没有一丁点单元测试,却有一磅功能测试。

下面的两幅图表明了一些类的使用情况。用一些功能测试来测试这些类一块工作的情况。修复一个类的bug会破坏许多功能测试。。。

上面的情况我看到过多次。其中的一个例子是一个很小的改动破坏了47个测试用例。我们通过开会来决定这个bug是不是要被留在代码中。最后决定我们要留足够的时间来fix所有的case。几个月过去了,事情依然糟糕。。

解决方法是使用单元测试来代替功能测试:

结果是这个工程变的更加灵活。

4. 功能测试认知纠错

通过只编写功能测试用例,我可以写更少的测试代码,同时测试更多的功能代码!”这是真的!但是这会以你的工程变得更加脆弱为代价。另外,如果不使用单元测试,你的应用有些地方很难被测试。同时达到最好的覆盖率和灵活性是使用功能测试和单元测试的组合,其中单元测试的比重要大,功能测试的比重要小。

我的业务逻辑是让所有的类一块工作,所以只测试一个方法是没有意义的。”我建议你单独测试所有的方法。同时我也并不建议你不使用功能测试,它们也是有价值的。

我不介意我的单元测试组件会花费几分钟来运行”但是你的团队中的其他人介意么?你的team lead介意么?你的manager呢?如果它花费几分钟而不是几秒钟,你还会在一天的时间把整个测试套件运行多次么?在什么情况下人们根本不会运行测试?

5. 单元测试mock基础

下面是单元测试的一个简单例子,测试各种情况却不依赖其他方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void testLongitude()
{
assertEquals( "-111.44" , Normalize.longitude( "111.44w" ) );
assertEquals( "-111.44" , Normalize.longitude( "111.44W" ) );
assertEquals( "-111.44" , Normalize.longitude( "111.44 w" ) );
assertEquals( "-111.44" , Normalize.longitude( "111.44 W" ) );
assertEquals( "-111.44" , Normalize.longitude( "111.44 w" ) );
assertEquals( "-111.44" , Normalize.longitude( "-111.44w" ) );
assertEquals( "-111.44" , Normalize.longitude( "-111.44W" ) );
assertEquals( "-111.44" , Normalize.longitude( "-111.44 w" ) );
assertEquals( "-111.44" , Normalize.longitude( "-111.44 W" ) );
assertEquals( "-111.44" , Normalize.longitude( "-111.44" ) );
assertEquals( "-111.44" , Normalize.longitude( "111.44-" ) );
assertEquals( "-111.44" , Normalize.longitude( "111.44 -" ) );
assertEquals( "-111.44" , Normalize.longitude( "111.44west" ) );
// ...
}

当然,任何人都能为上面这种情况做单元测试。但是大部分业务逻辑都使用了其它业务逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class FarmServlet extends ActionServlet
{
public void doAction( ServletData servletData ) throws Exception
{
String species = servletData.getParameter("species");
String buildingID = servletData.getParameter("buildingID");
if ( Str.usable( species ) && Str.usable( buildingID ) )
{
FarmEJBRemote remote = FarmEJBUtil.getHome().create();
remote.addAnimal( species , buildingID );
}
}
}

这里不仅仅调用了其他业务逻辑,还调用了应用服务器!可能还会访问网络!上千次的调用可能会花费不少于10秒的时间。另外对EJB的修改可能会破坏我对这个方法的测试!所以我们需要引入一个mock对象。

首先是创建mock。如果FarmEJBRemote是一个类,我将会继承(extend)它并且重写(override)它所有的方法。但是既然它是一个接口,我会编写一个新类并实现(implement)所有方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MockRemote implements FarmEJBRemote
{
String addAnimal_species = null;
String addAnimal_buildingID = null;
int addAnimal_calls = 0;
public void addAnimal( String species , String buildingID )
{
addAnimal_species = species ;
addAnimal_buildingID = buildingID ;
addAnimal_calls++;
}
}

这个类什么都没做,只是携带了单元测试和需要被测试代码之间要交互的数据。

这个类会让你感觉不舒服么?应该是这样。在我刚接触它的时候有两件事情把我弄糊涂了:类的属性不是private的,并且命名上有下划线。如果你需要mock java.sql.connection。总共有40个方法! 为每个方法的各个参数,返回值和计数都实现Getters和setters?嗯…稍微想一下…我们把属性声明为private是为了封装,把事情是如何做的封装在内部,于是日后我们就可以修改我们的业务逻辑代码而不用破坏决定要进入我们的内脏的其他代码(也就是要调用我们的业务逻辑的代码)。但这对于mock来说并不适用,不是么?根据定义,mock没有任何业务逻辑。进一步来说,它没有任何东西不是从其他地方拷贝过来的。所有的mock对象都能100%在build阶段生成!..所以虽然有时候我仍然觉的这么实现Mock有一点恶心,但是最后我会重拾自信,这是最好的方法了。只是闻起来会让你有些不舒服,但是效果比使用其它方法好多了。

现在我需要使用mock代码来替代调用应用服务器的部分。我对需要使用mock的地方做了高亮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class FarmServlet extends ActionServlet
{
public void doAction( ServletData servletData ) throws Exception
{
String species = servletData.getParameter("species");
String buildingID = servletData.getParameter("buildingID");
if ( Str.usable( species ) && Str.usable( buildingID ) )
{
FarmEJBRemote remote = FarmEJBUtil.getHome().create();
remote.addAnimal( species , buildingID );
}
}
}

首先,让我们把这句代码从其他猛兽中分离出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class FarmServlet extends ActionServlet
{
private FarmEJBRemote getRemote()
{
return FarmEJBUtil.getHome().create();
}
public void doAction( ServletData servletData ) throws Exception
{
String species = servletData.getParameter("species");
String buildingID = servletData.getParameter("buildingID");
if ( Str.usable( species ) && Str.usable( buildingID ) )
{
FarmEJBRemote remote = getRemote();
remote.addAnimal( species , buildingID );
}
}
}

这有一点痛..我将会继承我的产品类然后重写getRemote(),于是我可以把mock代码混入到这个操作中了。我需要做一点点改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class FarmServlet extends ActionServlet
{
FarmEJBRemote getRemote()
{
return FarmEJBUtil.getHome().create();
}
public void doAction( ServletData servletData ) throws Exception
{
String species = servletData.getParameter("species");
String buildingID = servletData.getParameter("buildingID");
if ( Str.usable( species ) && Str.usable( buildingID ) )
{
FarmEJBRemote remote = getRemote();
remote.addAnimal( species , buildingID );
}
}
}

如果你是一个好的面向对象工程师,你现在应该疯了!破坏单元测试代码中的封装性是很不舒服的,但是破坏产品代码封装性的事情就不要做了!长篇大论的解释有可能帮助事态平息,我的观点是:在你的产品代码中,对类的第一次封装要永远保持警惕…但是,有时候,你可能考虑用价值20美元的可测试性来和价值1美元的封装性来做交易。为了让你减轻一点痛苦,你可以加一个注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class FarmServlet extends ActionServlet
{
//exposed for unit testing purposes only!
FarmEJBRemote getRemote()
{
return FarmEJBUtil.getHome().create();
}
public void doAction( ServletData servletData ) throws Exception
{
String species = servletData.getParameter("species");
String buildingID = servletData.getParameter("buildingID");
if ( Str.usable( species ) && Str.usable( buildingID ) )
{
FarmEJBRemote remote = getRemote();
remote.addAnimal( species , buildingID );
}
}
}

现在我可以实现一个类来返回mock值了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FarmServletShunt extends FarmServlet
{
FarmEJBRemote getRemote_return = null;
FarmEJBRemote getRemote()
{
return getRemote_return;
}
}

注意一下怪异的名字:“shunt”。我不确定它是什么意思,但我认为这个词语来自电子工程/工艺,它指用一段电线来临时组装一个完整的电路。一开始听起来这个想法很愚蠢,但是过后我就慢慢习惯了。

一个shunt有点像mock,一个没有重写所有方法的mock。用这种方法,你可以mock一些方法,然后测试其他的方法。一个单元测试可以由几个shunts来完成,它们重写了相同的类,每个shunt测试了类的不同部分。Shunt通常情况下为嵌套类。

终场表演的时候到了!看一下单元测试代码!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class TestFarmServlet extends TestCase
{
static class FarmServletShunt extends FarmServlet
{
FarmEJBRemote getRemote_return = null;
FarmEJBRemote getRemote()
{
return getRemote_return;
}
}
public void testAddAnimal() throws Exception
{
MockRemote mockRemote = new MockRemote();
FarmServletShunt shunt = new FarmServletShunt();
shunt.getRemote_return = mockRemote();
// just another mock to make
MockServletData mockServletData = new MockServletData();
mockServletData.getParameter_returns.put("species","dog");
mockServletData.getParameter_returns.put("buildingID","27");
shunt.doAction( mockServletData );
assertEquals( 1 , mockRemote.addAnimal_calls );
assertEquals( "dog" , mockRemote.addAnimal_species );
assertEquals( 27 , mockRemote.addAnimal_buildingID );
}
}

基本的测试框架我们就展示完了。下面我要和大家分享一个和单元测试有关的概念——依赖注入,也是我们的单元测试中要到的,敬请期待。