企业系统集成点测试策略

集成是企业应用系统中绕不开的话题。与外部系统的集成点不仅实现起来麻烦,更是难以测试。本文介绍了一种普遍适用的集成点测试策略,兼顾测试的覆盖程度、速度、可靠性和可重复性,为集成点的实现与测试建立一个通用的参考。

背景

本文作为例子介绍的系统是一个典型的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)。它们之间的交互关系如下图:

企业系统集成点测试策略_第1张图片

显而易见,在这个模式中,真正需要发出网络请求的只有网络端点这个组件。该组件的作用即是“按照预先规定好的通信方式,向给定的网络地址发出给定的请求,返回应答内容”。对于基于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[2]就是专门用于这种场合的测试工具。按照作者的介绍,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[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("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开发的后端系统中,我们所开发的在线应用都有大量集成工作。不止一个项目组发出这样的抱怨:因为依赖了被集成的远程服务,我们的测试变得缓慢而不稳定。于是,我们的一位同事郑晔[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("[email protected]");
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("[email protected]");
    assertThat(nonExistCustomer, nullValue());
}

其他操作的测试也与此相似。

集成测试

有了上述两个层面的测试,我们已经能够对集成点的五个组件完全覆盖。但是请勿掉以轻心:100%测试覆盖率并不等于所有可能出错的地方都被覆盖。例如我们前述的两组测试就留下了两个重要的点没有得到验证:

1. 真实的服务所在的URL;

2. 真实的服务其行为是否与文档描述一致。

这两个点都是与真实服务直接相关的,必须结合真实服务来测试。另一方面,对这两个点的测试实际上描述功能重于验证功能:第一,外部服务很少变化,只要找到了正确的用法,在相当长的时间内不会改变;第二,外部服务如果出错(例如服务器宕机),从项目本身而言并没有修复的办法。所以真正触碰到被集成的外部服务的集成测试,其主要价值是准确描述外部服务的行为,提供一个可执行的、精确的文档。

为了提供这样一份文档,我们在集成测试中应该尽量避免使用应用程序内实现的集成点(例如前面出现过的IdentityService),因为如果程序出错,我们希望自动化测试能告诉我们:出错的究竟是被集成的外部服务,还是我们自己编写的程序。我更倾向于使用标准的、接近底层的库来直接访问外部服务:

System.out.println("=== 2. Find that user out ===");
GetMethod getToSearchUser = new GetMethod(
         configuration.getUrlForSearchUser("[email protected]"));
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>
          <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:\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); 
 
    // 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请求,并拿回应答正文)的重复就变得明显了。《重构》对这种情况做了介绍[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); 
 
    // 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这个测试工具,我们能够很好地隔离对被集成服务的依赖,使构建过程快速而可靠。

随后我们还考察了已有的集成点实现,并将其重构成为前文所述的结构,从而将同样的测试策略应用于其上。通过这个过程,我们验证了:本文所述的测试策略是普遍适用的,遗留系统同样可以通过文中的重构过程达到解耦实现、从而分层测试的目标。

[1] “构建”一词在本文中是指使用自动化的构建工具(例如Maven)将源代码变为可交付的软件的过程。一般而言,JavaEE系统的构建过程通常包括编译、代码检查、单元测试、集成测试、打包、功能测试等环节。

[2] https://github.com/dreamhead/moco

[3] http://www.openptk.org/

[4] http://dreamhead.blogbus.com/

[5] https://github.com/GarrettHeel/moco-maven-plugin

[6] http://cukes.info/

[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小节。

你可能感兴趣的:(企业系统集成点测试策略)