单元测试的反模式
本书的最后一部分涉及常见的单元测试反模式。你很可能在过去遇到过其中的一些。不过,用第四章中定义的好的单元测试的四个属性来看待这个话题还是很有意思的。你可以用这些属性来分析任何单元测试概念或模式;反模式也不例外。
本章包括
- 单元测试私有方法
- 暴露私有状态以实现单元测试
- 将领域知识泄露给测试
- mocking具体类
这一章是一些不太相关的主题(主要是反模式)的汇总,这些主题在本书的前面部分并不合适,最好是单独使用。反模式是对一个重复出现的问题的常见解决方案,表面上看起来很合适,但会导致更多的问题。
你将学习如何在测试中使用时间,如何识别和避免诸如私有方法的单元测试、代码污染、mocking具体类等反模式。这些主题中的大部分都来自于第二部分中描述的第一个原则。不过,它们还是很值得明确说明的。你可能在过去至少听说过其中的一些反模式,但本章将帮助你把这些点联系起来,可以说是看到它们所基于的基础。
11.1 单元测试私有方法
说到单元测试,最常问的问题之一是如何测试一个私有方法。简短的回答是,你根本就不应该这样做,但这个问题有相当多的细微差别。
11.1.1 私有方法和测试脆弱性
为了进行单元测试而公开那些你本应保持私有的方法,这违反了我们在第五章中讨论的基本原则之一:只测试可观察的行为。暴露私有方法会导致测试与实现细节的耦合,并最终破坏你的测试对重构的抵抗力–(这是四个指标中最重要的。 所有这四项指标都是对回退的保护,对重构的抵抗,快速反馈和可维护性)。 不要直接测试私有方法,而是间接地测试它们,作为总体可观察行为的一部分。
11.1.2 私有方法和覆盖面不足
有时,私有方法过于复杂,将其作为可观察行为的一部分来测试并不能提供足够的覆盖率。 假设可观察行为已经有了合理的测试覆盖率,可能会有两个问题在起作用:
- 这是死的代码。如果未被发现的代码没有被使用,这可能是重构后留下的一些无关紧要的代码。最好是删除这些代码。
- 有一个缺失的抽象。 如果私有方法过于复杂(因此很难通过类的公共API进行测试),这就表明缺少一个抽象,应该被提取到一个单独的类中。
让我们用一个例子来说明第二个问题。
清单11.1 一个具有复杂私有方法的类
public class Order {
private Customer _customer;
private List < Product > _products;
public string GenerateDescription() {
return $ "Customer name: {_customer.Name}, " + $ "total number of products: {_products.Count}, " + $ "total price: {GetPrice()}";
}
private decimal GetPrice() {
decimal basePrice = /* Calculate based on _products */ ;
decimal discounts = /* Calculate based on _customer */ ;
decimal taxes = /* Calculate based on _products */ ;
return basePrice - discounts + taxes;
}
}
GenerateDescription()方法很简单:它返回订单的一般描述。 但是它使用了私有的GetPrice()方法,这个方法要复杂得多:它包含了重要的商业逻辑,需要彻底测试。 这个逻辑是一个缺失的抽象。 与其暴露GetPrice方法,不如把它提取到一个单独的类中,使这个抽象显性化,如下一个列表所示。
清单11.2 提取复杂的私有方法
public class Order {
private Customer _customer;
private List < Product > _products;
public string GenerateDescription() {
var calc = new PriceCalculator();
return $ "Customer name: {_customer.Name}, " + $ "total number of products: {_products.Count}, " + $ "total price: {calc.Calculate(_customer, _products)}";
}
}
public class PriceCalculator {
public decimal Calculate(Customer customer, List < Product > products) {
decimal basePrice = /* Calculate based on products */ ;
decimal discounts = /* Calculate based on customer */ ;
decimal taxes = /* Calculate based on products */ ;
return basePrice - discounts + taxes;
}
}
现在你可以独立于Order测试PriceCalculator。 你也可以使用基于输出(功能)的单元测试风格,因为PriceCalculator没有任何隐藏的输入或输出。更多关于单元测试风格的信息,请参见第6章。
11.1.3 当测试私有方法是可以接受的时候
从来不测试私有方法的规则也有例外。 为了理解这些例外情况,我们需要重新审视第五章中代码的宣传和目的之间的关系。 表11.1总结了这种关系(你已经在第5章看到了这个表,为了方便起见,我把它复制到这里)。
表11.1 准则的宣传与目的之间的关系
可观察的行为 | 实施细节 | |
---|---|---|
Public | Good | Bad |
Private | N/A | Good |
你可能还记得第五章的内容,将可观察的行为公开,而将实现的细节保密,这样就可以得到一个设计良好的API。 另一方面,泄露实现细节会破坏代码的封装性。可观察行为和私有方法的交叉点在表格中被标记为不适用,因为要使一个方法成为可观察行为的一部分,它必须被客户代码使用,如果该方法是私有的,这是不可能的。
请注意,测试私有方法本身并不坏。它之所以不好,只是因为这些私有方法是实现细节的一个代理。 测试实现细节是最终导致测试变脆的原因。话虽如此,在极少数情况下,一个方法既是私有的又是可观察行为的一部分(因此表11.1中的N/A标记并不完全正确)。
让我们以一个管理信用查询的系统为例。新的查询每天一次被直接批量加载到数据库中。 管理员然后逐一审查这些查询,并决定是否批准它们。下面是查询类在该系统中的样子。
清单11.3 一个有私有构造函数的类
public class Inquiry {
public bool IsApproved { get; private set; }
public DateTime ? TimeApproved { get; private set; }
private Inquiry(bool isApproved, DateTime ? timeApproved) {//私有构造器
if (isApproved && !timeApproved.HasValue) throw new Exception();
IsApproved = isApproved;
TimeApproved = timeApproved;
}
public void Approve(DateTime now) {
if (IsApproved) return;
IsApproved = true;
TimeApproved = now;
}
}
私有构造函数是私有的,因为该类是由一个对象关系映射(ORM)库从数据库中恢复的。该ORM不需要公共构造函数;它很可能与私有构造函数一起工作。同时,我们的系统也不需要构造函数,因为它不负责创建这些查询。
鉴于你不能实例化它的对象,你如何测试查询类?一方面,审批逻辑显然很重要,因此应该进行单元测试。但另一方面,将构造函数公开将违反不公开私有方法的规则。
Inquiry的构造函数是一个方法的例子,它既是私有的,又是可观察行为的一部分。这个构造函数履行了与ORM的契约,而且它是私有的这一事实并没有使契约变得不那么重要:没有它ORM就不能从数据库中恢复查询。
因此,在这种特殊情况下,将Inquiry的构造函数公开并不会导致测试的脆性。事实上,它可以说使该类的API更接近于设计良好。只要确保构造函数包含维护其封装性所需的所有前提条件。 在列表11.3中,这样的前提条件是要求在所有批准的查询中都要有批准时间。
另外,如果你想让类的公共API表面尽可能的小,你可以在测试中通过反射来实例化查询。虽然这看起来像一个黑客,但你只是在遵循ORM,它也在幕后使用反射。