打桩与模拟的区别(以 http client 为例)
缘起
最初是从专业的 TDD 群里得知在测试中还有打桩与模拟的区别,我之前只有一个笼统的概念,当需要控制被依赖方的某些操作的返回结果时(比如调用数据库的保存操作,我希望它在测试中返回一个固定的成功结果;或者调用 rpc 服务,得到某个固定的 json),就需要 mock 这个被依赖方。至于这个 mock,到底是 mock(模拟) 还是 stub(打桩),没有深究。
后来一看 Martin Fowler 的文章,居然对这个我头脑中笼统的 mock,细分了 4 种不同的情况!当然,我并没有看懂,但是在实践中,的确明显感受到了 2 种 mock 的细微区别,这两种,就是 Martin Fowler 所说的打桩和模拟,也就是最早从 TDD 群里得知的这两种 mock。
比如,当被测对象依赖第三方服务,需要使用 http client 调用第三方服务时,在测试中希望固定第三方服务的返回结果,然后测试被测对象的行为。这个时候,就有两种策略,第一是把这个 http client 替换成一个假的 client,拥有和真实 http client 相同的方法,但是这个假对象的对应方法,会立即返回固定的结果。第二是不替换这个 http client,而是利用某些神奇的工具,对目标 url 进行监听,一看到 http client 发了请求到这个 url,立即给到 http client 一个固定返回结果。在我看来,第一个方法是打桩,第二个方法是模拟。
举个例子,你的待测试对象,依赖第三方接口(比如需要调用微信的 api 以获取临时二维码的链接),你的服务需要从微信的接口返回的原始 json 里解析出这个图片地址,在测试中你需要一个固定的原始 json 返回值,通过打桩的方式就是替换发起 http 请求的 client,比如像这样
test('extract qrcode image url', async () => {
class MockHttpClient extends HttpClient {
request: () => ({the: 'fixed json'})
...
}
const mpService = new MpService(new MockHttpClient())
const res = await mpService.getQrCodeImage()
expect(res).toBe('https://qr.url')
})
而通过模拟的方式,则可以引用 nock 这个库,代码如同这样:
import nock from 'nock'
test('extract qrcode image url', async () => {
nock('https://api.weixin.qq.com').post('/qr-code').reply(200, {the: 'fixed json'})
const mpService = new MpService(new RealHttpClient())
const res = await mpService.getQrCodeImage()
expect(res).toBe('https://qr.url')
})
打桩似乎比较土,只要自己写个假的对象,并实现相应的方法即可。模拟比较高级是因为往往需要借助第三方工具,从而不需要自己写假对象,只需要写假的返回结果就行。从实际操作层面看,打桩要写很多代码,但是模拟代码量比较少(其实原因是引入了第三方工具,如果自己实现模拟,需要比打桩更多的代码量)。而打桩也并非只能自己写假对象,也可以引用第三方工具,比如 ts-mockery 就可以帮你完成写模拟对象的事情,你只需要写一下关注的方法的假的实现:
import { Mock } from 'ts-mockery'
test('extract qrcode image url', async () => {
const mockHttpClient = Mock.of<< span="">RealHttpClient>({request: () => ({the: 'fixed json'})})
const mpService = new MpService(mockHttpClient())
const res = await mpService.getQrCodeImage()
expect(res).toBe('https://qr.url')
})
从这个库的使用来看,名字叫 mock,而实际做的是打桩,所以说,打桩和模拟的区别,很多人都没有注意到。
为什么模拟方式可以使用真正的依赖,而改变其行为?我并没有去看 nock 的实现代码,但是它一定也是替换了某些真正对象的,比如对于发送 http 请求来说,很可能在底层有一个全局的对象来做这件事情,我们通常使用的 http client 比如 axios 等,最终还是依赖了这个全局对象,而 nock 就是替换了这个全局对象,从而在测试中,真实 http client 的逻辑也会被走到。(纯属臆测,经不起考证)
太长不看
以我目前粗浅的理解,打桩和模拟有这些区别:
打桩 | 模拟 | |
调用深度 | 浅 被依赖方的方法调用整个被替换了,因此原来的被依赖对象的方法逻辑完全没有被走到 | 深 被依赖的真正对象没有完全被替换,因此部分逻辑还是会走到,只有某些关键部位被替换。 |
介入的接触点 | 表面 | 深层 |
以依赖 http client 的待测对象举例说明 | 替换 http client | 替换 http server |
测试纯度 | 更纯 因为只执行待测对象的代码,而替身代码往往很简单 | 不纯 还会走真正的依赖部分的逻辑 |
适用场景 | 单元测试 | 端到端测试 |
且看 Martin Fowler 的说明
对于假对象来说,存在多个定义,一般的术语叫测试替身。这个术语包含了:dummy(木偶)、fake(伪造)、stub(打桩)、mock(模拟)。可见说了半天,我也才仅仅体会到了最后两种测试替身的区别,对于 dummy 和 fake,目前没有感知。据 Martin Fowler 说:
木偶对象被传来传去但是根本不会被真正使用到。它们通常只是用来填塞参数列表 伪造对象实际上是有能够工作的实现,但通常会走捷径从而并不适合生产环境(比如内存数据库就是个很好的例子) 打桩提供了测试中调用时所对应的响应返回值,通常对于测试目的之外的逻辑根本不进行响应。打桩还可能被用来记录调用的信息,比如一个邮件网关打桩会记住它“发送”的信息,或者是它“发送”的信息的计数。这里的“发送”打了引号,因为对于打桩来说,根本不用真正发送。 模拟是事先写好的期待的调用规格说明。
也有人说,打桩是一个简单的伪造对象,只是为了让测试能够顺畅运行;而模拟则是一个更加智能的打桩,你可以验证测试代码通过了它的内部。
我没有感知到前两种测试替身,可能与使用的语言有关系。在 NodeJs 世界,我可以随时给参数列表一些任意的原始值,或者自由对象,所以没有意识到他们是木偶。
再次拿 http client 举个例子
最近学习 Java,正在尝试用 Java 把自己以前用 NodeJs 实现过的东西再重新实现一遍。这里用 Java 把上面的 NodeJs 例子重新说明一遍,以消除自己对于 Java 的恐惧。
任务
测试 MpService 类中的 getMpQrCode 方法。
这个 MpService 类,就是利用 Http Client 发送请求给微信 API,然后封装一个 MpQR 对象返回。这个 MpQR 大致长这样:
public class MpQR {
@JsonProperty("expire_seconds")
private Long expireSeconds;
@JsonProperty("imageUrl")
private String imageUrl;
@JsonProperty("sceneId")
private String sceneId;
@JsonProperty("ticket")
private String ticket;
@JsonProperty("url")
private String url;
}
这个 MpService 类,在请求微信 API 碰到异常时,会封装一个 Fallback 的 MpQR 类:
public MpQR getMpQrCode() {
URI uri = URI.create(this.qrCodeCreateUrl);
HttpRequest request = HttpRequest.newBuilder().POST(HttpRequest.BodyPublishers.ofString("")).uri(uri).build();
try {
HttpResponse<< span="">String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString());
WeixinTicketResponse ticketResponse = new Gson().fromJson(response.body(), WeixinTicketResponse.class);
...
return new MpQR().ticket(ticketResponse.ticket).imageUrl(ticketResponse.url).expireSeconds(ticketResponse.expiresInSeconds).url(ticketResponse.url);
} catch (InterruptedException ie){
return new MpQR().ticket("interrupted").imageUrl(Constants.FALLBACK_QR_URL);
} catch (Exception ex) {
return new MpQR().ticket("error").imageUrl(Constants.FALLBACK_QR_URL);
}
}
使用土办法打桩的方式
测试 MpService 类的正常路径(API 请求正常返回)
自己写个土的 MockHttpClient (好吧,还是叫了 mock 的名字,但是实际上它是用来打桩的):
public class MockHttpClient extends HttpClient {
@Override
public Optional<< span="">CookieHandler> cookieHandler() {
return Optional.empty();
}
@Override
public Optional<< span="">Duration> connectTimeout() {
return Optional.empty();
}
@Override
public Redirect followRedirects() {
return null;
}
@Override
public Optional<< span="">ProxySelector> proxy() {
return Optional.empty();
}
@Override
public SSLContext sslContext() {
return null;
}
@Override
public SSLParameters sslParameters() {
return null;
}
@Override
public Optional<< span="">Authenticator> authenticator() {
return Optional.empty();
}
@Override
public Version version() {
return null;
}
@Override
public Optional<< span="">Executor> executor() {
return Optional.empty();
}
@Override
public << span="">T> HttpResponse<< span="">T> send(HttpRequest request, HttpResponse.BodyHandler<< span="">T> responseBodyHandler) throws IOException, InterruptedException {
return new HttpResponse<< span="">T>() {
@Override
public int statusCode() {
return 200;
}
@Override
public HttpRequest request() {
return null;
}
@Override
public Optional<< span="">HttpResponse<< span="">T>> previousResponse() {
return Optional.empty();
}
@Override
public HttpHeaders headers() {
return null;
}
@Override
public T body() {
return (T) "{\"ticket\":\"gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmm\n3sUw==\",\"expire_seconds\":60,\"url\":\"http://weixin.qq.com/q/kZgfwMTm72WWPkovabbI\"}";
}
@Override
public Optional<< span="">SSLSession> sslSession() {
return Optional.empty();
}
@Override
public URI uri() {
return null;
}
@Override
public Version version() {
return null;
}
};
}
@Override
public << span="">T> CompletableFuture<< span="">HttpResponse<< span="">T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<< span="">T> responseBodyHandler) {
return null;
}
@Override
public << span="">T> CompletableFuture<< span="">HttpResponse<< span="">T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<< span="">T> responseBodyHandler, HttpResponse.PushPromiseHandler<< span="">T> pushPromiseHandler) {
return null;
}
}
这真是麻烦,只想返回假的 json,结果要写一大段代码(尽管多数是 IDE 帮忙生成的)。
然后就可以这样来写测试:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
public class MpServiceTest {
private MpService mpService = new MpService(new MockHttpClient());
@Test
void testGetMpQrCode() {
MpQR mpQR = mpService.getMpQrCode();
assertThat(mpQR.getTicket()).isEqualTo("gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmm");
}
}
使用 Mockito 打桩
使用上面那种手写打桩对象的方式不灵活,当要测试异常场景时,又得新写一个打桩对象,仅仅在 send 方法的实现有所差别:直接抛出错误。如果采用第三方库,就会灵活很多,比如 mockito。以上的测试完全可以使用 mockito 来做,由于不需要手写打桩对象,代码量少很多。主要利用了 @Mock 注解。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
public class MpServiceTest {
private MpService mpService;
@Mock
private HttpClient mockHttpClient;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(MpServiceTest.class);
}
@Test
void testGetMpQrCode() {
when(mockHttpClient.send(any(), any())).thenReturn(new HttpResponse<< span="">Object>() {
@Override
public int statusCode() {
return 0;
}
@Override
public HttpRequest request() {
return null;
}
@Override
public Optional<< span="">HttpResponse<< span="">Object>> previousResponse() {
return Optional.empty();
}
@Override
public HttpHeaders headers() {
return null;
}
@Override
public Object body() {
return "{\"ticket\":\"gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmm\n3sUw==\",\"expire_seconds\":60,\"url\":\"http://weixin.qq.com/q/kZgfwMTm72WWPkovabbI\"}";
}
@Override
public Optional<< span="">SSLSession> sslSession() {
return Optional.empty();
}
@Override
public URI uri() {
return null;
}
@Override
public HttpClient.Version version() {
return null;
}
});
mpService = new MpService(mockHttpClient);
MpQR mpQR = mpService.getMpQrCode();
assertThat(mpQR.getTicket()).isEqualTo("gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmm");
}
如果现在需要加一个测试异常的用例,就非常简单:
@Test
void testGetMpQrCodeMetInterruptedException() throws IOException, InterruptedException {
when(mockHttpClient.send(any(), any())).thenThrow(new InterruptedException("Test Exception"));
mpService = new MpService(mockHttpClient);
MpQR mpQR = mpService.getMpQrCode();
assertThat(mpQR.getTicket()).isEqualTo("interrupted");
}
如何采用模拟的方式来写这样的测试?
采用模拟的方式来实现这些测试用例,就需要使用真实的 http client,那么要被替换的内容就得往后靠。在 NodeJs 中,可以使用 nock ,直接 nock("https://api.weixin.qq.com")。但是在 Java 的世界里,我没有找到对等的工具。于是似乎得把微信 API 的域名,抽象出来,在测试时,灌入一个 localhost,然后在 localhost 启动一个模拟的服务,让其返回期待的 json。这种方式,搜一搜,可以找到 MockWebServer 的方法。
这里给出第一个测试用例的 MockWebServer 实现
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.env.Environment;
import org.springframework.test.context.junit4.SpringRunner;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.None)
public class WechatMpApiControllerTest {
public static MockWebServer mockBackEnd;
@BeforeAll
static void setUp() throws IOException {
mockBackEnd = new MockWebServer();
mockBackEnd.start();
}
@AfterAll
static void tearDown() throws IOException {
mockBackEnd.shutdown();
}
@BeforeEach
void initialize() {
String baseUrl = String.format("http://localhost:%s",
mockBackEnd.getPort());
mpService.setQrCodeCreateUrl(baseUrl + "/test");
}
private MpService mpService;
@Test
void testMpUrlHappyPath() {
MockResponse mockResponse = new MockResponse();
mockResponse.setBody("{\"ticket\":\"gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmm\n" +
"3sUw==\",\"expire_seconds\":60,\"url\":\"http://weixin.qq.com/q/kZgfwMTm72WWPkovabbI\"}");
mockResponse.addHeader("Content-Type", "application/json");
assertThat(mockBackEnd.getRequestCount()).isEqualTo(0);
mockBackEnd.enqueue(mockResponse);
mpService = new MpService();
MpQR mpQR = mpService.getMpQrCode();
assertThat(mpQR.getTicket()).isEqualTo("gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmm");
assertThat(mockBackEnd.getRequestCount()).isEqualTo(1);
}
}
总结
对于打桩和模拟,我有个粗浅的理解,不知道对不对。举例来说明:
对于依赖 http client 的待测试对象,要测试它,不是替换 http client 本身,就是要替换它背后的 http server。通过替换 http client 本身来测试,就是打桩测试;通过替换背后的 http server,就是模拟测试。打桩测试似乎更纯粹,因为只测试待测对象的逻辑,将依赖替换成简单的立刻反弹对象,从而避免代码走得过深,可能更适合单元测试。而模拟测试,则使用了真实的 http client,可能更适合端到端集成测试。
还有个偏题的小感悟:写 java 果然很累,原因是为了满足静态类型的要求,不得不写很多填充型的代码。当然 Mockito 可以缓解,IDE 的自动填充也可以缓解,但是仍然有点累!