面向可测试性的重构代码:一个例子


在过去的几周里,我在一个遗留项目上工作,这给了我很多关于测试、Mockito和PowerMock的素材。上周,我写了关于abusing PowerMock。然而,这并不意味着你永远不应该使用PowerMock只是如果它的用法很普通,那它就是一种代码气味。在本文中,我想展示一个例子,说明如何在PowerMock的临时帮助下,将遗留代码重构为更易测试的设计。

让我们以下面的代码为例来检查一下如何做到这一点:

public class CustomersReader {

    public JSONObject read() throws IOException {
        String url = Configuration.getCustomersUrl();
        CloseableHttpClient client = HttpClients.createDefault();
        HttpGet get = new HttpGet(url);
        try (CloseableHttpResponse response = client.execute(get)) {
            HttpEntity entity = response.getEntity();
            String result = EntityUtils.toString(entity);
            return new JSONObject(result);
        }
    }
}

请注意Configuration我们无法在第三方图书馆上课。同样,为了简洁起见,我只关心快乐的道路;现实世界的代码可能会因为失败处理而变得更加复杂。

显然,这段代码从这个配置中读取一个超文本传输协议网址,浏览该网址并返回其包装在一个JSONObject中的输出。问题是它很难测试,所以我们最好将其重构为一个更易测试的设计。然而,重构是一个巨大的风险,所以我们必须首先创建测试来确保非回归。更糟糕的是,单元测试在这种情况下没有帮助,因为重构会改变类并破坏现有的测试。

在此之前,我们需要测试来验证现有的行为——无论我们能一起破解什么,即使它们不符合好的实践。有两种选择:

  • 假造:设置一个超文本传输协议服务器来应答超文本传输协议客户端,并设置一个数据库/文件供配置类读取(取决于具体的实现)
  • 模仿者:像往常一样创建模仿者并打断他们的行为

尽管力量模拟很危险,但它没有假货那么脆弱,也不容易安装。因此,让我们从PowerMock开始,但只是作为一种临时措施。目标是同时改进设计和测试。最后,PowerMock将被删除。这个测试是一个好的开始:

@RunWith(PowerMockRunner.class)
public class CustomersReaderTest {

    @Mock private CloseableHttpClient client;
    @Mock private CloseableHttpResponse response;
    @Mock private HttpEntity entity;

    private CustomersReader customersReader;

    @Before
    public void setUp() {
        customersReader = new CustomersReader();
    }

    @Test
    @PrepareForTest({Configuration.class, HttpClients.class})
    public void should_return_json() throws IOException {
        mockStatic(Configuration.class, HttpClients.class);
        when(Configuration.getCustomersUrl()).thenReturn("crap://test");
        when(HttpClients.createDefault()).thenReturn(client);
        when(client.execute(any(HttpUriRequest.class))).thenReturn(response);
        when(response.getEntity()).thenReturn(entity);
        InputStream stream = new ByteArrayInputStream("{ \"hello\" : \"world\" }".getBytes());
        when(entity.getContent()).thenReturn(stream);
        JSONObject json = customersReader.read();
        assertThat(json.has("hello"));
        assertThat(json.get("hello")).isEqualTo("world");
    }
}

此时,测试线束已就位,设计可以更改一点一点地(确保不倒退)。

第一个问题是呼叫Configuration.getCustomersUrl()。让我们介绍一项服务ConfigurationService类作为CustomersReader类和Configuration班级。

public class ConfigurationService {
    public String getCustomersUrl() {
        return Configuration.getCustomersUrl();
    }
}

现在,让我们将这项服务注入到我们的主类中:

public class CustomersReader {

    private final ConfigurationService configurationService;

    public CustomersReader(ConfigurationService configurationService) {
        this.configurationService = configurationService;
    }

    public JSONObject read() throws IOException {
        String url = configurationService.getCustomersUrl();
        // Rest of code unchanged
    }
}

最后,让我们相应地更改测试:

@RunWith(PowerMockRunner.class)
public class CustomersReaderTest {

    @Mock private ConfigurationService configurationService;
    @Mock private CloseableHttpClient client;
    @Mock private CloseableHttpResponse response;
    @Mock private HttpEntity entity;

    private CustomersReader customersReader;

    @Before
    public void setUp() {
        customersReader = new CustomersReader(configurationService);
    }

    @Test
    @PrepareForTest(HttpClients.class)
    public void should_return_json() throws IOException {
        when(configurationService.getCustomersUrl()).thenReturn("crap://test");
        // Rest of code unchanged
    }
}

下一步是切断对静态方法调用的依赖HttpClients.createDefault()。为此,让我们将这个调用委托给另一个类,并将实例注入到我们的类中。

public class CustomersReader {

    private final ConfigurationService configurationService;
    private final CloseableHttpClient client;

    public CustomersReader(ConfigurationService configurationService, CloseableHttpClient client) {
        this.configurationService = configurationService;
        this.client = client;
    }

    public JSONObject read() throws IOException {
        String url = configurationService.getCustomersUrl();
        HttpGet get = new HttpGet(url);
        try (CloseableHttpResponse response = client.execute(get)) {
            HttpEntity entity = response.getEntity();
            String result = EntityUtils.toString(entity);
            return new JSONObject(result);
        }
    }
}

最后一步是完全移除PowerMock。简单极了:

@RunWith(MockitoJUnitRunner.class)
public class CustomersReaderTest {

    @Mock private ConfigurationService configurationService;
    @Mock private CloseableHttpClient client;
    @Mock private CloseableHttpResponse response;
    @Mock private HttpEntity entity;

    private CustomersReader customersReader;

    @Before
    public void setUp() {
        customersReader = new CustomersReader(configurationService, client);
    }

    @Test
    public void should_return_json() throws IOException {
        when(configurationService.getCustomersUrl()).thenReturn("crap://test");
        when(client.execute(any(HttpUriRequest.class))).thenReturn(response);
        when(response.getEntity()).thenReturn(entity);
        InputStream stream = new ByteArrayInputStream("{ \"hello\" : \"world\" }".getBytes());
        when(entity.getContent()).thenReturn(stream);
        JSONObject json = customersReader.read();
        assertThat(json.has("hello"));
        assertThat(json.get("hello")).isEqualTo("world");
    }
}

没有任何力量模拟的痕迹,无论是模拟静态方法还是跑步者。根据我们最初的目标,我们实现了100%测试友好的设计。当然,这是一个非常简单的例子,现实生活中的代码要复杂得多。然而,通过在PowerMock的帮助下一点一点地改变代码,最终有可能实现一个干净的设计。

本文的完整源代码是available on Github