本期要点:
1. 如果构造函数中工作太多,可视为一种代码的缺点。
2. 它为什么是一种缺点?
3. 如何识别和修复它?
4. 修复前后的代码示例
∎ 1 ∎
缺陷:构造函数做了太多工作
在构造函数中,常见到的活动有很多,例如:创建/初始化协作类、与其他服务通信,以及设置其自身状态的逻辑(这可能在写测试时,很难找到测试所需的缝隙),强制子类/模拟继承不需要的行为。
如果构造函数中的工作过多,则为对该类或测试用例中所用到的协作类 的实例化造成阻碍。
该缺点散发出来的一些信号:
☑︎ 构造函数中或字段声明时有新的关键字
☑︎ 构造函数中或字段声明时有静态方法调用
☑︎ 除了给字段赋值以外还做了其它事情
☑︎ 构造函数完成后,对象本身还未完成初始化工作(注意初始化方法)
☑︎ 在构造函数中有一些控制流(例如条件或循环逻辑)
☑︎ 在构造函数中有较复杂关系的多类构造,而没有使用工厂模式或生成器
☑︎ 有额外使用的初始化方法或块
∎ 2 ∎
为什么这是一个缺点?
当构造器必须实例化,和初始化它的协作类时,这往往导致不灵活和过早耦合的设计。这样的构造函数在编写测试用例时,很难对其注入测试所需的协作类。
它违反了单一责任原则
当collaborator构造与初始化混合在一起时,它表明只有一种方法可以配置类,这就关闭了原本可能可用的重用机会。对象图的创建是一项全面的责任——这与类首先存在的原因不同。在构造器中做这样的工作违反了单一责任原则。
直接测试很困难
测试这样的构造函数是困难的。要实例化对象,构造函数必须执行。如果这个构造函数做了大量的工作,那么在测试中创建对象时,你就不得不做这些工作。如果协作者访问外部资源(例如文件、网络服务或数据库),协作者中的细微变化可能需要反映在构造函数中,但可能会由于缺少测试覆盖而被忽略,而这些测试由于构造函数太难测试而未编写。我们最终陷入恶性循环。
子类化和重写测试也比较难
有时构造函数本身几乎不做任何工作,但委托给一个在测试子类中被重写的方法。这可能会解决构造困难的问题,但是使用“子类来测试”技巧是您在万不得已的情况下才应该做的事情。另外,通过子类化,您将无法测试重写的方法。这个方法做了很多工作(记住,这就是为什么它首先被创建的原因),所以它可能应该被测试。
它迫使你把合作类也绑定在一起了
有时,当我们测试 A 对象时,并不希望真正地创建它的所有协作类,因为这些类与我当前想测试的行为没有什么关系。例如,你不希望你的MySqlRepository对象去访问真实的MySql数据库服务。但是,如果在被测系统(SUT)中,我们使用的方式的确是直接创建的,也就是使用了关键字 new ,那么我们将被迫使用这个MySql 重量级对象了。
它抹掉了一个“缝”
代码中有缝隙(Seam)就是指可以将代码库切分开,通过屏蔽依赖项,并实例化相对较小的、集中的对象的地方。如果是在构造函数中有类似 new ClassA() 这种语句时,你就根本无法创建不同的(子类)对象。
有关接缝的更多信息,请参阅Michael Feathers一书《Working Effectively with Legacy Code(修改代码的艺术)》。
仅管这类构造函数只是为Test而写的,那也是它的“缺点”
通过创建单独的“仅测试使用”的构造函数并不能解决问题。因为真正使用的构造函数仍将会被其他类使用。即使你可以单独测试这个对象(使用特定于测试的构造函数创建它),你还会遇到其他的类,也有类似的构造函数,使它仍旧难以测试。当测试这些类时,你仍旧会被束缚。
底线
最后,归根到底,还是要问,在隔离或创建测试替身对象的合作者时,是困难?还是容易?
如果很难,那说明你在构造器中做了太多的工作!
如果很容易,那说明你做的不错。
所以,在编写代码时,请始终考虑测试它有多困难。你正在编写的构造函数,对它实例化会很容易吗?
(请记住,您的测试类并不是唯一实例化该类的代码。)
“许多设计都充满了 实例化其他对象或从全局可访问位置检索对象的对象。
如果不加以检查,这些编程实践会导致高度耦合的设计,而这些设计很难测试。”——J.B.Rainsberger《JUnit Recipes》
∎ 3 ∎
如何识别这类缺点
缺点的症状表现:
☑︎ new关键字构造了您想在测试中替换为test double的任何内容?(通常这比一个简单的值对象大)。
☑︎ 任何静态方法调用?(请记住:静态调用是不可Mock的,也是不可注入的,因此如果您看到服务器初始化,或者其他类似的东西,警笛应该会在你的脑海中响起!)
☑︎ 任何条件或循环逻辑?(每次实例化对象时,必须成功地导航逻辑。这将导致过多的设置代码,不仅是在您直接测试类时,而且如果您在测试任何相关类时碰巧需要该类,也会导致过多的设置代码。)
在编写或审阅代码时,请考虑一个基本问题:“我要怎么测试这个?”
“如果答案不明显,或者测试看起来很难看或者很难写,那么就把它当作一个警告信号。你的设计可能需要修改;改变一些事情直到代码易于测试,你的设计最终会变得更好。”——《用JUnit实现Java中的实用单元测试》
注意:
构造值对象(Value Objects)在许多情况下都是可以接受的(例如:LinkedList、HashMap、User、EmailAddress、CreditCard)。
因为,值对象的关键特点是:(1)构造简单;(2)以状态为中心(许多getter/setter对行为影响很低)(3)不引用任何服务对象。
∎ 4 ∎
如何修复这类缺点
不要在构造函数中创建协作类,而应该将它们传入进来。
将对象拓扑的构造和初始化的职责转移到另一个对象中。例如,提取一个构建器类或工厂方法(类),并将这些协作类传递给构造函数。
例如:如果依赖于DatabaseService(希望这是一个接口),则使用依赖注入(DI)将所需DatabaseService对象的确切子类传递给构造函数。
重要的事情说三遍:
不要在构造函数中创建协作类,而应该将它们传入进来!
不要在构造函数中创建协作类,而应该将它们传入进来!
不要在构造函数中创建协作类,而应该将它们传入进来!
Do not look for things! Ask for things!
如果需要对传入的对象进行初始化,则有三个选项(以Java为例):
一、使用Guice的方式:使用一个 Provider<YourObject> 来创建和初始化对象的构造函数所需的参数。将对象初始化和对象拓扑图构造的责任交给Guice。这将消除在初始化对象的需要。有时除了提供程序之外,您还可以使用构建器或工厂,然后将构建器和工厂传递给构造函数。
二、使用手动依赖注入的方式:为对象的构造函数参数使用生成器或工厂。通常对于某个对象的整个拓扑有一个制造工厂类,请参见下面的示例。工厂类的职责是创建对象拓扑,而不再做其它任何工作。在工厂里你应该看到的是大量的新关键字和引用的传递。对象拓扑关系的职责是执行逻辑工作,而不是执行对象实例化(应用程序逻辑类中应该严重缺乏新的关键字)。
三、最后的办法:在类中有一个可以在构造后调用的init(…)方法。尽可能避免这种情况,最好使用另一个唯一负责为此对象配置参数的对象。
∎ 5 ∎
构造器改造前后的例子
从根本上说,“在构造函数中做一些实质性工作”都相当于在其中做了逻辑,这就会给实例化对象或引入测试替身测试对象带来一些困难。
new 操作符在构造函数和类成员变量带来的问题:
上面的例子中,混合了对象拓扑关系的创建和逻辑。在测试中,我们通常希望创建与生产中不同的对象图。通常它是一个较小的关系视图,其中的一些对象应该被测试替身对象所代替。将 new 操作符保留在函数中,我们根本没有机会创建写测试用例时所需要的对象关系视图。
☑︎ 问题一:对类实例变量的内联对象实例化,也存在着与构造函数中相同的问题。
☑︎ 问题二:也许你会说,例子中的对象也很容易实例化呀。然而,假如类 Kitchen 是代表了一些成本非常昂贵的操作,比如文件/数据库访问,就不太容易测试,因为我们永远无法用测试替身对像来代替 Kitchen 。
☑︎ 问题三:你的设计更脆弱,因为你永远无法以多态性的方式替代 House 中的Kitchen 或 Bedroom 的行为。
如果 Kitchen 是一个值对象,例如:Linked List, Map, User, Email Address, etc.等,那么,只要值对象不引用Service 对象,我们就可以使用内联方式创建它们。Service 对象是最有可能需要被替换为测试替身对象的一种类型,因此,您应该永远都不要希望通过直接实例化或通过静态方法调用实例化来锁定它们。
构造函数将对象做了部分初始化工作的问题:
对象拓扑关系的创建(为 Garden 创建和配置 Gardener 这个协作类)与 Garden 应该做的事情,应该是不同的。当我们将配置和实例化在构造函数中混合在一起时,对象变得更加脆弱,并与具体的对象关系结构绑定在一起。这使得代码更难修改,并且(或多或少)带来了不可测问题。
☑︎ 问题一:Garden 需要一个园丁,但配置园丁不是 Garden 的责任。
☑︎ 问题二:在 Garden 的单元测试中,工作日是在构造函数中专门设置的,因此迫使园丁 Joe 每天工作 12 小时。像这样的强制依赖会导致测试运行缓慢。在单元测试中,您希望在更短的时间内就能运行完,而不是真的等上12小时。
☑︎ 问题三:你不能替换穿的靴子。您可能希望对引导使用一个测试替身对象,以避免加载和使用 BootsWithMassiveStaticInitBlock()时出现的问题。(静态初始化块通常是危险和麻烦的,尤其是当它们与全局状态交互时。)
(未完待续……)