【问题标题】:Preferable way of making code testable: Dependency injection vs encapsulation使代码可测试的首选方法:依赖注入与封装
【发布时间】:2015-03-12 05:35:48
【问题描述】:

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

我有一个 java 程序,它应该从天气网络服务中获取气温。我将它封装在一个类中,该类创建一个 HttpClient 并向天气服务发出 Get REST 请求。为该类编写单元测试需要存根 HttpClient 以便可以接收虚拟数据。有一些选项如何实现这一点:

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

仅出于测试目的创建setter。默认构造“普通”HttpClient,但也可以使用setter更改HttpClient。

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

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

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

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

【问题讨论】:

  • @Mock 和@RunWith(MockitoJUnitRunner.class) 怎么样?就不能模仿注入的bean吗?

标签: java unit-testing dependency-injection


【解决方案1】:

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

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

这种方法被称为六边形/洋葱结构,我建议在这里阅读:

或者这篇总结核心思想的帖子:

编辑:

进一步了解您的 cmets:

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

这不是因为一个小问题而使代码臃肿,而这个小问题本可以通过使用反射的几行或简单地更改为封装私有字段访问来“修复”?

您在引号中加上“固定”这一事实充分说明了您认为这种解决方案的有效性。 ;) 我同意代码肯定有些臃肿,这起初可能令人不安。但真正的重点是制作一个易于开发的可维护代码库。我认为有些项目起步很快,因为他们通过使用黑客和狡猾的编码实践来“修复”问题以保持进度。由于压倒性的技术债务会导致需要数周甚至数月的大规模重构,因此生产力通常会停滞不前。

一旦您以六边形方式设置了一个项目,当您需要执行以下操作之一时,真正的回报就会出现:

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

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

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

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

  5. 修复错误。 与上述内容相关联,重现性通常是修复的最大步骤。所有集成层都是不可变的、独立的和接受明确的参数的优点是很容易隔离单个故障层并修改参数直到它失败。 (尽管同样,精心设计的代码也能很好地做到这一点)。

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

【讨论】:

  • 我喜欢这个答案并投了赞成票。但有些问题: - 测试 HttpClientWeatherStatus 怎么样?忽略单元测试,否则我们必须找到一种方法来模拟 HttpClient 那里? - 是不是因为一个小问题而使代码膨胀,可以通过使用反射的一些行或简单地更改为封装私有字段访问来“修复”?我不是说你的答案不正确,我只是想听听你的想法。
【解决方案2】:

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

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

例如,这里有一个可能的详细解决方案:

第 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;它将简化生产和测试代码。

【讨论】:

    猜你喜欢
    • 2012-03-20
    • 2012-03-14
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-01-27
    • 1970-01-01
    相关资源
    最近更新 更多