使代码可测试的首选方法:依赖注入与封装 [英] Preferable way of making code testable: Dependency injection vs encapsulation

查看:90
本文介绍了使代码可测试的首选方法:依赖注入与封装的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我经常发现自己想知道这些问题的最佳实践是什么。一个例子:



我有一个Java程序,该程序应该从天气Web服务获取气温。我将其封装在一个类中,该类创建HttpClient并向天气服务发出Get REST请求。为该类编写单元测试需要对HttpClient存根,以便可以代替地接收伪数据。有一些som选项可实现此目的:



构造函数中的依赖注入。这会破坏封装。如果我们改为改用SOAP Web服务,则必须注入SoapConnection而不是HttpClient。



仅出于测试目的而创建设置器。 默认情况下会构造正常 HttpClient,但是也可以使用设置器来更改HttpClient。



反射。 strong>由构造函数设置HttpClient作为私有字段(但不按参数接受),然后让测试使用反射将其更改为存根。



私有包。降低字段限制,使其可以在测试中访问。



在尝试阅读有关该主题的最佳做法时,似乎在我看来,普遍的共识是依赖注入是首选方法,但是我认为打破封装的缺点还没有得到足够的考虑。



给你什么认为使类成为可测试的首选方法吗?


解决方案

我认为最好的方法是通过依赖项注入,但不完全是您描述的方法。而不是直接注入 HttpClient ,而是注入 WeatherStatusService (或某些等效名称)。我将使用一种方法(在您的用例中) getWeatherStatus()使其成为一个简单的界面。然后,您可以使用 HttpClientWeatherStatusService 来实现此接口,并在运行时将其注入。要对核心类进行单元测试,您可以选择使用自己的单元测试要求来实现 WeatherStatusService 或使用模拟框架来模拟 getWeatherStatus 方法。这种方式的主要优点是:


  1. 您不会破坏封装(因为更改为SOAP实现涉及创建 SOAPWeatherStatusService 并删除HttpClient处理程序)。

  2. 您已经分解了最初的单个类,现在有两个目的明确的类,一个类显式处理从API检索的数据,另一个类处理核心逻辑。可能是这样的流程:接收天气状态请求(从更高处)->从api请求数据检索->处理/验证返回的数据->(可选)存储数据或触发其他处理以对数据进行操作->返回数据。

  3. 如果出现不同的用例来利用此数据,则可以轻松地重用 WeatherStatusService 实现。 (例如,也许您有一个用例每4小时存储一次天气状况(向用户显示一天的动态变化图),而另一个用例则可以获取当前天气。在这种情况下,您需要两个都需要使用同一API的不同核心逻辑要求,因此使这些方法之间的API访问代码保持一致是很有意义的。)

这种方法被称为六角形/洋葱形结构,我建议在这里阅读以下内容:





或者此帖子总结了核心思想:





编辑:



进一步您的评论:


如何测试HttpClientWeatherStatus?忽略单元测试,否则我们必须找到一种模拟HttpClient的方法?


使用 HttpClientWeatherStatus 类。理想情况下,它应该是不可变的,因此在创建时将 HttpClient 依赖项注入到构造函数中。这使单元测试变得容易,因为您可以模拟 HttpClient 并防止与外界的任何交互。例如:

 公共类HttpClientWeatherStatusService实现WeatherStatusService {
私有最终HttpClient httpClient;

public HttpClientWeatherStatusService(HttpClient httpClient){
this.httpClient = httpClient;
}

public WeatherStatus getWeatherStatus(String location){
//设置请求。
//使用注入的httpClient发出请求。
//解析响应。
返回新的WeatherStatus(温度,湿度,weatherType);
}
}

返回的 WeatherStatus 事件为:

 公共类WeatherStatus {
私有最终浮动温度;
私人最终浮标湿度;
private final String weatherType;
//构造函数和获取方法。
}

然后测试看起来像这样:

  public WeatherStatusServiceTests {
@Test
public void GivenALocation_WhenAWeatherStatusRequestIsMade_ThenTheCorrectStatusForThatLocationIsReturned(){
//设置测试。
//创建httpClient模拟。
字符串位置=世界;
//创建预期的响应。
//期望请求包含位置,返回响应。
WeatherStatusService服务=新的HttpClientWeatherStatusService(httpClient);
//重播模拟。

//运行测试。
WeatherStatus状态= service.getWeatherStatus(位置);

//验证测试。
//声明状态包含正确解析的响应。
}
}

您通常会发现很少有条件集成层中的循环(因为这些构造代表逻辑,并且所有逻辑都应该在核心中)。因此(特别是因为在调用代码中只有一条条件分支路径),有些人会认为几乎没有点单元测试该类,并且可以轻松地将其包含在集成测试中,并且以一种不太脆弱的方式。我理解这种观点,并且在集成层中跳过单元测试没有问题,但是我个人还是会对其进行单元测试。这是因为我相信集成域中的单元测试仍然可以帮助我确保班级的可用性和可移植性/可重用性(如果易于测试,则可以从代码库的其他位置轻松使用)。我还将单元测试用作详细说明该类用法的文档,其优势在于,只要文档过时,任何CI服务器都会提醒我。


难道不是因为一个小问题而使代码膨胀,而这个小问题本可以通过使用反射或仅更改为打包私有字段访问的几行来修复的?


在引号中加上固定的事实充分说明了您认为这种解决方案的有效性。 ;)我同意代码肯定会有些膨胀,乍一看这可能会令人不安。但是,真正的意义是制作易于开发的可维护代码库。我认为有些项目起步很快,因为它们通过使用黑客和狡猾的编码实践来解决问题以保持步伐。由于压倒性的技术债务导致变化,生产力通常会停顿下来,这应该是猛烈的重构的衬托,需要数周甚至数月的时间。



一旦有了项目以六边形方式设置时,需要执行以下操作之一时,实际收益才会出现:


  1. 更改集成层之一的技术堆栈。(例如,从mysql到postgres)。在这种情况下(如上所述),您只需实现一个新的持久层,以确保使用绑定/事件/适配器层中的所有相关接口。无需更改核心代码或接口。最后删除旧层,然后将新层注入到位。


  2. 添加新功能。通常集成层已经存在,甚至可能不需要修改就可以使用。在上面的 getCurrentWeather() store4HourlyWeather()用例的示例中。假设您已经使用上述类实现了 store4HourlyWeather()功能。要创建此新功能(假设流程以轻松的请求开始),您需要制作三个新文件。在Web层中需要一个新类来处理初始请求,在核心层中需要一个新类来表示 getCurrentWeather()的用户故事,并且您需要绑定/事件/适配器层中的接口,核心类实现了该接口,并且Web类已将其注入到其构造函数中。现在,一方面,是的,您只可能创建一个文件,甚至只是将其添加到现有的静态Web处理程序上就创建了3个文件。当然,您可以,并且在这个简单的示例中可以正常工作。只是随着时间的推移,层之间的区别才变得明显,重构也变得困难。考虑将其添加到现有类上的情况,该类不再具有明显的单一目的。你会怎么称呼它?谁会知道在其中查找此代码?


  3. 更新集成层更改,您的测试设置变得多么复杂,以便您现在可以测试该类?

  4. 。根据上面的示例,如果天气服务API(您从中获取信息的地方)发生了变化,那么只有一个地方需要更改程序以再次与新API兼容。 。这是代码中唯一知道数据实际来自哪里的地方,因此也是唯一需要更改的地方。


  5. 介绍项目可以争辩,因为任何布局合理的项目都非常容易理解,但是到目前为止,我的经验是大多数代码看起来都很简单易懂。它成就了一件事,并且非常擅长达成那件事。理解(例如)查找与Amazon-S3相关的代码的地方很明显,因为有一个专门用于与之交互的整个层,并且该层中将没有与其他集成问题有关的代码。


  6. 修复错误。与上述链接在一起,通常可重复性是修复的最大步骤。所有集成层都是不可变的,独立的并且接受明确的参数,其优点是很容易隔离单个故障层并修改参数直到故障。 (尽管同样,精心设计的代码也可以做到这一点。)


我希望我已经回答了您的问题,让我知道是否还有更多。 :)也许我会考虑在周末创建一个六角形示例项目,并将其链接到此处以更清楚地说明我的观点。


I often find myself wondering what is the best practice for these problems. An example:

I have a java program which should get the air temperature from a weather web service. I encapsulate this in a class which creates a HttpClient and does a Get REST request to the weather service. Writing a unit test for the class requires to stub the HttpClient so that dummy data can be received in stead. There are som options how to implement this:

Dependency Injection in constructor. This breaks encapsulation. If we switch to a SOAP web service in stead, then a SoapConnection has to be injected instead of HttpClient.

Creating a setter only for the purpose of testing. The "normal" HttpClient is constructed by default, but it is also possible to change the HttpClient by using the setter.

Reflection. Having the HttpClient as a private field set by the constructor (but not taking it by parameter), and then let the test use reflection to change it into a stubbed one.

Package private. Lower the field restriction to make it accessible in test.

When trying to read about best practices on the subject it seems to me that the general consensus is that dependency injection is the preferred way, but I think the downside of breaking encapsulation is not given enough thought.

What to you think is the preferred way to make a class testable?

解决方案

I believe the best way is through dependency injection, but not quite the way you describe. Instead of injecting an HttpClient directly, instead inject a WeatherStatusService (or some equivalent name). I would make this a simple interface with one method (in your use case) getWeatherStatus(). Then you can implement this interface with an HttpClientWeatherStatusService, and inject this at runtime. To unit test the core class, you have a choice of stubbing the interface yourself by implementing the WeatherStatusService with your own unit testing requirements, or using a mocking framework to mock the getWeatherStatus method. The main advantages of this way are that:

  1. You don't break encapsulation (because changing to a SOAP implementation involves creating a SOAPWeatherStatusService and deleting the HttpClient handler).
  2. You have broken your initial single class down, and now have two classes with a distinct purpose, one class explicitly handles retrieving the data from an API, the other class handles the core logic. This will probably be a flow like: Receive weather status request (from higher up) -> request data retrieval from api -> process/validate the returned data -> (optionally) store data or trigger other processes to operate on the data -> return the data.
  3. You can re-use the WeatherStatusService implementation easily if a different use case emerges to utilise this data. (For example, perhaps you have one use case to store the weather conditions every 4 hours (to show the user an interactive map of the days' developments), and another use case to get the current weather. In this case, you need two different core logic requirements which both need to use the same API, so it makes sense to have the API access code consistent between these approaches).

This method is known as hexagonal/onion architecture which I recommend reading about here:

Or this post which sums the core ideas up:

EDIT:

Further to your comments:

What about testing the HttpClientWeatherStatus? Ignore unit testing or else we have to find a way to mock HttpClient there?

With the HttpClientWeatherStatus class. It should ideally be immutable, so the HttpClient dependency is injected into the constructor on creation. This makes unit testing easy because you can mock HttpClient and prevent any interaction with the outside world. For example:

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);
    }
}

Where the returned WeatherStatus 'Event' is:

public class WeatherStatus {
    private final float temperature;
    private final float humidity;
    private final String weatherType;
    //Constructor and getters.
}

Then the tests look something like this:

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.
    }
}

You will generally find that there will be very few conditionals and loops in the integration layers (because these constructs represent logic, and all logic should be in the core). Because of this (specifically because there will only be a single conditional branching path in the calling code), some people would argue that there is little point unit testing this class, and that it can be covered by an integration test just as easily, and in a less brittle way. I understand this viewpoint, and don't have a problem with skipping unit tests in the integration layers, but personally I would unit test it anyway. This is because I believe unit tests in an integration domain still help me ensure that my class is highly usable, and portable/re-usable (if it's easy to test, then it's easy to use from elsewhere in the codebase). I also use unit tests as documentation detailing the use of the class, with the advantage that any CI server will alert me when the documentation is out of date.

Isn't it bloating the code for a small problem which could have been "fixed" by just some lines using reflection or simply changing to package private field access?

The fact that you put "fixed" in quotes speaks volumes about how valid you think such a solution would be. ;) I agree that there is definitely some bloat to the code, and this can be disconcerting at first. But the real point is to make a maintainable codebase which is easy to develop for. I think some projects start fast because they "fix" problems by using hacks and dodgy coding practices to maintain the pace. Often productivity grinds to a halt as the overwhelming technical debt renders changes which should be one liners into mammoth re-factors which take weeks or even months.

Once you have a project set up in a hexagonal way, the real payoffs come when you need to do one of the following:

  1. Change the technology stack of one of your integration layers. (e.g. from mysql to postgres). In this case (as touched on above), you simply implement a new persistence layer making sure you use all the relevant interfaces from the binding/event/adapter layer. There should be no need to change core code or the interface. Finally delete the old layer, and inject the new layer in place.

  2. Add a new feature. Often integration layers will already exist, and may not even need modification to be used. In the example of the getCurrentWeather() and store4HourlyWeather() use-cases above. Let's assume you've already implemented the store4HourlyWeather() functionality using the class outlined above. To create this new functionality (let's assume the process begins with a restful request), you need to make three new files. You need a new class in your web layer to handle the initial request, you need a new class in your core layer to represent the user story of getCurrentWeather(), and you need an interface in your binding/event/adaptor layer which the core class implements, and the web class has injected to its constructor. Now on the one hand, yes, you've created 3 files when it would have been possible to create only one file, or even just tack it onto an existing restful web handler. Of course you could, and in this simple example that would work fine. It is only over time that the distinction between layers become obvious and refactors become hard. Consider in the case where you tack it onto an existing class, that class no longer has an obvious single purpose. What will you call it? How will anyone know to look in it for this code? How complicated is your test set-up becoming so that you can test this class now that there are more dependencies to mock?

  3. Update integration layer changes. Following on from the example above, if the weather service API (where you are getting your information from) changes, there is only one place where you need to make changes in your program to be compatible with the new API again. This is the only place in the code which knows where the data actually comes from, so it's the only place which needs changing.

  4. Introduce the project to a new team member. Arguable point, since any well laid out project will be fairly easy to understand, but my experience so far has been that most code looks simple and understandable. It achieves one thing, and it's very good at achieving that one thing. Understanding where to look (for example) for Amazon-S3 related code is obvious because there is an entire layer devoted to interacting with it, and this layer will have no code in it relating to other integration concerns.

  5. Fix bugs. Linked to the above, often reproducibility is the biggest step towards a fix. The advantage of all the integration layers being immutable, independent, and accepting clear parameters, is that it is easy to isolate a single failing layer and modify the parameters until it fails. (Although again, well designed code will do this well too).

I hope I've answered your questions, let me know if you have more. :) Perhaps I will look into creating a sample hexagonal project over the weekend and linking to it here to demonstrate my point more clearly.

这篇关于使代码可测试的首选方法:依赖注入与封装的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆