使代码可测试的优选方法:依赖注入与封装

时间:2014-04-14 09:22:20

标签: java unit-testing dependency-injection

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

我有一个java程序,应该从天气网络服务获得气温。我将其封装在一个类中,该类创建一个HttpClient并向天气服务执行Get REST请求。为类编写单元测试需要对HttpClient进行存根,以便可以接收伪数据。有几个选项如何实现这个:

构造函数中的依赖注入。这打破了封装。如果我们切换到SOAP Web服务,那么必须注入SoapConnection而不是HttpClient。

仅为测试目的创建一个setter。默认情况下构造“普通”HttpClient,但也可以使用setter更改HttpClient。

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

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

当试图阅读有关该主题的最佳实践时,在我看来,普遍的共识是依赖注入是首选方式,但我认为打破封装的缺点没有给予足够的考虑。

您认为什么是使课程可测试的首选方式?

2 个答案:

答案 0 :(得分:5)

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

  1. 您不会破坏封装(因为更改为SOAP实现涉及创建SOAPWeatherStatusService并删除HttpClient处理程序)。
  2. 你已经打破了你的初始单个类,现在有两个具有不同目的的类,一个类显式处理从API检索数据,另一个类处理核心逻辑。这可能是一个流程:接收天气状态请求(从更高的位置) - >请求从api检索数据 - >处理/验证返回的数据 - > (可选地)存储数据或触发其他过程以对数据进行操作 - >返回数据。
  3. 如果出现不同的用例以利用此数据,您可以轻松地重复使用WeatherStatusService实现。 (例如,也许您有一个用例来每4小时存储一次天气情况(向用户显示日期和开发的交互式地图),以及另一个用例来获取当前天气。在这种情况下,您需要两个不同的核心逻辑要求,它们都需要使用相同的API,因此在这些方法之间使API访问代码保持一致是有意义的。)
  4. 这种方法被称为六角形/洋葱结构,我建议在这里阅读:

    或者这篇文章总结了核心思想:

    修改

    继续你的意见:

      

    测试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服务器都会在文档过期时提醒我。

      

    对于一个本来可以修复的小问题代码是不是很膨胀?#34;通过使用反射的一些行或只是更改为包私有字段访问?

    你把"固定"在引言中说明了你认为这种解决方案的有效性。 ;)我同意代码肯定存在一些膨胀,这一开始可能令人不安。但真正的要点是创建一个易于开发的可维护代码库。我认为有些项目开始很快,因为他们正在修复"通过使用黑客和狡猾的编码实践来保持节奏的问题。生产力通常会停滞不前,因为压倒性的技术债务会导致变化,而这些变化应该成为需要数周甚至数月的巨大重新因素。

    一旦您以六边形方式设置项目,当您需要执行以下操作之一时,真正的收益就来了:

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

    2. 添加新功能。通常,集成层已经存在,甚至可能不需要修改即可使用。在上面的getCurrentWeather()store4HourlyWeather()用例的示例中。我们假设您已经使用上面列出的类实现了store4HourlyWeather()功能。要创建这个新功能(让我们假设该过程以一个宁静的请求开始),您需要创建三个新文件。您需要在Web层中使用新类来处理初始请求,您需要在核心层中使用新类来表示getCurrentWeather()的用户故事,并且在绑定/事件/适配器层中需要一个接口,核心类实现,Web类已注入其构造函数。现在一方面,是的,当你可以创建一个文件时,你已经创建了3个文件,或者甚至只是将它添加到现有的restful web处理程序上。当然你可以,在这个简单的例子中,它可以正常工作。只是随着时间的推移,层之间的区别变得明显,重构变得困难。考虑在将其添加到现有类的情况下,该类不再具有明显的单一目的。你会怎么称呼它?怎么会有人知道这个代码?您的测试设置变得多么复杂,以至于您可以测试此类,因为有更多的依赖项需要模拟?

    3. 更新集成图层更改。继续上面的示例,如果天气服务API(您从中获取信息)发生变化,则只有一个地方需要在程序中进行更改以再次与新API兼容。这是代码中唯一知道数据实际来源的地方,因此它是唯一需要更改的地方。

    4. 将项目介绍给新的团队成员。可以说明,因为任何布局合理的项目都很容易理解,但到目前为止我的经验是大多数代码都很简单并且可以理解。它实现了一件事,并且非常擅长实现这一点。了解Amazon-S3相关代码的位置(例如)是显而易见的,因为有一整层用于与之交互,而且该层中没有与其他集成问题相关的代码。

    5. 修复错误。与上述相关联,通常可重复性是解决问题的最重要步骤。所有集成层都是不可变的,独立的,并且接受清晰的参数的优点是,很容易隔离单个故障层并修改参数直到它失败。 (尽管如此,精心设计的代码也可以做得很好。)

    6. 我希望我已经回答了您的问题,如果您有更多问题请与我联系。 :)也许我会考虑在周末创建一个样本六边形项目,并在此链接到它以更清楚地证明我的观点。

答案 1 :(得分:0)

优选的方法应该有利于适当的封装和其他面向对象的设计质量,同时保持测试中的代码简单。所以,我推荐的方法是:

  1. 考虑一个适合所需类的好的公共API(让我们称之为AirTemperatureMeasurement),这符合系统架构。
  2. 为它编写单元测试(此时失败,因为该类尚未实现)。单元测试必须模拟任何依赖性调用外部Web服务。
  3. 使用通过测试的最简单的解决方案实现测试中的类。
  4. 重复前面的步骤,同时寻找简化代码和删除重复的机会。
  5. 例如,这是一个可能的详细解决方案:

    第1步:

    public final class AirTemperatureMeasurement {
        public double getCelsius() { return 0; }
    }
    

    第2步:

    public final class AirTemperatureMeasurementTest {
        @Tested AirTemperatureMeasurement cut;
        @Capturing HttpClient anyHttpClient;
    
        @Test // a white-box test
        public readAirTemperatureInCelsius() {
            final HttpResponse response = ...suitable response...
    
            new Expectations() {{
                anyHttpClient.request((HttpUriRequest) any);
                result = response;
            }};
    
            double airTemperatureInCelsius = cut.getCelsius();
    
            assertEquals(28.5, airTemperatureInCelsius, 0.0);
        }
    }
    

    第3步:

    public final class AirTemperatureMeasurement {
        public double getCelsius() {
            CloseableHttpClient httpclient = HttpClients.createDefault();
            // Rest ommitted for brevity.
            return airTemperatureInCelsius;
        }
    }
    

    以上使用JMockit模拟库,但PowerMock也是一个选项。 我建议使用java.net.URL(如果可能)而不是Apache的HttpClient;它会简化生产和测试代码。