可维护测试的实践与策略
1. 复杂测试的问题与挑战
在测试编写过程中,我们可能会遇到一些复杂的情况,导致测试难以理解、维护且脆弱。以下是一些常见的问题表现:
- 测试类知晓资金和寄存器的内部细节、计算算法以及计算涉及的所有类型的内部细节(如寄存器有单位)。
- 需要大量的测试替身,测试方法主要是为存根指定应返回的值,甚至存根还会返回存根。
这些问题使得测试变得复杂,例如在资金价值计算算法发生任何变化时,都需要重写测试。而且,针对不同数量的资金(如 0 资金、1 资金、7 资金等)以及各种资金值,都需要编写多个测试,这无疑增加了测试的工作量和难度。
2. 是否需要使用模拟对象
在之前的示例中,我们为客户端类的所有协作者使用了测试替身。实际上,如果使用真实对象而非类,可能会节省几行测试代码。但这只是短期解决方案,因为在现实中,资金值可能从外部源(如 Web 服务)获取,这会使测试变得更加困难。因此,用测试替身替换所有协作者似乎是一个合理的选择。
3. 面向对象的方法
为了改善测试的可维护性,我们可以采用面向对象的方法来实现客户端类。以下是面向对象版本的客户端类示例:
public class Client {
private final List<IFund> funds;
....
public BigDecimal getValueOfAllFunds() {
BigDecimal value = BigDecimal.ZERO;
for (IFund f : funds) {
value = value.add(f.getValue());
}
return value;
}
}
在这个版本中,所有资金价值的计算都封装在 IFund 类型的 getValue() 方法中,客户端只需将结果相加。编写这样一个类的测试很简单,只需要两个存根(每个客户端拥有的资金一个):
public class ClientTest {
private final static BigDecimal VALUE_A = new BigDecimal(9);
private final static BigDecimal VALUE_B = new BigDecimal(2);
public void totalValueShouldBeEqualToSumOfAllFundsValues() {
Client client = new Client();
IFund fundA = mock(IFund.class);
IFund fundB = mock(IFund.class);
when(fundA.getValue()).thenReturn(VALUE_A);
when(fundB.getValue()).thenReturn(VALUE_B);
client.addFund(fundA);
client.addFund(fundB);
assertEquals(client.getValueOfAllFunds(), VALUE_A.add(VALUE_B));
}
}
这种测试简洁明了,不包含资金的内部细节。继续采用这种面向对象的方法,为每个类编写测试会变得非常简单,且每个测试只依赖于被测系统(SUT),与之前的测试有很大不同。同时,对于 0、1 和 7 资金的测试也不再困难。
4. 处理过程式代码
过程式代码会对测试产生灾难性的影响。如果代码不遵循面向对象设计的基本规则,测试将会变得困难。为了避免这种情况,最好在问题发生之前采取行动,不要让过程式代码进入代码库。测试驱动开发(TDD)在阻止过程式代码方面表现出色,因为为过程式代码编写测试非常痛苦,从测试开始编写代码会促使我们采用更面向对象的解决方案。
如果已经继承了大量的过程式代码,也有一些技术可以帮助处理这种情况,但这超出了本文的范围。
5. 结论
糟糕的代码会使编写测试变得困难。以下是两个相关的观点来支持这一说法:
- “一致性。如果你不是个失败者,它才是一种美德。” —— 互联网智慧
当遇到违反 “Tell, Don’t Ask!” 原则的代码时,不要复制粘贴,而应该清理它,通常是在适当的地方添加方法,然后编写干净、设计良好的代码。
- “每次模拟对象返回模拟对象时,一个仙女就会死去。” —— Twitter @damianguy 2009 Oct 19
当编写测试需要一个测试替身返回另一个测试替身时,说明正在处理的代码违反了 “迪米特法则”,应该先修复代码,再进行测试。
6. 代码变更时重写测试
当需求发生变化时,开发者分析并实现所需的更改,然后运行测试,可能会有一些测试失败。这种情况很常见,原因主要有两个:一是测试的质量问题,二是先编写代码的方法。
6.1 避免过度指定的测试
为了使测试保持灵活性,我们应遵循 “精确指定你想要发生的事情,不多不少” 的原则。过度指定的测试是指验证了与测试场景无关的方面的测试。例如,以下测试就存在过度指定的问题:
@Test
public void itemsAvailableIfTheyAreInStore() {
when(store.itemsLeft(ITEM_NAME)).thenReturn(2);
assertTrue(shop.isAvailable(ITEM_NAME));
verify(store).itemsLeft(ITEM_NAME);
}
这个测试除了断言被测系统的功能外,还验证了协作者的行为。如果测试的真正目的是验证 “如果物品在商店中,则物品可用”,那么最后对协作者行为的验证可能是多余的。可以将其拆分为两个更有针对性的测试:
@Test
public void itemsAvailableIfTheyAreInStore() {
when(store.itemsLeft(ITEM_NAME)).thenReturn(2);
assertTrue(shop.isAvailable(ITEM_NAME));
}
@Test
public void shouldCheckStoreForItems() {
shop.isAvailable(ITEM_NAME);
verify(store).itemsLeft(ITEM_NAME);
}
这样每个测试只有一个失败原因,不再过度指定。如果重构被测系统的实现,可能只有一个测试失败,从而明确哪个功能被破坏。
另外,使用具体参数值而不是更通用的值,以及进行不必要的防御性测试(如验证某些交互未发生、检查调用顺序等),都可能导致测试过度指定。编写测试时,应只测试必要的功能集。
6.2 是否真的采用测试优先的编码方式
当需求变更时,如果先更新生产代码,再处理失败的测试,就回到了先编写代码的开发方式,这会带来一些问题。测试可能难以发现新的错误,并且与生产代码的实现紧密耦合。
更好的做法是模仿 TDD 方法,按照以下顺序操作:
1. 需求变更。
2. 开发者分析哪些测试需要更新以反映新需求。
3. 更新测试(由于代码不满足新需求,测试会失败)。
4. 开发者分析需要对生产代码进行哪些更改。
5. 更新代码,使测试通过。
为了避免在代码变更后修复测试的烦恼,应该编写与实现松耦合的好测试,减少失败测试的数量,并在开发过程的所有阶段都采用测试优先的方法。
6.3 结论
如果从编写生产代码开始,测试可能会包含过多的实现细节,变得脆弱,每次修改生产代码时都会失败。而从测试开始编写代码,测试将更多地关注系统的功能,在类发生变化时更有可能保持通过。
我们应该编写验证系统行为预期结果的测试,而不是行为本身。尽可能通过分析返回值来验证系统是否正常工作,只有在必要时才进行交互测试。测试应专注于目标,排除无关的内容,例如尽可能使用存根而不是模拟对象,使用更多的参数匹配器。同时,要经常运行测试,否则可能会面临大量测试需要重写的困境。
7. 简单到不易出错的代码
虽然我们应该对每一行可能出错的代码进行单元测试,但在某些情况下,编写单元测试似乎是多余的。例如,简单的 getter/setter 方法:
public class User {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
为这样的代码编写测试可能没有意义,因为没有值得测试的逻辑,而且代码可能是由 IDE 生成的,减少了复制粘贴错误的风险。但如果 getter 和 setter 方法发生变化,增加了一些复杂性(如验证),则应该编写测试:
public void setName(String name) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException();
}
this.name = name;
}
另一个常见的 “简单到不易出错” 的代码示例是简单的委托方法:
public class DelegatorExample {
private Collaborator collaborator;
public void delegate() {
collaborator.doSomething();
}
}
为这样的代码编写测试可能需要一些努力,测试可能比被测试的方法更长、更复杂。是否为这样的方法编写测试取决于以下三个因素:
- 协作者类的类型(即特定特征)。
- 委托方法的复杂性。
- 是否存在其他类型的测试。
委托方法可能比表面看起来更复杂,可能会有参数处理、返回值使用、异常处理等情况。虽然集成测试可能会覆盖部分功能,但单元测试仍可能有必要填补一些场景的空白。每个这样的代码实例都应单独考虑,有些情况下编写测试可能是浪费时间。
综上所述,在编写测试时,我们需要根据代码的具体情况做出明智的决策,以确保测试的有效性和可维护性。
以下是一个 mermaid 格式的流程图,展示了代码变更时重写测试的流程:
graph LR
A[需求变更] --> B[分析需更新的测试]
B --> C[更新测试(测试失败)]
C --> D[分析代码更改]
D --> E[更新代码]
E --> F[测试通过]
以下是一个表格,总结了不同类型代码的测试情况:
| 代码类型 | 是否需要测试 | 原因 |
| ---- | ---- | ---- |
| 简单 getter/setter | 通常不需要 | 无逻辑,可能由 IDE 生成 |
| 有验证的 getter/setter | 需要 | 增加了复杂性 |
| 简单委托方法 | 视情况而定 | 取决于协作者类型、方法复杂性和其他测试的存在 |
可维护测试的实践与策略
8. 测试决策的关键因素分析
为了更清晰地判断是否需要为不同类型的代码编写测试,我们进一步分析前面提到的关键因素。
8.1 协作者类的类型
协作者类的特定特征会影响委托方法测试的必要性。例如,如果协作者类是一个稳定的、经过充分测试的第三方库,那么委托方法的测试可以适当简化;但如果协作者类是内部开发的、经常变动的模块,那么为委托方法编写测试就更为重要。以下是一个简单的对比表格:
| 协作者类类型 | 委托方法测试必要性 |
| ---- | ---- |
| 稳定第三方库 | 较低,但仍需考虑边界情况 |
| 内部频繁变动模块 | 较高,需全面测试 |
8.2 委托方法的复杂性
委托方法的复杂性不仅仅取决于代码的长度,还包括参数处理、返回值使用和异常处理等方面。我们可以通过以下列表来详细分析:
- 参数处理 :如果委托方法需要对传入的参数进行验证、转换或组合,那么其复杂性增加,测试的必要性也相应提高。
- 返回值使用 :当委托方法使用协作者的返回值进行进一步的计算、决策或存储时,需要确保返回值的正确性,因此测试变得更为重要。
- 异常处理 :若协作者的方法可能抛出异常,委托方法需要正确处理这些异常,这也增加了测试的必要性。
8.3 其他类型测试的存在
其他类型的测试,如集成测试,可以在一定程度上覆盖委托方法的功能。但集成测试通常无法覆盖所有可能的场景,特别是一些边界情况和异常处理。因此,即使有集成测试,单元测试仍然可能是必要的。以下是一个简单的流程图,展示了不同类型测试之间的关系:
graph LR
A[单元测试] --> B[集成测试]
B --> C[系统测试]
D[边界情况和异常处理] --> A
E[整体功能验证] --> B
F[系统整体表现] --> C
9. 测试优先开发的优势体现
测试优先开发(TDD)在提高代码质量和可维护性方面具有显著的优势。以下是一些具体的体现:
9.1 引导良好的设计
从测试开始编写代码会促使我们思考系统的功能需求和接口设计。例如,在编写测试时,我们需要明确输入和输出,这有助于定义清晰的方法签名和类的职责。通过 TDD,我们可以避免设计出过于复杂或耦合度过高的代码。
9.2 提高代码覆盖率
TDD 可以自然地提高代码覆盖率。因为我们是为了满足测试用例而编写代码,所以每个功能都会有相应的测试覆盖。这不仅可以发现代码中的缺陷,还可以确保代码的功能符合预期。
9.3 增强代码的可维护性
由于测试与代码紧密相关,当代码发生变化时,测试可以快速反馈是否引入了新的问题。这使得我们在修改代码时更加自信,也更容易维护代码的稳定性。
以下是一个简单的列表,总结了 TDD 的优势:
- 引导良好的设计
- 提高代码覆盖率
- 增强代码的可维护性
10. 避免过度指定测试的具体操作
为了避免编写过度指定的测试,我们可以采取以下具体操作:
10.1 优化测试方法命名
好的测试方法命名可以帮助我们明确测试的目标,从而避免验证无关的方面。例如,测试方法名应该准确描述测试的场景和预期结果,避免使用模糊或通用的名称。
10.2 使用通用参数匹配器
在测试中,尽量使用通用的参数匹配器(如 anyString() 、 anyInt() 等),而不是具体的参数值。这样可以使测试更加灵活,减少对特定值的依赖。
10.3 合理进行交互验证
只在必要时进行交互验证,避免对所有协作者的方法调用进行验证。例如,只有当某个交互对测试场景至关重要时,才进行验证。
以下是一个表格,总结了避免过度指定测试的操作和原因:
| 操作 | 原因 |
| ---- | ---- |
| 优化测试方法命名 | 明确测试目标,避免验证无关方面 |
| 使用通用参数匹配器 | 使测试更灵活,减少对特定值的依赖 |
| 合理进行交互验证 | 避免不必要的验证,提高测试的针对性 |
11. 总结与建议
在编写测试时,我们需要综合考虑代码的复杂性、协作者的特性以及测试的成本和收益。对于简单的代码,如简单的 getter/setter 和委托方法,要根据具体情况判断是否需要测试;对于复杂的代码,一定要编写全面的测试。
同时,建议采用测试优先开发的方法,这有助于提高代码的质量和可维护性。在编写测试时,要避免过度指定测试,确保测试的目标明确、简洁高效。
最后,要养成经常运行测试的习惯,及时发现代码中的问题,避免积累过多的测试问题导致难以修复。通过这些实践和策略,我们可以提高测试的有效性和可维护性,从而提升整个软件项目的质量。
以下是一个 mermaid 格式的流程图,展示了一个完整的测试决策流程:
graph LR
A[代码编写] --> B[判断代码类型]
B --> C{简单代码?}
C -- 是 --> D{需要测试?}
D -- 是 --> E[编写测试]
D -- 否 --> F[不测试]
C -- 否 --> E
E --> G[运行测试]
G --> H{测试通过?}
H -- 是 --> I[继续开发]
H -- 否 --> J[修复代码]
J --> E
通过以上的分析和建议,希望能帮助开发者在实际项目中更好地进行测试工作,确保代码的质量和可维护性。
超级会员免费看

被折叠的 条评论
为什么被折叠?



