java - 使代码可测试的首选方法:依赖注入(inject)与封装

标签 java unit-testing dependency-injection

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

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

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

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

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

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

在尝试阅读有关该主题的最佳实践时,在我看来,通常的共识是依赖注入是首选的方式,但是我认为打破封装的缺点并未得到足够的考虑。

您认为使类可测试的首选方法是什么?

最佳答案

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

  • 您不会破坏封装(因为更改为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'Event'是:
    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.
        }
    }
    

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

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

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

    一旦您以六边形方式设置了项目,那么当您需要执行以下一项操作时,真正的收获就来了:
  • 更改集成层之一的技术堆栈。 (例如,从mysql到postgres)。在这种情况下(如上所述),您只需实现一个新的持久层,以确保您使用了绑定/事件/适配器层中的所有相关接口。无需更改核心代码或接口。最后删除旧层,然后将新层注入到位。
  • 添加新功能。 通常集成层已经存在,甚至可能不需要修改就可以使用。在上面的getCurrentWeather()store4HourlyWeather()用例示例中。假设您已经使用上面概述的类实现了store4HourlyWeather()功能。要创建此新功能(假设过程以一个轻松的请求开始),您需要制作三个新文件。在Web层中需要一个新类来处理初始请求,在核心层中需要一个新类来表示getCurrentWeather()的用户故事,并且在绑定/事件/适配器层中需要一个由核心类实现的接口,并且网络类已将其注入构造函数。现在,是的,是的,您只可能创建一个文件,甚至只是将其添加到现有的静态Web处理程序上就创建了3个文件。您当然可以,并且在这个简单的示例中可以正常工作。只是随着时间的推移,层之间的区别才变得明显,重构也变得困难。考虑将其添加到现有类上的情况,该类不再具有明显的单一目的。你会怎么称呼它?谁会知道要在其中查找此代码?您的测试设置变得多么复杂,以便现在可以模拟更多的依赖项,从而可以测试此类?
  • 更新集成层更改。 在上面的示例中,如果天气服务API(您从中获取信息的地方)发生了变化,则只有一个地方需要更改程序以再次与新API兼容。这是代码中唯一知道数据实际来自何处的位置,因此它是唯一需要更改的位置。
  • 将项目介绍给新的团队成员。 这是可以争论的一点,因为任何布局合理的项目都相当容易理解,但是到目前为止,我的经验是大多数代码看起来都很简单易懂。它实现了一件事,并且很好地实现了这一件事。知道(例如)在哪里查找与Amazon-S3相关的代码是显而易见的,因为有一整个层致力于与之交互,并且该层中将没有与其他集成问题有关的代码。
  • 修复错误。 与上面的链接,通常可重复性是修复的最大步骤。所有集成层都是不可变的,独立的并且接受明确的参数,其优点是很容易隔离单个故障层并修改参数直到故障。 (尽管同样,精心设计的代码也可以做到这一点)。

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

    关于java - 使代码可测试的首选方法:依赖注入(inject)与封装,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/23056418/

    相关文章:

    java - 在 Azure CosmosDB Cassandra API 中设置读取操作的一致性级别

    java - Hibernate 无法使用 JBOSS 检索数据

    java - VTDGen 抛出 ParseException : File too big

    spring - MockRestServiceServer 和 java.lang.IllegalArgumentException : No InputStream specified

    python - 尝试实现 python TestSuite

    c# - Simple Injector 条件注入(inject)

    java - Java中设计模式的类库?

    c# - 无法设置 TestContext 属性

    c# - 如何为未注册的服务注入(inject) null

    c# - 依赖注入(inject)在本地 Azure 函数中不起作用