企业系统集成点测试策略

Cynthia ·
更新时间:2024-11-15
· 984 次阅读

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

  背景本文作为例子介绍的系统是一个典型的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这个测试工具,我们能够很好地隔离对被集成服务的依赖,使构建过程快速而可靠。

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



系统集成 测试策略 系统 测试

需要 登录 后方可回复, 如果你还没有账号请 注册新账号