我经常发现自己想知道这些问题的最佳实践是什么.一个例子:
我有一个java程序,应该从天气网络服务获得气温.我将其封装在一个类中,该类创建一个HttpClient并向天气服务执行Get REST请求.为类编写单元测试需要对HttpClient进行存根,以便可以接收伪数据.有几个选项如何实现这个:
构造函数中的依赖注入.这破坏了封装.如果我们切换到SOAP Web服务,则必须注入SoapConnection而不是HttpClient.
仅为测试目的创建setter.默认情况下构造"普通"HttpClient,但也可以通过使用setter来更改HttpClient.
反射.将HttpClient作为由构造函数设置的私有字段(但不是通过参数获取),然后让测试使用反射将其更改为存根字段.
打包私密.降低字段限制以使其在测试中可访问.
在试图阅读有关该主题的最佳实践时,在我看来,普遍的共识是依赖注入是首选方式,但我认为打破封装的缺点是没有给予足够的思考.
您认为什么是使类可测试的首选方法?
我认为最好的方法是通过依赖注入,但不是你描述的方式.而不是HttpClient
直接注入,而是注入WeatherStatusService
(或一些等效的名称).我会用一个方法(在你的用例中)使这个简单的接口getWeatherStatus()
.然后,您可以使用a实现此接口HttpClientWeatherStatusService
,并在运行时注入此接口.要对核心类进行单元测试,您可以选择通过实现WeatherStatusService
具有自己的单元测试要求或使用模拟框架来模拟getWeatherStatus
方法来自行存取接口.这种方式的主要优点是:
您不会破坏封装(因为更改为SOAP实现涉及创建SOAPWeatherStatusService
和删除HttpClient处理程序).
你已经打破了你的初始单个类,现在有两个具有不同目的的类,一个类显式处理从API检索数据,另一个类处理核心逻辑.这可能是一个流程:接收天气状态请求(从更高的位置) - >从api请求数据检索 - >处理/验证返回的数据 - >(可选)存储数据或触发其他进程来操作数据 - >返回数据.
WeatherStatusService
如果出现不同的用例来利用这些数据,您可以轻松地重复使用该实现.(例如,也许您有一个用例来每4小时存储一次天气情况(向用户显示日期开发的交互式地图),以及另一个用例来获取当前天气.在这种情况下,您需要两个不同的核心逻辑要求都需要使用相同的API,因此在这些方法之间使API访问代码保持一致是有意义的.
这种方法被称为六角形/洋葱结构,我建议在这里阅读:
http://alistair.cockburn.us/Hexagonal+architecture
http://jeffreypalermo.com/blog/the-onion-architecture-part-1/
或者这篇文章总结了核心思想:
http://blog.8thlight.com/uncle-bob/2012/08/13/the-clean-architecture.html
编辑:
继续你的意见:
那么测试HttpClientWeatherStatus呢?忽略单元测试,否则我们必须找到一种模拟HttpClient的方法吗?
随着HttpClientWeatherStatus
课程.它理想情况下应该是不可变的,因此HttpClient
在创建时将依赖注入到构造函数中.这使得单元测试变得简单,因为您可以模拟HttpClient
并阻止与外界的任何交互.例如:
public class HttpClientWeatherStatusService implements WeatherStatusService { private final HttpClient httpClient; public HttpClientWeatherStatusService(HttpClient httpClient) { this.httpClient = httpClient; } public WeatherStatus getWeatherStatus(String location) { //Setup request. //Make request with the injected httpClient. //Parse response. return new WeatherStatus(temperature, humidity, weatherType); } }
返回的WeatherStatus
"事件"是:
public class WeatherStatus { private final float temperature; private final float humidity; private final String weatherType; //Constructor and getters. }
然后测试看起来像这样:
public WeatherStatusServiceTests { @Test public void givenALocation_WhenAWeatherStatusRequestIsMade_ThenTheCorrectStatusForThatLocationIsReturned() { //SETUP TEST. //Create httpClient mock. String location = "The World"; //Create expected response. //Expect request containing location, return response. WeatherStatusService service = new HttpClientWeatherStatusService(httpClient); //Replay mock. //RUN TEST. WeatherStatus status = service.getWeatherStatus(location); //VERIFY TEST. //Assert status contains correctly parsed response. } }
您通常会发现集成层中的条件和循环很少(因为这些构造代表逻辑,所有逻辑都应该在核心中).正因为如此(特别是因为在调用代码中只有一个条件分支路径),有些人会认为这个类没有点单元测试,并且它可以通过集成测试轻松覆盖,并且以一种不那么脆弱的方式.我理解这个观点,并且在集成层中跳过单元测试没有问题,但我个人无论如何都会对它进行单元测试.这是因为我相信集成域中的单元测试仍然可以帮助我确保我的类具有高可用性,可移植/可重用(如果它易于测试,那么它很容易从代码库中的其他地方使用).我还使用单元测试作为详细说明类的使用的文档,其优点是任何CI服务器都会在文档过期时提醒我.
是不是因为一个小问题的代码膨胀,而这个小问题本来可以通过一些使用反射或仅仅更改为打包私有字段访问的行来"修复"?
将"固定"放在引号中这一事实说明了您认为这种解决方案的有效性.;)我同意代码肯定存在一些膨胀,这一开始可能令人不安.但真正的要点是创建一个易于开发的可维护代码库.我认为一些项目开始很快,因为他们通过使用黑客和狡猾的编码实践来"解决"问题以保持速度.生产力往往停滞不前,因为压倒性的技术债务使得变化成为一个巨大的重新因素,需要数周甚至数月.
一旦您以六边形方式设置项目,当您需要执行以下操作之一时,即可获得真正的收益:
更改其中一个集成层的技术堆栈.(例如从mysql到postgres).在这种情况下(如上所述),您只需实现一个新的持久层,确保使用绑定/事件/适配器层中的所有相关接口.不需要更改核心代码或界面.最后删除旧图层,并将新图层注入到位.
添加新功能.通常,集成层已经存在,甚至可能不需要修改即可使用.在上面的例子getCurrentWeather()
和store4HourlyWeather()
用例中.假设您已经store4HourlyWeather()
使用上面列出的类实现了功能.要创建这个新功能(让我们假设该过程以一个宁静的请求开始),您需要创建三个新文件.您需要在Web层中使用新类来处理初始请求,您需要在核心层中使用新类来表示用户故事getCurrentWeather()
,并且您需要核心类实现的绑定/事件/适配器层中的接口,并且Web类已经注入其构造函数.现在一方面,是的,你已经创建了3个文件,只能创建一个文件,或者甚至只是将它添加到现有的restful web处理程序上.当然你可以,在这个简单的例子中,它可以正常工作.只是随着时间的推移,层之间的区别变得明显,重构变得困难.考虑在将其添加到现有类的情况下,该类不再具有明显的单一目的.你会怎么称呼它?怎么会有人知道这个代码?您的测试设置变得多么复杂,以至于您可以测试此类,因为有更多的依赖项需要模拟?
更新集成层更改.继上面的示例之后,如果天气服务API(您从中获取信息)发生变化,则只有一个地方需要在程序中进行更改以便再次与新API兼容.这是代码中唯一知道数据实际来源的地方,因此它是唯一需要更改的地方.
将项目介绍给新的团队成员.可以说,因为任何布局合理的项目都很容易理解,但到目前为止我的经验是大多数代码看起来简单易懂.它实现了一件事,并且非常擅长实现这一点.了解Amazon-S3相关代码的位置(例如)是显而易见的,因为有一整层用于与之交互,而且该层中没有与其他集成问题相关的代码.
修复错误.与上述相关联,通常可重复性是解决问题的最重要步骤.所有集成层都是不可变的,独立的,并且接受清晰的参数的优点是,很容易隔离单个故障层并修改参数直到它失败.(尽管如此,设计良好的代码也可以做得很好).
我希望我已经回答了你的问题,如果你有更多,请告诉我.:)也许我会考虑在周末创建一个样本六边形项目并在此链接以更清楚地展示我的观点.