在工作过程中,我注意到一些常见的错误步骤,它们使单元测试变得无效、冗长、难以维护,而且写起来很麻烦。
这篇文章提供了一些建议,以避免一些可能被忽视的错误,特别是对于经验不足的开发者。
请注意,本文中提到的一些例子是在Java中,另一些是在Javascript中,但其原则大多是可以互换的。
重置Mocks
许多单元测试需要使用一些模拟来模拟外部状态或行为。初始化这些mocks往往涉及到一些常见的步骤,然后配置每个测试所需的状态/行为。
但是,如果不小心,你的测试就会开始混在一起。参考一下这个例子:
const mockExternalStore = {
isValid: true
};
describe('UnderTest', () => {
it('is valid if external store is', () => {
const underTest = new UnderTest(mockExternalStore);
expect(underTest.isValid()).to.be.equal(true);
});
it('is not valid if external store is not', () => {
mockExternalStore.isValid = false;
const underTest = new UnderTest(mockExternalStore);
expect(underTest.isValid()).to.be.equal(false);
});
}
如果按照顺序运行,这些测试将通过。但是如果它们以相反的顺序执行会发生什么呢?第二个测试仍然会通过,但第一个测试会失败。这是因为写这个测试时假设mockExternalStore.isValid == true,就像其初始状态一样。但是第二个测试改变了mock的状态,新的状态会潜移默化地影响到在它之后运行的每个测试。
大多数测试框架,包括Java和Javascript,都提供了一些设置函数,通常命名为beforeEach。我建议你使用它们,以确保每一个测试都从相同的上下文开始。然后,这个例子就变成了:
let mockExternalStore;
describe('UnderTest', () => {
beforeEach(() => {
mockExternalStore = {
isValid: true
};
});
it('is valid if external store is', () => {
// I assume mockExternalStore.isValid == true is the agreed starting scenario, so don't assign it again
const underTest = new UnderTest(mockExternalStore);
expect(underTest.isValid()).to.be.equal(true);
});
it('is not valid if external store is not', () => {
mockExternalStore.isValid = false;
const underTest = new UnderTest(mockExternalStore);
expect(underTest.isValid()).to.be.equal(false);
});
}
慎重对待异常
有时我们想测试一些非合规的情况是否会导致一个特定的异常被抛出。这可以很容易做到,但这需要审慎一点。考虑一下这个例子:
@Test
public void whenCallingForbiddenMethodThenThrowCustomException() {
try {
underTest.forbiddenMethod();
} catch (Exception e) {
assertTrue(e instanceof MyCustomException);
}
}
这看起来差不多:如果除了MyCustomException之外的任何东西被抛出,测试就会失败。但是如果forbiddenMethod根本就没有抛出呢?测试仍然会通过,这很可能不是你想要的。
你可以通过两种方式解决这个问题。一些测试框架,如JUnit,为你提供了一个专门设计的功能。那个测试可以像这样固定和简化:
@Test(expectedException = MyCustomException.class)
public void whenCallingForbiddenMethodThenThrowCustomException() {
underTest.forbiddenMethod();
}
如果你没有这样的功能可用,或者如果你想对抛出的异常进行特定检查,则应记住测试失败,以防它没有遵循预期的过程:
@Test
public void whenCallingForbiddenMethodThenThrowCustomException() {
try {
underTest.forbiddenMethod();
fail();
} catch (MyCustomException e) {
assertTrue(e.getCause() instanceof AnotherCustomException);
}
}
倾向于Explicit测试数据
几个测试可以用不同的输入参数进行复制,以涵盖更多的常规和边缘案例。无论你是手动操作还是使用一些花哨的@DataProvider,你必须提供一组输入参数。
想象一下,必须测试一个基于常量密钥的散列函数。
public final String MY_KEY = "4e9aa2e2-0c29-4d01-a4fe-b464fd89ef74";
// Hashes input together with MY_KEY
public String hash(String input) {
...
}
你的测试可能看起来像这样:
@Test(dataProvider = "hashSamples")
public void hashingTest(String input, String expectedDigest) {
assertEquals(underTest.hash(input), expectedDigest);
}
@DataProvider
private Object[][] hashSamples() {
return new Object[][]{
{"input1", "E6d212KuLc0XvXsc"},
{"loooooonginpuuuuuuuuuut", "SCNb9HHscUPzCNHL"},
{"input+with-symbol$", "5ePJWwMwYwQBxLf9"},
{"", "aixwFwUnRmU1405D"},
{null, "4aHg05q4ftFzn7dX"}};
}
但现在你可能会想,如果有人改变了MY_KEY,你不希望测试中断。你可能会想用一些代码来自动生成这些参数。
@Test(dataProvider = "hashSamples")
public void hashingTest(String input, String expectedDigest) {
assertEquals(underTest.hash(input), expectedDigest);
}
@DataProvider
private Object[][] hashSamples() {
return new Object[][]{
buildSample("input1", MY_KEY},
buildSample("loooooonginpuuuuuuuuuut", MY_KEY},
buildSample("input+with-symbol$", MY_KEY},
buildSample("", MY_KEY},
buildSample(null, MY_KEY}};
}
public Object[] buildSample(String input, String key) {
String digest = ... //Does this code look familiar?!?
return new Object[]{input, digest};
}
现在你基本上是在测试中复制了生产代码,这就违背了测试本身的目的。
这是一个相当极端的例子,但这种情况在较小的范围内也会发生,除非你注意到这一点。
不要为了测试目的而改变生产代码
在通常的实践中,开发代码的人有时候也写测试。这就导致了一个错误的假设,即这两者应该是一起成型的。
虽然写测试的困难确实经常反映了代码设计的问题,但相反的情况并不总是如此。
考虑一下这段代码:
public class BlackBox {
public doHouseCleaning() {
sweepTheFloor()
// ... some more code
doTheLaundry()
// ... some more code
doTheDishes()
// ... some more code
}
private sweepTheFloor() {
// ... a long method
}
private doTheLaundry() {
// ... a very long method
}
private doTheDishes() {
// ... an extremely long method
}
}
问题:通过doHouseCleaning()测试BlackBox的所有细微差别是非常困难的。
解决方案:把所有的私有方法变成包私有,这样你就可以独立地测试较小的部分了!
…等等,什么?这听起来不对,不是吗?最有可能的是,你应该把责任分散到多个类中。例如:
public class HouseCleaner {
public doHouseCleaning() {
broom.sweepTheFloor()
// ... some more code
washingMachine.doTheLaundry()
// ... some more code
dishWasher.doTheDishes()
// ... some more code
}
}
public class Broom {
public sweepTheFloor() {
// ...
}
}
public class WashingMachine {
public doTheLaundry() {
// ...
}
}
public class DishWasher {
public doTheDishes() {
// ...
}
}
这只是一个 “修复”生产代码的例子,以使单元测试开发更容易。
所以,当你在编写测试时遇到挑战,试着找出潜在的问题,并修复它,而不是在考虑测试代码的情况下改变生产代码。
最后:如果你平时有很多问题想要解决,你的测试职业规划也需要一点光亮,你也想跟着大家一起分享探讨,我给你
推荐一个「软件测试学习交流群:746506216」 你缺的知识这里有,你少的技能这里有,你要的大牛也在这里……
资源分享【这份资料必须领取~】
下方这份完整的软件测试视频学习教程已经上传CSDN官方认证的二维码,朋友们如果需要可以自行免费领取 【保证100%免费】