有一些人曾经说过,单元测试应该对被测代码完全透明,并且你不应该为了简化测试而更改运行时的代码。这是错误的!实际上,单元测试是对运行时代码的最好运用,应该同其他运用同等看待。如果你的代码太不灵活,以致在测试中无法使用,那么你就应该对它进行修改。
例如,你对下面的一段代码怎么看?
你觉得这段代码看起来好吗?这里我们看到存在两个问题,它们都与代码的适应性以及抵御变化的能力相关。第一个问题是,你不太可能决定去使用一个不同的Log对象,因为这是在类的内部创建的。比如在测试中,你可能想使用一个什么也不做的Log,但是你办不到。
一般来说,这样的类应该能够使用给定的任何Log。
这个类的目的并不是创建记录器,而是执行某些JDBC逻辑。同样的评论也适用于PropertyResourceBundle的使用。现在它看上去还不错,但是如果你决定使用XML来存储配置又会怎么样呢?再一次强调,决定使用什么实现并不应该是这个类的目的。
一个有效的设计策略就是,将直接业务逻辑外的其他对象传递给逻辑内的对象。外围对象选择应该由在调用链上更高层次的某个人控制。最终,当你沿着调用层向上移动时,使用一个给定的记录器或者配置的决定权就应该推给最高层次的人。这种策略可以提供最好的代码灵活性以及应付变化的能力。并且我们都知道,唯一不变的就是变化。
重构所有的代码以传递域对象是比较耗费时间的。也许你不准备重构整个应用程序,而仅仅为了能够编写一个单元测试。幸运的是,这里有一个简单的重构技术,可以让你为代码保持相同的接口,并同时允许传递给它没有创建的城对象。为了确认这一点,我们来看看重构后的 DefaultAccountManager类是什么样子的,请参考代码7.5;其中修改的部分代码已用粗体表示。
注意①,我们使用了一个新的接口Configuration,来替换了前一个代码段中的PropertyResourceBundle 类。这就使代码变得更加灵活,因为引入了一个接口(这非常容易mock),并且 Configuration接口可以实现成你想要的任何东西(包括使用资源包)。现在的这个设计更加优秀了,原因是我们可以通过Log与Configuration接口的任何实现使用和复用DefaultAccountManager类(如果我们使用带有两个参数的构造函数)。类可以由外部(它的调用者)来控制。同时,我们并没有破坏已有的接口,只是增加了一个新的构造函数。另外,我们还保留了原始的默认构造函数,它仍然使用默认值对域成员 logger 与 configuration进行了初始化。
通过这样的重构,我们提供了一个在测试中控制域对象的暗门。我们不仅保留了后向兼容性,同时也为日后铺好了一条轻松重构的道路。调用的类可以按照它们自己的情况开始使用这个新的构造函数。
你是否因为引入暗门而有所担心呢?尽管这样可以让你的代码更易于测试。下面是极限编程权威Ron Jeffries的解释:
我的汽车有一个诊断口以及一个测油计。我的炉子侧面以及烤箱前面都有检查孔。我的钢笔筒是透明的,因此我可以看到里面是否还有墨水。
如果我发现有必要向一个类添加一个方法,以使找能够测试它,那么我就会这么做。这经常会发生,比如在有着简单的接口和复杂的内部函数的类中(可能会需要一个 Extract 类)。
我只是把我所理解的类的需求给予那些类,并且关注它下一步需要什么。
设计模式实践:控制反转(loC)
应用loC模式到一个类中,意味着该类不再创建其不直接负责的对象实例,取而代之的是传递任何所需要的实例。实例可以通过使用一个具体的构造器、setter或者需要这些实例地方法的参数,而被传递过去。在被调用类上正确地设置这些域对象就成了调用代码的责任'。
loC使单元测试变得轻而易举。为了证明这一点,让我们来看看现在要为findAccountByUser方法编写一个测试是多么轻松:
在①中,我们使用一个mock logger来实现Log接口,但是什么都没做。接下来我们创建了一个 MockConfiguration 实例2,然后设置为:在调用Configuration.getsQL时返回一个给定的SQL查询。最后,我们创建所测试DefaultAccountManager的实例3,并将Log与Configuration实例传递给它。
现在在测试代码中,我们已经完全能够从被测试代码的外围控制我们的记录和配置行为。因此我们的代码会变得更加灵活,并且允许使用任何记录和配置的实现。在本章和后面的章节中,你将会看到更多这样的代码重构。
最后要注意的一点是,如果你先编写测试,你会自觉地将代码设计得比较灵活。灵活性对编写单元测试而言是一个关键。如果你先进行测试,就可容易避免后期为了灵活性重构代码而带来的开销。