集成是企业应用系统中绕不开的话题。与外部系统的集成点不仅实现起来麻烦,更是难以测试。本文介绍了一种普遍适用的集成点测试策略,兼顾测试的覆盖程度、速度、可靠性和可重复性,为集成点的实现与测试建立一个通用的参考。
背景
本文作为例子介绍的系统是一个典型的 JavaEE Web 应用,基于 Java 6 和 Spring 开发,采用 Maven 构建。该系统需要以 XML over HTTP 的方式集成两个外部系统。
该系统由一支典型的分布式团队交付:业务代表平常在墨尔本工作,交付团队则分布在悉尼和成都。笔者作为技术领导者带领一支成都的团队承担主要交付任务。
痛点
由于需要集成两个外部系统,我们的 Maven 构建 [1] 过程中有一部分测试(使用 JUnit)是与集成相关的。这部分测试给构建过程造成了一些麻烦。
首先是依赖系统的可靠性问题。在被依赖的两个服务之中,有一个服务部署在开发环境中的实例经常会关机维护,而它一旦关机就会导致与其集成的测试无法通过,进而导致整个构建失败。我们的交付团队严格遵守持续集成实践:构建失败时不允许提交代码。这么一来,当我们依赖的服务关机维护时,交付团队正常的工作节奏就会被打乱。
即使没有关机维护,由于开发环境中部署的服务实例仍在不断测试和调优,被依赖的服务实例也不时出现运行性能低、响应时间长等问题,使我们的构建过程也变得很慢,有时甚至会出现随机的构建失败。
被依赖的服务在开发环境下不可靠、性能低,会使应用程序的构建过程也随之变得脆弱而缓慢,从而打击程序员频繁进行构建的积极性,甚至损害持续集成的有效性。作为团队的技术领导者,我希望解决这个问题,使构建可靠而快速地运行,以确保所有人都愿意频繁执行构建。
如何测试集成点
在一个基于 Spring 的应用中,与外部服务的集成通常会被封装为一个 Java 接口以及其中的若干方法。例如“创建某品牌的用户”的服务很可能如下呈现:
<p>public interface IdentityService {</p><p>Customer create(Brand brand, Customer customer);</p>
一个实现了 IdentityService 接口的对象会被 Spring 实例化并放入应用上下文,需要使用该服务的客户代码可以通过依赖注入获得该对象的引用,从而调用它的 create 方法。在测试这些客户代码时,始终可以 mock 一个 IdentityService 对象,将其注入被测对象,从而解耦对外部服务的依赖。这是使用依赖注入带来的收益。
因此,我们的问题主要聚焦于集成点本身的测试。
用面向对象的语言来集成一个基于 HTTP 的服务,集成点的设计经常会出现这样一个模式,其中涉及五个主要的组成部分:门面(Façade);请求构造器(Request Builder);请求路由器(Request Router);网络端点(Network End Point);应答解析器(Response Parser)。它们之间的交互关系如下图:
显而易见,在这个模式中,真正需要发出网络请求的只有网络端点这个组件。该组件的作用即是“按照预先规定好的通信方式,向给定的网络地址发出给定的请求,返回应答内容”。对于基于 HTTP 的服务集成而言,网络端点的接口大致如下呈现:
<p>public interface EndPoint {</p><p>Response get(String url);</p><p>Response post(String url, String requestBody);</p><p>Response put(String url, String requestBody);</p>
其中 Response 类包含两项主要信息:HTTP 返回码,以及应答正文。
<p>public class Response {</p><p>private final int statusCode;</p><p>private final String responseBody;</p>
不难注意到,EndPoint 类所关心的是把正确的请求发送到正确的地址、取回正确的应答。它并不关心这个地址究竟是什么(这是请求路由器组件的责任),也不关心请求与应答包含什么信息(这是请求构造器和应答解析器的责任)。这一特点使得 EndPoint 类的测试完全不需要依赖真实服务的存在。
网络端点的测试
如前所述,EndPoint 类并不关心发送请求的地址,也不关心请求与应答的内容,只关心以正确的方式来发送请求并拿回应答——“正确的方式”可能包括身份认证与授权、必要的 HTTP 头信息等。为了测试这样一个类,我们不需要朝真正的网络服务地址发送请求,也不需要遵循真实的请求 / 应答协议,完全可以自己创造一个 HTTP 服务,用最简单的请求 / 应答文本来进行测试。
Moco[2] 就是专门用于这种场合的测试工具。按照作者的介绍,Moco 是“一个非常容易设置的 stub 框架,主要用于测试与集成”。在 JUnit 测试中,只需要两行代码就可以声明一个 HTTP 服务器,该服务器监听 12306 端口,对一切请求都会以字符串“foo”作为应答:
<p>MocoHttpServer server <b>=</b> httpserver<b>(</b>12306<b>);</b></p><p>server<b>.</b>reponse<b>(</b>"foo"<b>);</b></p>
接下来就可以像访问正常的服务器一样,用 Apache Commons HTTP Client 来访问这个服务器。唯一需要注意的是,访问服务器的代码需要放在 running 块中,以确保服务器能被正常关闭:
running(server, new Runnable() { @Override public void run() throws IOException { Content content = Request.Get("http://localhost:12306").execute().returnContent(); assertThat(content.asString(), is("foo")); } }
当然,作为一个测试辅助工具,Moco 支持很多灵活的配置,感兴趣的读者可以自行查阅文档。接下来我们就来看如何用 Moco 来测试我们系统中的网络端点组件。作为例子,我们这里需要集成的是用于管理用户身份信息的 OpenPTK[3]。OpenPTK 使用自定义的 XML 通信协议,并且每次请求之前要求客户端程序先向 /openptk-server/login 地址发送应用名称和密码以确认应用程序的合法身份。为此,我们先准备一个 Moco server 供测试之用:
server = httpserver(12306); server.post(and( by(uri("/openptk-server/login")), by("clientid=test_app&clientcred=fake_password"))).response(status(200));
接下来我们告诉要测试的网络端点,应该访问位于 localhost:12306 的服务器,并提供用户名和密码:
configuration = new IdentityServiceConfiguration(); configuration.setHost("<a href="http://localhost:12306/">http://localhost:12306</a>"); configuration.setClientId("test_app"); configuration.setClientCredential("fake_password"); xmlEndPoint = new XmlEndPoint(configuration);
然后就可以正式开始测试了。首先我们测试 XmlEndPoint 可以用 GET 方法访问一个指定的 URL,取回应答正文:
@Test public void shouldBeAbleToCarryGetRequest() throws Exception { final String expectedResponse = "<message>SUCCESS</message>"; server.get(by(uri("/get_path"))).response(expectedResponse); running(server, new Runnable() { @Override public void run() { XmlEndPointResponse response = xmlEndPoint.get("<a href="http://localhost:12306/get_path">http://localhost:12306/get_path</a>"); assertThat(response.getStatusCode(), equalTo(STATUS_SUCCESS)); assertThat(response.getResponseBody(), equalTo(expectedResponse)); } }); }
实现了这个测试以后,我们再添加一个测试,描述“应用程序登录失败”的场景,这样我们就得到了对 XmlEndPoint 类的 get 方法的完全测试覆盖:
@Test(expected = IdentityServiceSystemException.class) public void shouldRaiseExceptionIfLoginFails() throws Exception { configuration.setClientCredential("wrong_password"); running(server, new Runnable() { @Override public void run() { xmlEndPoint.get("<a href="http://localhost:12306/get_path">http://localhost:12306/get_path</a>"); } }); }
以此类推,也很容易给 post 和 put 方法添加测试。于是,在 Moco 的帮助下,我们就完成了对网络端点的测试。虽然这部分测试真的发起了 HTTP 请求,但只是针对位于 localhost 的 Moco 服务器,并且测试的内容也只是最基本的 GET/POST/PUT 请求,因此测试仍然快且稳定。
Moco**** 的前世今生
在 ThoughtWorks 成都分公司,我们为一家保险企业开发在线应用。由于该企业的数据与核心保险业务逻辑存在于 COBOL 开发的后端系统中,我们所开发的在线应用都有大量集成工作。不止一个项目组发出这样的抱怨:因为依赖了被集成的远程服务,我们的测试变得缓慢而不稳定。于是,我们的一位同事郑晔 [4] 开发了 Moco 框架,用它来简化集成点的测试。
除了我们已经看到的 API 模式(在测试用例中使用 Moco 提供的 API)以外,Moco 还支持 standalone 模式,用于快速创建一个测试用的服务器。例如下列配置(位于名为“foo.json”的文件中)就描述了一个最基本的 HTTP 服务器:
[ { "response" : { "text" : "Hello, Moco" } } ]
把这个服务器运行起来:
java -jar moco-runner-<version>-standalone.jar -p 12306 foo.json
再访问“ http://localhost:12306 ”下面的任意 URL,都会看到“Hello, Moco”的字样。结合各种灵活的配置,我们就可以很快地模拟出需要被集成的远程服务,用于本地的开发与功能测试。
感谢开源社区的力量,来自澳大利亚的 Garrett Heel 给 Moco 开发了一个 Maven 插件 [5],让我们可以在构建过程中适时地打开和关闭 Moco 服务器(例如在运行 Cucumber[6] 功能测试之前启动 Moco 服务器,运行完功能测试之后关闭),从而更好地把 Moco 结合到构建过程中。
目前 Moco 已经被 ThoughtWorks 成都分公司的几个项目使用,并且根据这些项目提出的需求继续演进。如果你有兴趣参与这个开源项目,不论是使用它并给它提出改进建议,还是为它贡献代码,郑晔都会非常开心。
其它组件的测试
有了针对网络端点的测试之后,其他几个组件的测试已经可以不必发起网络请求。理论上来说,每个组件都应该独自隔离进行单元测试;但个人而言,对于没有外部依赖的对象,笔者并不特别强求分别独立测试。只要有效地覆盖所有逻辑,将几个对象联合在一起测试也并无不可。
出于这样的考虑,我们可以针对整个集成点的 façade(即 IdentityService)进行测试。在实例化 IdentityService 对象时,需要 mock[7] 其中使用的 XmlEndPoint 对象,以隔离“发起网络请求”的逻辑:
xmlEndPoint = mock(XmlEndPoint.class); identityService = new IdentityServiceImpl(xmlEndPoint);
然后我们就需要 mock 的 XmlEndPoint 对象表现出几种不同的行为,以便测试 IdentityService(及其内部使用的其他对象)在这些情况下都做出了正确的行为。以“查找用户”为例,XmlEndPoint 的两种行为都是 OpenPTK 的文档里所描述的:
1. 找到用户:HTTP 状态码为“200 FOUND”,应答正文为包含用户信息的 XML;
2. 找不到用户:HTTP 状态码为“204 NO CONTENT”,应答正文为空。
针对第一种(“找到用户”)情况,我们对 mock 的 XmlEndPoint 对象提出期望,要求它在 get 方法被调用时返回一个代表 HTTP 应答的对象,其中返回码为 200、正文为包含用户信息的 XML:
when(xmlEndPoint.get(anyString())).thenReturn( new XmlEndPointResponse(STATUS_SUCCESS, userFoundResponse));
当 mock 的 XmlEndPoint 对象被设置为这样的行为,“查找用户”操作就应该能找到用户、并组装出合法的结果对象:
Customer customer = identityService.findByEmail("<a href="mailto:gigix1980@gmail.com">gigix1980@gmail.com</a>"); assertThat(customer.getFirstName(), equalTo("Jeff")); assertThat(customer.getLastName(), equalTo("Xiong"));
userFoundResponse 所引用的 XML 字符串中包含了用户信息,当 XmlEndPoint 返回这样一个字符串时,IdentityService 就能把它转换成一个 Customer 对象。这样我们就验证了 IdentityService(以及它内部所使用的其他对象)的功能。
第二种场景(“找不到用户”)的测试也与此相似:
@Test public void shouldReturnNullWhenUserDoesNotExist() throws Exception { when(xmlEndPoint.get(anyString())).thenReturn( new XmlEndPointResponse(STATUS_NO_CONTENT, null)); Customer nonExistCustomer = identityService.findByEmail("<a href="mailto:not.exist@gmail.com">not.exist@gmail.com</a>"); assertThat(nonExistCustomer, nullValue()); }
其他操作的测试也与此相似。
集成测试
有了上述两个层面的测试,我们已经能够对集成点的五个组件完全覆盖。但是请勿掉以轻心:100% 测试覆盖率并不等于所有可能出错的地方都被覆盖。例如我们前述的两组测试就留下了两个重要的点没有得到验证:
1. 真实的服务所在的 URL;
2. 真实的服务其行为是否与文档描述一致。
这两个点都是与真实服务直接相关的,必须结合真实服务来测试。另一方面,对这两个点的测试实际上描述功能重于验证功能:第一,外部服务很少变化,只要找到了正确的用法,在相当长的时间内不会改变;第二,外部服务如果出错(例如服务器宕机),从项目本身而言并没有修复的办法。所以真正触碰到被集成的外部服务的集成测试,其主要价值是准确描述外部服务的行为,提供一个可执行的、精确的文档。
为了提供这样一份文档,我们在集成测试中应该尽量避免使用应用程序内实现的集成点(例如前面出现过的 IdentityService),因为如果程序出错,我们希望自动化测试能告诉我们:出错的究竟是被集成的外部服务,还是我们自己编写的程序。我更倾向于使用标准的、接近底层的库来直接访问外部服务:
System.out.println("=== 2. Find that user out ==="); GetMethod getToSearchUser = new GetMethod( configuration.getUrlForSearchUser("gigix1980@gmail.com")); getToSearchUser.setRequestHeader("Accept", "application/xml"); httpClient.executeMethod(getToSearchUser); assertThat(getToSearchUser.getStatusCode(), equalTo(200)); System.out.println(getResponseBody(getToSearchUser));
可以看到,在这段测试中,我们直接使用 Apache Commons HTTP Client 来发起网络请求。对于应答结果我们也并不验证,只是确认服务仍然可用、并把应答正文(XML 格式)直接打印出来以供参考。如前所述,集成测试主要是在描述外部服务的行为,而非验证外部服务的正确性。这种粒度的测试已经足够起到“可执行文档”的作用了。
持续集成
在上面介绍的几类测试中,只有集成测试会真正访问被集成的外部服务,因此集成测试也是耗时最长的。幸运的是,如前所述,集成测试只是用于描述外部服务,所有的功能验证都在网络端点测试(使用 Moco)及其他组件的单元测试中覆盖,因此集成测试并不需要像其他测试那样频繁运行。
Maven 已经对这种情形提供了支持。在 Maven 定义的构建生命周期 [8] 中,我们可以看到有“test”和“integration-test”两个阶段(phase)。而且在 Maven 项目网站上我们还可以看到一个叫“Failsafe”的插件 [9],其中的介绍这样说道:
The Failsafe Plugin is designed to run integration tests while the Surefire Plugins is designed to run unit tests. The name (failsafe) was chosen both because it is a synonym of surefire and because it implies that when it fails, it does so in a safe way.
按照 Maven 的推荐,我们应该用 Surefire 插件来运行单元测试,用 Failsafe 插件来运行集成测试。为此,我们首先把所有集成测试放在“integration”包里,然后在 pom.xml 中配置 Surefire 插件不要执行这个包里的测试:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>${maven-surefire-plugin.version}</version> <executions> <execution> <id>default-test</id> <phase>test</phase> <goals> <goal>test</goal> </goals> <configuration> <excludes> <b><exclude>**/integration/**/*Test.java</exclude></b> </excludes> </configuration> </execution> </executions> </plugin>
再指定用 Failsafe 插件执行所有集成测试:
<plugin> <artifactId>maven-failsafe-plugin</artifactId> <version>2.12</version> <configuration> <includes> <b><include>**/integration/**/*Test.java</include></b> </includes> </configuration> <executions> <execution> <id>failsafe-integration-tests</id> <phase>integration-test</phase> <goals> <goal>integration-test</goal> </goals> </execution> <execution> <id>failsafe-verify</id> <phase>verify</phase> <goals> <goal>verify</goal> </goals> </execution> </executions> </plugin>
这时如果执行“mvn test”,集成测试已经不会运行;如果执行“mvn integration-test”,由于“integration-test”是在“test”之后的一个阶段,因此两组测试都会运行。这样我们就可以在持续集成服务器(例如 Jenkins)上创建两个不同的构建任务:一个是提交构建,每次有代码修改时执行,其中不运行集成测试;另一个是完整构建,每天定时执行一次,其中运行集成测试。如此,我们便做到了速度与质量兼顾:平时提交时执行的构建足以覆盖我们开发的功能,执行速度飞快,而且不会因为外部服务宕机而失败;每日一次的完整构建覆盖了被集成的外部服务,确保我们足够及时地知晓外部服务是否仍然如我们期望地正常运行。
对已有系统的重构
如果一开始就按照前文所述的模式来设计集成点,自然很容易保障系统的可测试性;但如果一开始没有做好设计,没有抽象出“网络端点”的概念,而是把网络访问的逻辑与其他逻辑耦合在一起,自然也就难以写出专门针对网络访问的测试,从而使得大量测试会发起真实的网络访问,使构建变得缓慢而不可靠。
下面就是一段典型的代码结构,其中杂糅了几种不同的职责:准备请求正文;发起网络请求;处理应答内容。
PostMethod postMethod = getPostMethod( velocityContext, templateName, soapAction); new HttpClient().executeMethod(postMethod); String responseBodyAsString = postMethod.getResponseBodyAsString(); if (responseBodyAsString.contains("faultstring")) { throw new WmbException(); } Document document; try { LOGGER.info("request:\n" + responseBodyAsString); document = DocumentHelper.parseText(responseBodyAsString); } catch (Exception e) { throw new WmbParseException( e.getMessage() + "\nresponse:\n" + responseBodyAsString); } return document;
针对每个要集成的服务方法,类似的代码结构都会出现,从而出现了“重复代码”的坏味道。由于准备请求正文、处理应答内容等逻辑各处不同(例如上面的代码使用 Velocity[10] 来生成请求正文、使用 JDOM[11] 来解析应答),这里的重复并不那么直观,自动化的代码检视工具(例如 Sonar)通常也不能发现。因此第一步的重构是让重复的结构浮现出来。
使用抽取函数(Extract Method)、添加参数(Add Parameter)、删除参数(Remove Parameter)等重构手法,我们可以把上述代码整理成如下形状:
// 1. prepare request body String requestBody = renderTemplate(velocityContext, templateName); <b> // 2. execute a post method and get back response body</b> <b> PostMethod postMethod = getPostMethod(soapAction, requestBody);</b> <b> new HttpClient().executeMethod(postMethod);</b> <b> String responseBody = postMethod.getResponseBodyAsString();</b> <b> if (responseBodyAsString.contains("faultstring")) {</b> <b> throw new WmbException();</b> <b> }</b> // 3. deal with response body Document document = parseResponse(responseBody); return document;
这时,第 2 段代码(使用预先准备好的请求正文执行一个 POST 请求,并拿回应答正文)的重复就变得明显了。《重构》对这种情况做了介绍 [12]:
如果两个毫不相关的类出现 Duplicated Code,你应该考虑对其中一个使用 Extract Class,将重复代码提炼到一个独立类中,然后在另一个类内使用这个新类。但是,重复代码所在的函数也可能的确只应该属于某个类,另一个类只能调用它,抑或这个函数可能属于第三个类,而另两个类应该引用这第三个类。你必须决定这个函数放在哪儿最合适,并确保它被安置后就不会再在其他任何地方出现。
这正是我们面对的情况,也正是“网络端点”这个概念应该出现的时候。使用抽取函数和抽取类(Extract Class)的重构手法,我们就能得到名为 SOAPEndPoint 的类:
public class SOAPEndPoint { public String post(String soapAction, String requestBody) { PostMethod postMethod = getPostMethod(soapAction, requestBody); new HttpClient().executeMethod(postMethod); String responseBody = postMethod.getResponseBodyAsString(); if (responseBodyAsString.contains("faultstring")) { throw new WmbException(); } return responseBody; }
原来的代码变为使用这个新的类:
// 1. prepare request body String requestBody = renderTemplate(velocityContext, templateName); <b> // 2. execute a post method and get back response body</b> <b> // soapEndPoint is dependency injected by Spring Framework</b> <b> String responseBody = soapEndPoint.post(soapAction, requestBody);</b> // 3. deal with response body Document document = parseResponse(responseBody); return document;
再按照前文所述的测试策略,使用 Moco 给 SOAPEndPoint 类添加测试。可以看到,SOAPEndPoint 的逻辑相当简单:把指定的请求文本 POST 到指定的 URL;如果应答文本包含“faultstring”字符串,则抛出异常;否则直接返回应答文本。尽管名为“SOAPEndPoint”,post 这个方法其实根本不关心请求与应答是否符合 SOAP 协议,因此在测试这个方法时我们也不需要让 Moco 返回符合 SOAP 协议的应答文本,只要覆盖应答中是否包含“faultstring”字符串的两种情况即可。
读者或许会问:既然 post 方法并不介意请求与应答正文是否符合 SOAP 协议,为什么这个类叫 SOAPEndPoint?答案是:在本文没有给出实现代码的 getPostMethod 方法中,我们需要填入一些 HTTP 头信息,这些信息是与提供 Web Services 的被集成服务相关的。这些 HTTP 头信息(例如应用程序的身份认证、Content-Type 等)适用于所有服务方法,因此可以抽取到通用的 getPostMethod 方法中。
随后,我们可以编写一些描述性的集成测试,并用 mock 的方式使所有“使用 SOAPEndPoint 的类”的测试不再发起网络请求。至此,我们就完成了对已有的集成点的重构,并得到了一组符合前文所述的测试策略的测试用例。当然读者可以继续重构,将请求构造器与应答解析器也分离出来,在此不再赘述。
小结
在开发一个“重集成”的 JavaEE Web 应用的过程中,自动化测试中对被集成服务的依赖使得构建过程变得缓慢而脆弱。通过对集成点实现的考察,我们识别出一个典型的集成点设计模式。基于此模式以及与之对应的测试策略,借助 Moco 这个测试工具,我们能够很好地隔离对被集成服务的依赖,使构建过程快速而可靠。
随后我们还考察了已有的集成点实现,并将其重构成为前文所述的结构,从而将同样的测试策略应用于其上。通过这个过程,我们验证了:本文所述的测试策略是普遍适用的,遗留系统同样可以通过文中的重构过程达到解耦实现、从而分层测试的目标。
[1] “构建”一词在本文中是指使用自动化的构建工具(例如 Maven)将源代码变为可交付的软件的过程。一般而言,JavaEE 系统的构建过程通常包括编译、代码检查、单元测试、集成测试、打包、功能测试等环节。
[2] https://github.com/dreamhead/moco
[4] http://dreamhead.blogbus.com/
[5] https://github.com/GarrettHeel/moco-maven-plugin
[7] 笔者使用的 mock 框架是 Mockito: https://code.google.com/p/mockito/
[8] http://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html
[9] http://maven.apache.org/plugins-archives/maven-failsafe-plugin-2.12.4/
[10] http://velocity.apache.org/
[11] http://jdom.org/
[12] 《重构》,3.1 小节。
感谢侯伯薇对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论