做为一个骄傲的 JavaScript 程序员,在写 Java 代码时还真不适应。最近,我终于在 MockWebServer 上折腾了一番,总算是有一丢丢适应了,感叹不同的语言有不同的特性,也有不同的限制,因为在做一些相似的事情时,思路和方法也不尽相同。真的是八仙过海,各显神通。
对于一个会向外发送 HTTP 请求的服务,如果要写自动化测试,不免会希望能够模拟外部服务的响应,从而测试会更加受控,并且也更加快速。在 JavaScript 项目中,很自然会使用 nock。如果是在 Java 项目中,则可以使用 MockWebServer。
nock
nock 的实现细节我还没有研究过,它的强大之处在于,不用管待测试的服务是怎么实现的,只要知道它在执行过程中会对某个第三方服务进行请求的话,就可以使用 nock 接管并进行响应模拟。
比如某个待测试的代码,依赖微信的服务,那么就可以通过 nock(https://api.weixin.qq.com)来实现对微信服务的响应模拟,更完整的代码如下:
typescript
nock(https://api.weixin.qq.com).post(/cgi-bin/stable_token, { grant_type: client_credential, appid: /.+/, secret: /.+/, force_refresh: false }).reply(200, mockTokenRes)
nock(https://api.weixin.qq.com) .filteringRequestBody(/.*/, *) .post(/cgi-bin/material/add_material?access_token=${mockTokenRes.access_token}&type=image, *) .reply(200, {})
nock(https://api.weixin.qq.com) .filteringRequestBody(/.*/, *) .post(/cgi-bin/draft/add?access_token=${mockTokenRes.access_token}) .reply(200, { item: [], media_id: H3tPf5wuG9Gu8tD8v7bpjWB8seLdmWdiF_LpGF0-wQzCU8XBbfiwOYgRxd8qzGqu })
nock(https://api.weixin.qq.com) .filteringRequestBody(/.*/, *) .post(/cgi-bin/media/uploadimg?access_token=${mockTokenRes.access_token}&type=image) .reply(200, { item: [], media_id: the-media-id })
以上代码片段,模拟了微信公众号的好几个 API 的响应。更完整的代码详见: https://github.com/Jeff-Tian/mp/blob/e85509ee1c45d4644b86630f58011676980abd8a/test/yuque.js#L19。
nock 的强大之处在于,即使待测试代码写死了这个第三方 URL,仍然能够轻松模拟响应。前面说了,完全不需要关心具体实现嘛。给人的感觉是,nock 能够站在你的实现代码和目标服务器之间,你的实现代码发出的每一条请求,它都可以打开检查,一旦发现匹配了某个请求目标,就能够拦截,返回一个假的响应给到你的实现代码,而你的实现代码对此一无所知。
MockWebServer
在 Java 项目里,似乎做不到这么简单。不过,这种测试需求普遍存在,从而有很多方案,其中之一就是 MockWebServer。
要使用 MockWebServer 来模拟外部第三方服务的响应,首先需要确保最终依赖第三方服务的实现中,没有在代码里写死第三方服务的 URL,而是可以通过依赖注入的方式,轻松修改 URL。一旦写死,就不好测了。话说回来,如果发现测试难写,就说明设计上有问题,代码有坏味道,需要修改实现代码。
能够通过依赖注入的方式来修改 URL 非常重要,原因是 MockWebServer 不像 nock 一样可以做一个中间人。相反,MockWebServer 启动在 localhost 的某个端口上,只有通过依赖注入替换掉原始外部服务的 URL(最重要的是主机名和端口),才能让实现代码主动将请求发给它,以便让它返回假的响应。
于是,要使用 MockWebServer,就会是这样的情况:
为了注入 MockWebServer 而写一些假类(或者叫测试替身?)
首先,要找到依赖外部服务的一个类,以便对其进行注入。有时候,不得不另外写一些假的类,并继承自这些真正的类,并在测试过程中使用假类替换掉真正的类。
由于假类是继承自真正的实现类的,所以是可以通过依赖注入无缝替换的。如果不行,同样要反思一下实现代码,是不是没有遵循里氏替换原则?
举个例子,写了一个 API 接口,它调用 authing.cn 的 API,然后做一些数据组装后返回。实现代码使用了 authing.cn 提供的 SDK,我们希望不改动 SDK 的情况下,设置一些 authing.cn 服务器的模拟响应,就需要写一些假的类来替换掉 SDK 中的真正实现类。
比如写了一个 /friends 接口,它依赖 authing 的 ManagementClient 类:
java private final ManagementClient managementClient; private final String authingAppId;
@Autowired
public AuthingUserManagementService(ManagementClient managementClient, @Value(${authing.appId}) String authingAppId) {
this.managementClient = managementClient;
this.authingAppId = authingAppId;
}
其实现代码的核心是:
java
@Override
public UserPaginatedRespDto listByAppId(String appId, int page, int pageSize) {
var advancedFilterItemDto = new ListUsersAdvancedFilterItemDto();
advancedFilterItemDto.setField(loggedInApps);
advancedFilterItemDto.setOperator(ListUsersAdvancedFilterItemDto.Operator.IN);
advancedFilterItemDto.setValue(List.of(appId));
var reqDto = new ListUsersRequestDto();
reqDto.setAdvancedFilter(List.of(advancedFilterItemDto));
var option = new ListUsersOptionsDto();
var sorting = new SortingDto();
sorting.setField(SortingDto.Field.LAST_LOGIN);
sorting.setOrder(SortingDto.Order.DESC);
var pagination = new PaginationDto();
pagination.setPage(page);
pagination.setLimit(pageSize);
option.setSort(List.of(sorting));
option.setPagination(pagination);
reqDto.setOptions(option);
return this.managementClient.listUsers(reqDto);
}
以上逻辑不重要,重要的是我们希望在最后一行,在自动化测试中调用 listUsers 时,能够返回一个预先设置好的模拟响应。
于是,就要写一个假的 ManagementClient 继承自真正的 ManagementClient,以便在测试时用假的替换真的。尝试写这个假的 ManagementClient 时,发现它需要一个 ManagementClientOptions 参数,这个参数可以用来设置主机名等等必要的参数。找到这里,才是真正找到了我们需要注入 MockWebServer 的地方!
java package com.brickpets.user.service.impl.fakes;
import cn.authing.sdk.java.model.ManagementClientOptions; import okhttp3.mockwebserver.MockWebServer;
public class FakeManagementClientOptions extends ManagementClientOptions { public FakeManagementClientOptions(MockWebServer server) { this.setAccessKeyId(accessKeyId); this.setAccessKeySecret(access);
this.setHost(http://%s:%d.formatted(server.getHostName(), server.getPort()));
}
}
有了假的 FakeManagementClientOptions,就可以写假的 ManagementClient 了:
java package com.brickpets.user.service.impl.fakes;
import cn.authing.sdk.java.client.ManagementClient; import okhttp3.mockwebserver.MockWebServer;
public class FakeManagementClient extends ManagementClient { public FakeManagementClient(MockWebServer server) { super(new FakeManagementClientOptions(server)); } }
在测试中注入写好的假类(或者叫测试替身?)
写一个 TestConfig,它会在测试时起作用,将这些假类替换掉真实的类。
java package com.brickpets.user.config;
import com.brickpets.user.service.impl.fakes.FakeManagementClient; import okhttp3.mockwebserver.MockWebServer; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary;
@TestConfiguration public class TestConfig { @Bean @Primary public FakeManagementClient fakeManagementClient(MockWebServer mockWebServer) { return new FakeManagementClient(mockWebServer); }
@Bean
public MockWebServer mockWebServer() {
return new MockWebServer();
}
}
在测试中设置模拟响应
到这一步其实几乎和使用 nock 一样的,但就是不像 nock 可以直接就用,而需要上面的步骤来做一些铺垫。最终的测试大约长这样:
总结
JavaScript 语言灵活真的是体现在各个方面,连测试也是。这种灵活让人沉浸久了之后,很难适应类似 Java 这种语言。似乎在写 Java 时,才能更加深刻地体会到设计模式、面向对象编程原则的重要性,写了 Java 之后,也能更加体会到设计模式、编程原则可能都是被“逼”出来的。