旧版代码和测试气味
使用单元测试可以在许多方面帮助改善代码库。
我最喜欢的方面之一是,测试可以使我们指出生产代码中的代码味道。
例如,如果测试需要大型设置或声明许多输出,则可能表明被测单元未遵循良好的设计,例如SRP和其他OOD。
但是有时测试本身的结构或设计不佳。
在这篇文章中,我将针对这种情况给出两个示例,并说明如何解决。
测试类型
(或图层)
有几种类型的测试。
- 单元测试
单元测试应该易于描述和理解。 这些测试应该运行得很快 。 他们应该测试一件事。 一个单元(方法?)。 - 整合测试
集成测试的定义更加模糊。 他们检查哪种模块? 几个模块集成在一起? 依赖注入器接线? 使用真实数据库进行测试? - 行为测试
这些测试将验证功能。 它们可能是PM / PO与开发团队之间的接口。 - End2End /接受/分期/功能
高水平的测试。 可以在生产或类似生产的环境上运行。
测试的复杂性
基本上,测试的“级别越高”,它越复杂。 而且,每个测试级别的可能测试数量与生产代码之间的比率会急剧增加。 单元测试将随着代码的增长而线性增长。 但是从集成测试和更高级别的测试开始,选项开始以指数级增长。
计算简单:如果两个类相互交互,并且每个类都有2个方法,那么我们应该检查多少个选项才能覆盖所有选项? 并设想这些方法具有某些控制流程,例如if 。
偶尔失败的测试
有很多原因使测试“有问题”。 最糟糕的情况之一是测试有时会失败且通常会通过。 团队将忽略CI的邮件。 它会在系统中产生噪音。 您永远无法确定是否有错误或损坏的东西或错误的警报。 最终,我们将禁用配置项,因为“它不起作用并且不值得花时间”。
集成测试和虚警
如果我们不遵守基本规则,则任何类型的测试均会产生误报。 测试级别越高,错误警报的可能性就越大。 在集成测试中,由于外部资源问题而导致误报的可能性更高:没有互联网连接,没有数据库连接,随机丢失等等。
我们的测试环境
我们的系统是“准遗产”。 它不是完全遗留的,因为它具有测试。 那些测试甚至具有良好的覆盖率。 由于它的(非)结构化方式和测试的构建方式,它是传统的。 它过去仅由集成测试覆盖。 在过去的几个月中,我们开始实施单元测试。 特别是在新代码和新功能上。
我们所有的集成测试都继承自BaseTest ,后者继承了Spring的AbstractJUnit4SpringContextTests 。 测试的上下文连接了所有内容。 大约95%的生产代码。 它需要时间,但更糟的是,它连接到实际的外部资源,例如MongoDB和连接到Internet的服务。
为了提高测试速度,几周前,我将MongoDB更改为嵌入式。 它将测试的运行时间缩短了一个数量级。
这种设置会使测试更加困难。 模拟服务非常困难。 环境不是与Internet和DB等隔离的。
在漫长的介绍之后,我想描述两个有问题的测试以及修复它们的方式。 他们共同的失败属性是他们有时会失败并且通常会通过。 但是,每个失败的原因都不相同。
案例研究1 –在构造函数中创建Internet连接
第一个示例显示了一个测试,该测试有时由于连接问题而失败。 棘手的部分是,在构造函数中创建了一个服务。 该服务获得了HttpClient,它也是在构造函数中创建的。
另一个问题是,我无法修改测试以使用模拟代替Spring接线。 这是原始的构造函数(针对示例进行了修改):
private HttpClient httpClient;
private MyServiceOne myServiceOne;
private MyServiceTwo myServiceTwo;
public ClassUnderTest(PoolingClientConnectionManager httpConnenctionManager, int connectionTimeout, int soTimeout) {
HttpParams httpParams = new BasicHttpParams();
HttpConnectionParams.setConnectionTimeout(httpParams, connectionTimeout);
HttpConnectionParams.setSoTimeout(httpParams, soTimeout);
HttpConnectionParams.setTcpNoDelay(httpParams, true);
httpClient = new DefaultHttpClient(httpConnenctionManager, httpParams);
myServiceOne = new MyServiceOne(httpClient);
myServiceTwo = new MyServiceTwo();
}
经测试的方法使用了myServiceOne 。 由于该服务中的连接问题,测试有时会失败。 另一个问题是,它并不总是确定性的(来自网络的结果),因此失败了。
编写代码的方式无法使我们模拟服务。
在测试代码中,使用@Autowired批注注入了被测类。
解决方案–
想法取自有效地使用遗留代码 。
- 确定我需要解决的问题。
为了使测试具有确定性,并且无需真正连接到Internet,我需要访问服务以创建服务。 - 我将介绍创建这些服务的受保护方法。
而不是在构造函数中创建服务,我将调用这些方法。 - 在测试环境中,我将创建一个类来扩展被测类。
此类将覆盖这些方法,并将返回假的(模拟的)服务。
解决方案的代码
被测课程
public ClassUnderTest(PoolingClientConnectionManager httpConnenctionManager, int connectionTimeout, int soTimeout) {
HttpParams httpParams = new BasicHttpParams();
HttpConnectionParams.setConnectionTimeout(httpParams, connectionTimeout);
HttpConnectionParams.setSoTimeout(httpParams, soTimeout);
HttpConnectionParams.setTcpNoDelay(httpParams, true);
this.httpClient = createHttpClient(httpConnenctionManager, httpParams);
this.myserviceOne = createMyServiceOne(httpClient);
this.myserviceTwo = createMyServiceTwo();
}
protected HttpClient createHttpClient(PoolingClientConnectionManager httpConnenctionManager, HttpParams httpParams) {
return new DefaultHttpClient(httpConnenctionManager, httpParams);
}
protected MyServiceOne createMyServiceOne(HttpClient httpClient) {
return new MyServiceOne(httpClient);
}
protected MyServiceTwo createMyServiceTwo() {
return new MyServiceTwo();
}
测试班
private MyServiceOne mockMyServiceOne = mock(MyServiceOne.class);
private MyServiceTwo mockMyServiceTwo = mock(MyServiceTwo.class);
private HttpClient mockHttpClient = mock(HttpClient.class);
private class ClassUnderTestForTesting extends ClassUnderTest {
private ClassUnderTestForTesting(int connectionTimeout, int soTimeout) {
super(null, connectionTimeout, soTimeout);
}
@Override
protected HttpClient createHttpClient(PoolingClientConnectionManager httpConnenctionManager, HttpParams httpParams) {
return mockHttpClient;
}
@Override
protected MyServiceOne createMyServiceOne(HttpClient httpClient) {
return mockMyServiceOne;
}
@Override
protected MyServiceTwo createMyServiceTwo() {
return mockMyServiceTwo;
}
}
现在,不用在测试中连接类,而是在@Before方法中创建它。 它接受其他服务(此处未描述)。 我使用@Autowire获得了这些服务。
另一个注意事项:在创建特殊的测试类之前,我运行了该类的所有集成测试,以验证重构没有破坏任何内容。 我还重新启动了本地服务器,并验证了一切正常。 使用旧版代码时,进行这些验证很重要。
案例研究2 –随机输入的统计检验
第二个示例描述了由于随机结果和统计断言而失败的测试。 该代码在具有相似属性的对象之间进行了随机选择(我在这里简化了场景)。 Random对象是在类的构造函数中创建的。
简化示例:
private Random random;
public ClassUnderTest() {
random = new Random();
// more stuff
}
//The method is package protected so we can test it
MyPojo select(List<MyPojo> pojos) {
// do something
int randomSelection = random.nextInt(pojos.size());
// do something
return pojos.get(randomSelection);
}
原始测试进行了统计分析。 我将对其进行解释,因为它太复杂且冗长,无法编写。 它有1万次迭代的循环。 每次迭代都称为被测方法。 它具有一个Map,该Map计算每个MyPojo的出现次数(返回结果)。 然后,它检查每个MyPojo是否在(10K / Number-Of-MyPojo)处选择了某种偏差0.1。
例:
假设我们在列表中有4个MyPojo实例。 然后该断言验证了每个实例在2400到2600次(10K / 4)之间被选择,偏差为10%。
您当然可以期望有时测试会失败。 增加偏差只会减少错误的失败测试的次数。
解决方案–
- 重载被测方法。
在重载方法中,添加一个与全局字段相同的参数。 - 将代码从原始方法移到新方法。
确保使用方法的参数而不是类的字段。 不同的名称可以在这里提供帮助。 - 使用模拟测试新创建的方法。
解决方案代码
private Random random;
// Nothing changed in the constructor
public ClassUnderTest() {
random = new Random();
// more stuff
}
// Overloaded method
private select(List<MyPojo> pojos) {
return select(pojos, this.random);
}
//The method is package protected so we can test it
MyPojo select(List<MyPojo> pojos, boolean inRandom) {
// do something
int randomSelection = inRandom.nextInt(pojos.size());
// do something
return pojos.get(randomSelection);
}
结论
使用遗留代码可能会充满挑战并且很有趣。 使用遗留测试代码也可能很有趣。 停止接收失败的测试令人讨厌的邮件真的很不错。 这也增加了团队对CI流程的信任。
翻译自: https://www.javacodegeeks.com/2015/03/working-with-legacy-test-code.html