集成是企业应用系统中绕不开的话题。与外部系统的集成点不仅实现起来麻烦,更是难以测试。本文介绍了一种普遍适用的集成点测试策略,兼顾测试的覆盖程度、速度、可靠性和可重复性,为集成点的实现与测试建立一个通用的参考。
背景本文作为例子介绍的系统是一个典型的JavaEE Web应用,基于Java 6和Spring开发,采用Maven构建。该系统需要以XML over HTTP的方式集成两个外部系统。
该系统由一支典型的分布式团队交付:业务代表平常在墨尔本工作,交付团队则分布在悉尼和成都。笔者作为技术带领一支成都的团队承担主要交付任务。
痛点
由于需要集成两个外部系统,我们的Maven构建[1]过程中有一部分测试(使用JUnit)是与集成相关的。这部分测试给构建过程造成了一些麻烦。
首先是依赖系统的可靠性问题。在被依赖的两个服务之中,有一个服务部署在开发环境中的实例经常会关机维护,而它一旦关机会导致与其集成的测试无法通过,进而导致整个构建失败。我们的交付团队严格遵守持续集成实践:构建失败时不允许提交代码。这么一来,当我们依赖的服务关机维护时,交付团队正常的工作节奏会被打乱。
即使没有关机维护,由于开发环境中部署的服务实例仍在不断测试和调优,被依赖的服务实例也不时出现运行性能低、响应时间长等问题,使我们的构建过程也变得很慢,有时甚至会出现随机的构建失败。
被依赖的服务在开发环境下不可靠、性能低,会使应用程序的构建过程也随之变得脆弱而缓慢,从而打击程序员频繁进行构建的积极性,甚至损害持续集成的有效性。作为团队的技术,我希望解决这个问题,使构建可靠而快速地运行,以确保所有人都愿意频繁执行构建。
如何测试集成点在一个基于Spring的应用中,与外部服务的集成通常会被封装为一个Java接口以及其中的若干方法。例如“创建某品牌的用户”的服务很可能如下呈现:
public interface IdentityService {Customer create(Brand brand, Customer customer);
一个实现了IdentityService接口的对象会被Spring实例化并放入应用上下文,需要使用该服务的客户代码可以通过依赖注入获得该对象的引用,从而调用它的create方法。在测试这些客户代码时,始终可以mock一个IdentityService对象,将其注入被测对象,从而解耦对外部服务的依赖。这是使用依赖注入带来的收益。
因此,我们的问题主要聚焦于集成点本身的测试。
用面向对象的语言来集成一个基于HTTP的服务,集成点的设计经常会出现这样一个模式,其中涉及五个主要的组成部分:门面(Fa?ade);请求构造器(Request Builder);请求路由器(Request Router);网络端点(Network End Point);应答解析器(Response Parser)。它们之间的交互关系如下图:
显而易见,在这个模式中,真正需要发出网络请求的只有网络端点这个组件。该组件的作用即是“按照预先规定好的通信方式,向给定的网络地址发出给定的请求,返回应答内容”。对于基于HTTP的服务集成而言,网络端点的接口大致如下呈现:
public interface EndPoint { Response get(String url); Response post(String url, String requestBody); Response put(String url, String requestBody);
其中Response类包含两项主要信息:HTTP返回码,以及应答正文。
public class Response { private final int statusCode; private final String responseBody;
不难注意到,EndPoint类所关心的是把正确的请求发送到正确的地址、取回正确的应答。它并不关心这个地址究竟是什么(这是请求路由器组件的责任),也不关心请求与应答包含什么信息(这是请求构造器和应答解析器的责任)。这一特点使得EndPoint类的测试完全不需要依赖真实服务的存在。
网络端点的测试
如前所述,EndPoint类并不关心发送请求的地址,也不关心请求与应答的内容,只关心以正确的方式来发送请求并拿回应答——“正确的方式”可能包括身份认证与授权、必要的HTTP头信息等。为了测试这样一个类,我们不需要朝真正的网络服务地址发送请求,也不需要遵循真实的请求/应答协议,完全可以自己创造一个HTTP服务,用简单的请求/应答文本来进行测试。
Moco是专门用于这种场合的测试工具。按照作者的介绍,Moco是“一个非常容易设置的stub框架,主要用于测试与集成”。在JUnit测试中,只需要两行代码可以声明一个HTTP服务器,该服务器监听12306端口,对一切请求都会以字符串“foo”作为应答:
MocoHttpServer server = httpserver(12306); server.reponse("foo");
接下来可以像访问正常的服务器一样,用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。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("http://localhost:12306"); 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("http://localhost:12306/get_path"); 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("http://localhost:12306/get_path"); } }); }
以此类推,也很容易给post和put方法添加测试。于是,在Moco的帮助下,我们完成了对网络端点的测试。虽然这部分测试真的发起了HTTP请求,但只是针对位于localhost的Moco服务器,并且测试的内容也只是基本的GET/POST/PUT请求,因此测试仍然快且稳定。
Moco的前世今生
在ThoughtWorks成都分公司,我们为一家保险企业开发在线应用。由于该企业的数据与核心保险业务逻辑存在于COBOL开发的后端系统中,我们所开发的在线应用都有大量集成工作。不止一个项目组发出这样的抱怨:因为依赖了被集成的远程服务,我们的测试变得缓慢而不稳定。于是,我们的一位同事郑晔开发了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插件,让我们可以在构建过程中适时地打开和关闭Moco服务器(例如在运行Cucumber功能测试之前启动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("gigix1980@gmail.com"); 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("not.exist@gmail.com"); assertThat(nonExistCustomer, nullValue()); }
其他操作的测试也与此相似。
集成测试
有了上述两个层面的测试,我们已经能够对集成点的五个组件完全覆盖。但是请勿掉以轻心:测试覆盖率并不等于所有可能出错的地方都被覆盖。例如我们前述的两组测试留下了两个重要的点没有得到验证:
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定义的构建生命周期中,我们可以看到有“test”和“integration-test”两个阶段(phase)。而且在Maven项目网站上我们还可以看到一个叫“Failsafe”的插件,其中的介绍这样说道:
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> <exclude>**/integration/**/*Test.java</exclude> </excludes> </configuration> </execution> </executions> </plugin>
再指定用Failsafe插件执行所有集成测试:
<plugin> <artifactId>maven-failsafe-plugin</artifactId> <version>2.12</version> <configuration> <includes> <include>**/integration/**/*Test.java</include> </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: " + responseBodyAsString); document = DocumentHelper.parseText(responseBodyAsString); } catch (Exception e) { throw new WmbParseException( e.getMessage() + " response: " + responseBodyAsString); } return document;
针对每个要集成的服务方法,类似的代码结构都会出现,从而出现了“重复代码”的坏味道。由于准备请求正文、处理应答内容等逻辑各处不同(例如上面的代码使用Velocity来生成请求正文、使用JDOM来解析应答),这里的重复并不那么直观,自动化的代码检视工具(例如Sonar)通常也不能发现。因此第一步的重构是让重复的结构浮现出来。
使用抽取函数(Extract Method)、添加参数(Add Parameter)、删除参数(Remove Parameter)等重构手法,我们可以把上述代码整理成如下形状:
// 1. prepare request body String requestBody = renderTemplate(velocityContext, templateName); // 2. execute a post method and get back response body PostMethod postMethod = getPostMethod(soapAction, requestBody); new HttpClient().executeMethod(postMethod); String responseBody = postMethod.getResponseBodyAsString(); if (responseBodyAsString.contains("faultstring")) { throw new WmbException(); } // 3. deal with response body Document document = parseResponse(responseBody); return document;
这时,第2段代码(使用预先准备好的请求正文执行一个POST请求,并拿回应答正文)的重复变得明显了。《重构》对这种情况做了介绍:
如果两个毫不相关的类出现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); // 2. execute a post method and get back response body // soapEndPoint is dependency injected by Spring Framework String responseBody = soapEndPoint.post(soapAction, requestBody); // 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这个测试工具,我们能够很好地隔离对被集成服务的依赖,使构建过程快速而可靠。
随后我们还考察了已有的集成点实现,并将其重构成为前文所述的结构,从而将同样的测试策略应用于其上。通过这个过程,我们验证了:本文所述的测试策略是普遍适用的,遗留系统同样可以通过文中的重构过程达到解耦实现、从而分层测试的目标。