集成测试策略
集成是企业应用程序不容忽视的话题,这不仅是因为与外部系统的集成容易出错,而且因为它们很难测试。 本文介绍了一种通用的集成点测试策略,该策略可以提高测试的覆盖范围,速度,可靠性和可重复性,因此可以用作实现和测试大量集成应用程序的参考。
背景
本文中作为示例使用的系统是一个典型的Java EE Web应用程序,它是用Java 6和Spring开发的,并用Maven构建的。 该系统通过HTTP上的XML与两个外部系统集成。
该应用程序由分布式团队交付:业务代表位于墨尔本,而交付团队位于悉尼和成都。 作者之一熊雄(Jeff Xiong)是成都团队的技术负责人,该团队负责交付工作。
疼痛
该应用程序需要与两个外部系统集成,因此我们的一些测试用例(用JUnit编写)必须与它们集成,这使得Maven build 1进程不稳定。
不能保证外部服务的可靠性。 我们的依赖服务之一也正在开发中,并且经常关闭,这会导致我们的集成测试(以及整个构建过程)失败。 我们的交付团队严格遵循连续交付的惯例,并且在构建失败时不会检入任何代码。 在这种情况下,依赖服务的不稳定会成为交付团队的障碍。
更糟糕的是,部署在开发环境中的依赖服务没有像生产那样优化,这可能会导致严重的性能问题。 这些实际的缺点使我们的构建过程非常缓慢,有时会导致随机故障。
由于外部服务的不可靠性和低性能将使构建过程既脆弱又非常缓慢,因此交付团队频繁构建变得很痛苦。 此外,它损害了连续集成过程的效率。 作为团队的技术负责人,我希望解决此问题,以便构建可以快速可靠地运行。
如何测试整合点
对于基于Spring框架构建的应用程序,当它们需要与外部系统集成时,通常会通过Java接口来实现。 例如,为特定品牌创造客户的服务可能如下所示:
public interface IdentityService {
Customer create(Brand brand, Customer customer);
Spring实例化一个实现IdentityService的类,并将该实例保留在应用程序上下文中,然后需要该服务的客户端代码可以通过依赖项注入来保留该实例,并调用其“ create”方法。 当我们为它编写测试时,可以将模拟的IdentityService实例注入到客户端代码中。 这样,我们就可以将测试代码与外部服务分离。 这是进行依赖注入的好处。
由于我们不必担心测试客户端代码,因此我们将重点放在测试集成点上。
与面向对象语言的基于HTTP的服务集成时,集成点通常以如下方式设计:它们由五个主要组件组成:Façade; 请求生成器; 请求路由器; 网络端点和响应解析器。 下图显示了它们如何交互:
如您在该图中所看到的,网络端点是唯一通过HTTP请求与外界联系的组件。 它以预定义的协议将某个请求发送到某个网址,并返回响应。 对于基于HTTP的服务,通常按以下方式定义网络端点:
public interface EndPoint {
Response get(String url);
Response post(String url, String requestBody);
Response put(String url, String requestBody);
响应类包含2条信息:HTTP状态代码和响应正文。
public class Response {
private final int statusCode;
private final String responseBody;
您可能已经注意到,EndPoint类负责将给定的请求发送到给定的地址,并从外部服务返回响应。 它不在乎地址是什么(这是请求路由器的工作),也不在乎请求和响应的内容(Request Builder和Response Parser分别负责它们)。 这使EndPoint的测试完全独立于实际的外部服务。
测试网络端点
EndPoint真正关心的类是是否是发送请求和检索响应的正确方法-“正确的方法”可能包括身份验证和授权,必要的HTTP标头信息等。为了测试该类,我们不会无需将请求发送到远程服务器的地址并遵守真实的请求/响应协议。 相反,我们可以创建自己的HTTP服务器,并使用非常简单的请求/响应对其进行测试。
为此方案指定了Moco作为测试工具。 根据作者的介绍,它是“一个易于安装的存根框架,主要侧重于测试和集成”。 要创建HTTP服务器,只需要两行代码-创建的服务器将在端口12306上侦听并以字符串“ foo”响应任何请求:
MocoHttpServer server = httpserver(12306);
server. response ( "foo" );
然后,我们可以像使用Apache Commons HTTP Clients的真实服务器一样访问此HTTP服务器。 只需注意一件事:与服务器交互的代码必须放在“运行”块中,以便可以相应地关闭服务器:
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在集成点中测试Network End Point组件。 例如,我们将与OpenPTK集成,后者将在Identity Solutions和专用用户界面或访问点之间建立桥梁。 OpenPTK使用基于XML的自定义通信协议,它要求客户端在每个请求之前使用应用程序名称和密码发送请求到/ openptk-server / login,以确保应用程序被授权。 因此,我们准备进行测试的Moco服务器如下:
server = httpserver(12306);
server.post(and(
by(uri("/openptk-server/login")),
by("clientid=test_app&clientcred=fake_password"))).response(status(200));
然后,我们配置网络端点以使用用户名和密码访问位于localhost:12306的Moco服务器:
configuration = new IdentityServiceConfiguration();
configuration.setHost(" http://localhost:12306 ");
configuration.setClientId("test_app");
configuration.setClientCredential("fake_password");
xmlEndPoint = new XmlEndPoint(configuration);
最终,我们的测试夹具已经准备就绪。 现在该测试XmlEndPoint是否可以使用HTTP 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请求,但它们仅与Moco创建的本地主机服务器交互,并且仅执行基本的HTTP GET / POST / PUT请求。 因此,这些测试案例一直都是快速而可靠的。
测试其他组件
由于我们已经测试了端点,因此其他组件的测试不必发送任何HTTP请求。 理想情况下,每个组件都应单独进行单元测试; 但就我个人而言,当要测试的对象没有外部依赖性时,我不会迷恋隔离。 只要涵盖所有情况,我不介意同时测试多个对象。
我们将整体测试Façade组件(IdentityService)。 实例化IdentityServiceImpl时,将创建XmlEndPoint的模拟实例。 这样可以确保发送HTTP请求的代码与以下测试2隔离:
xmlEndPoint = mock(XmlEndPoint.class);
identityService = new IdentityServiceImpl(xmlEndPoint);
然后,我们需要模拟的XmlEndPoint实例根据不同的条件运行,因此我们可以相应地测试IdentityService的行为。 以“查找用户”为例,XmlEndPoint被记录为可以执行以下操作:
1.当找到用户时:HTTP状态码为200,响应主体将包含XML的用户信息;
2.找不到用户时:HTTP状态代码为204,响应正文为空。
对于第一种情况(“找到用户”),我们期望XmlEndPoint的get方法返回状态为200且主体包含XML用户信息的响应:
when(xmlEndPoint.get(anyString())).thenReturn(
new XmlEndPointResponse(STATUS_SUCCESS, userFoundResponse));
像这样设置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());
}
Other methods of IdentityService also can be tested similarly.
整合测试
完成上述两个级别的测试后,我们已经介绍了集成点五个组成部分的所有情况。 但是请不要放松警惕:100%的覆盖率并不意味着我们已经涵盖了可能发生错误的所有地方。 例如,仍然有两个尚未验证的重要错过的地方:
- 真正的远程服务的URL的可用性;
- 这些服务的行为是否与文档一致。
这两个项目必须在实际环境中进行测试。 此外,对于针对这些项目的测试用例,描述功能比验证其正确性更为重要。 原因是双重的。 首先, [5]远程服务很少更改; 其次,每当远程服务出现任何错误(例如不可用)时,我们都无法采取任何措施进行修复。 因此,涉及真实服务的集成测试的价值在于提供准确且可执行的文档。
为了提供这样的文档,我们应该避免使用我们自己的应用程序集成点(例如上面提到的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在其生命周期中定义了两个阶段:测试和集成测试。 有一个名为“ Failsafe ”的Maven插件:
故障安全插件旨在运行集成测试,而Surefire插件旨在运行单元测试。 之所以选择名称(failsafe)是因为它是surefire的同义词,也因为它暗示了当它失败时,它是以安全的方式这样做的。 |
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”将执行单元测试和集成测试。 我们可以在连续集成服务器(例如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生成请求正文,并使用JDOM解析响应。 甚至自动代码检查工具(如Sonar)也找不到它们。
在应用了一些重构方法后,例如“提取方法”,“添加参数”,“删除参数”等,我们可以按以下方式重构代码:
// 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;
现在,复制在第二个块中更加明显。 《 重构 》一书描述了这种情况3 :
如果您在两个不相关的类中重复了代码,请考虑在一个类中使用Extract Class,然后在另一个类中使用新组件。 另一种可能性是该方法实际上仅属于一个类,并且应由另一类调用,或者该方法属于应由两个原始类引用的第三类。 您必须确定该方法在何处有意义,并确保该方法在那里存在。 |
这就是我们面临的情况,这是应该引入“网络端点”概念的时候。 通过使用Extract Method和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;
}
原始代码将使用新的SOAPEndPoint类:
// 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”和何时不包含字符串“ faultstring”的情况。
鉴于此,您可能想知道为什么将该类称为SOAPEndPoint? 答案是,在方法getPostMethod(此处未介绍)中,我们需要填写一些HTTP标头,而这些标头主要与要集成的远程服务提供的Web服务有关。 这些标头足以满足客户端上的服务方法,因此可以将它们提取为通用方法:getPostMethod。
接下来,我们可以编写一些描述性的集成测试,并使用模拟技术来确保对SOAPEndPoint的任何引用都不会引发任何网络请求。
现在,我们已经完成了对所有集成点的重构,并创建了一组遵循我们的测试策略的测试。 作为读者的练习,您可以继续重构以拆分请求构建器和响应解析器。
结语
由于严重依赖外部服务,因此依赖这些外部服务的Java EE Web应用程序的构建通常会变得缓慢且不可靠。 我们已经确定了实现集成点的模式。 使用这种模式和相应的测试策略,在Moco的帮助下,我们设法将测试与外部服务隔离开来,并使我们的构建更快,更可靠。
我们还研究了集成点的一些现有实现,并将它们重构为模式,因此我们也可以将测试策略应用于现有代码。 这证明了测试策略是通用的:即使是传统系统也可以通过采用重构技术来实现实现解耦和隔离测试。
关于Moco的更多信息
在ThoughtWorks成都办事处,我们正在为一家金融公司开发在线应用程序。 因为公司的所有数据和核心业务规则都存储在COBOL后端系统中,所以在线应用程序不可避免地要进行大量的集成工作。 办公室中的大多数团队都抱怨说,由于与相关的远程服务器集成在一起,测试用例变得缓慢且不可靠。 为了减轻痛苦,我的同事Zheng Ye创建了Moco框架来简化集成测试。
除了在上述测试案例中已经看到的API模式之外,Moco还支持独立模式,该模式旨在快速创建测试服务器。 例如,以下配置(位于文件“ foo.json”中)描述了基本的HTTP服务器:
[
{
"response" : {
"text" : "Hello, Moco"
}
}
]
启动服务器:
java -jar moco-runner-<version>-standalone.jar start -p 12306 -c foo.json
如果访问位置“ http:// localhost:12306”下的任何URL,则“ Hello Moco”将显示在屏幕上。 由于Moco具有各种灵活的配置选项,因此我们能够模拟将要与之集成的任何远程服务器,并将其装备用于本地开发和功能测试。
借助开源社区的力量,Moco获得了来自澳大利亚开发人员Garrett Heel的Maven插件。 在他的帮助下,我们能够非常轻松地将Moco嵌入我们的Maven构建过程中,并根据我们的需要启动或停止Moco服务器(例如,在Cucumber功能测试之前启动Moco服务器,并在完成后停止它)。
目前,Moco正在ThoughtWorks成都办事处的多个项目中使用。 这些项目提出的新要求仍在继续开发中。 如果您对Moco感兴趣,请随时提供改进建议或做出贡献。
集成测试策略