简介
假设您正在实现某个功能,经过一番艰苦卓绝的编码后,终于可以提交、合并代码了。流水线开始运行,几分钟后失败了。部分单元测试用例失败了……这会让您很痛苦,因为修改的是别人遗留下来的程序,所以您并不清楚单元测试类的细节。搞清楚单元测试为什么失败以及理清楚他们之间的依赖关系可能是很有挑战的,会使原本1个小时的工作量变为一整天。
本文帮您提高测试类的质量,使之更易用。我通过12个步骤来度量测试类需要的改进。这个想法来源于“Joel测试:高质量代码的12个步骤”。这是一个评估软件团队质量的的方法。我非常认可这个想法,于是将它应用到测试中。就像Joel的测试一样,您不需要计算如同测试覆盖率以及其他不容易对齐的指标。只有12个问题,您都可以使用是或否来回答。每回答一个“是”,该测试类记1分。测试类的满分是12分,说明它很完美。同时,低于12分的就可以通过该问题进行有效的改善。
这些问题并没有涵盖所有涉及测试类易用性的因素。有些问题并不适合衡量测试类,因为您不能模拟所有东西。有可能有的非常完美的测试类,对这些问题的回答都是“不”。但是,如果能够对大多数问题回答为“是”的测试类,那么它的易用性就有极大的概率非常高。
1 测试是否独立
您应该能够以随机顺序运行测试,而不是让它们相互依赖。这使您的测试可维护性更高,因为您不必害怕影响其他测试。
2 测试是否可靠
您应该能够信赖您的测试。没有人喜欢在时而失败的构建或流水线上等待几分钟。能做的只是重新启动构建过程,祈祷这次不会失败。遗憾的是,修复这些测试的优先级很低,人们只会浪费时间重新启动并等待成功的构建。
3 测试是否高效
高效给出测试结果至关重要。您不会轻易分散精力,始终保持在工作流程中。当程序员在测试中等待超过10秒时,他们就会刷头条或者朋友圈。这会打断工作流程,而重新回到流程中需要一定的时间成本。更有可能的是,在花费太多时间的时候,人们会通过忽略或者注释来绕过测试。
4 Before和After是否与每个测试相关
在每次测试之前和之后做一些事情是减少重复代码的好方法。然而,当不是每个测试用例都需要这个特定的初始状态时,它就会成为一个问题。从那一刻起,您不仅在浪费 CPU 和时间,而且还使测试的维护成本剧增。Before 注解方法可能会为不需要它的测试用例创建不需要的状态。
实现 Before 和 After 的更好方法是使用参数化测试。您还可以使用子类将需要 Before 和 After 方法的测试用例与不需要该行为的测试用例分开。
在以下示例中,嵌套类将 Before 与父类分开。
public class StepsToBetterTests {
Car car;
@Test
void testA(){
car = new Car();
Assertions.assertEquals(5, car.getSpeed());
}
@Nested
class subTests{
@BeforeEach
void setUp() {
car = mock(Car.class);
when(car.getSpeed()).thenReturn(10);
}
@Test
void subtestA(){
Assertions.assertEquals(10, car.getSpeed());
}
}
class Car{
public int getSpeed(){
return 5;
}
}
}
5 测试是否单一
一个单元测试应该只覆盖一个测试用例。这使得测试更容易理解。否则,当单元测试失败时,您必须找出哪些用例失败了;当然,这些都没有很好的记录。
6 测试是否没有重复
维护的代码越少越好。测试用例也是如此;有时,您必须测试方法在不同输入和输出的表现。例如,当你测试一个计算器时,你会尝试不同的数字来查看计算是否正确。不要多次复制粘贴测试用例并且只更改参数,而是检查您的框架是否具有参数化测试用例之类的能力。
在以下示例中,使用参数化测试来测试add方法。
private static Stream<Arguments> valuesAndExpectedResultForAdd() {
return Stream.of(
Arguments.of(1, 1, 2),
Arguments.of(2, 1, 3),
Arguments.of(4, 2, 6)
);
}
@ParameterizedTest(name = "adding {0} and {1} expected result: {2}")
@MethodSource({"valuesAndExpectedResultForAdd"})
void add_shouldAddNumbersTogether(int firstInteger, int secondInteger, int expectedResult) {
assertEquals(add(firstInteger, secondInteger), expectedResult);
}
7 是否使用了每个模拟方法和对象
一些模拟框架默认不是很严格。因此,可以在不使用或设置任何期望的情况下创建模拟。通过对模拟的期望行为更加精确和明确,您可以提高测试的质量和可维护性。
不太严格的测试可能更难调试。您最终可能会遇到使用错误配置的模拟并导致测试失败的情况。
8 测试是否有逻辑顺序
如同生产代码有顺序一样,单元测试中的代码也是如此。我喜欢以 Given-When-Then 风格编写单元测试。这种风格清楚地描述了测试用例需要什么、执行什么操作以及何时执行。单元测试将由以下三个部分组成:
- 输入:创建测试用例所需的对象和模拟;
- 条件:您要测试的操作;
- 预期:您要执行的结果和断言。
并不要求每个人都使用这种风格,但是你的测试类或项目中的每个单元测试都应该使用一种连贯的风格。这样,每个人都清楚如何阅读和编写测试用例。
9 是否了解测试框架
当您了解测试框架和依赖项时,编写测试会变得更容易。您不必知道细节;只需知道框架的能力以及它提供的测试方法即可。因此,当您需要这些方法时,您可以查找它们。这可以防止您重新造轮子从而节省您的时间。
使用框架提供的最佳实践和方法,使您的测试更具可读性、可维护性和更易于编写。因此,您可以专注于如何用您的测试用例覆盖需求。
在我作为开发人员的六年中,这对我帮助最大。
10 测试需求还是实现
理想的测试只需要一些输入并验证输出是否正确。可悲的是,现实世界中的测试有点混乱,需要一些初始状态、模拟等。
当您编写测试以查看您是否正确实现了需求时,您可以使测试更能抵抗生产代码中的更改。目标是编写测试来验证答案是否正确,而无需与生产代码紧密耦合。
11 测试是否明确
在编写测试时,您应该有非常明确的预期结果。测试终端时,不仅要检查HTTP报文体,还要检查状态码是否符合预期。
另一个例子是像Mockito这样的测试框架。无论输入参数如何,Mockito都允许您对方法进行调用。这可能会导致不明确的结果。因此,不要使用 any() 并允许任何值,而是明确并说明您希望传递的值。这将使您的测试更加明确,并使失败更加明显。
@Test
void LessExplicit(){
// Given
car = mock(Car.class);
// Here, we don't specify what number we expect but always return a correct response.
when(car.shiftGear(anyInt())).thenReturn(10);
// When
int gear = car.shiftGear(10);
// Then
Assertions.assertEquals(10, gear);
}
@Test
void MoreExplicit(){
// Given
car = mock(Car.class);
// Here, we make explicit what input and output we expect from the mock.
when(car.shiftGear(10)).thenReturn(10);
// When
int gear = car.shiftGear(10);
// Then
Assertions.assertEquals(10, gear);
}
12 测试是否简单
写一个好的测试已经够难了,所以应该使其可读性更高,因为它会被大量阅读。理想情况下,测试应该是显而易见的并明显展示测试目的。测试代码应该纯净并针对直观性和明确性进行优化。为了使您的测试易于理解,您可以尝试遵循本文中的其他建议或在查找网上资源。
两条最重要的建议是:了解您的测试框架的能力;对您的测试和测试代码进行逻辑排序。
引申阅读
更多关于 Java 测试的信息:
如果你想在写代码时了解更多关于Java的信息,可以在Twitter上关注作者:Follow @David Vlijmincx