Java EE 集成测试

作者:Adam Bien

实用集成测试可提高工作效率、确保 Java EE 6 应用程序的可部署性。

2011 年 9 月发布

下载:

下载Java EE

简介

我在上一篇文章“Java EE 单元测试”中介绍了 Java Platform, Enterprise Edition 6 (Java EE 6) 应用程序的单元测试,使用 Mockito 模拟出所有外部依赖关系。单元测试对于验证应用程序业务逻辑非常重要,但它们不能确保 Java EE 6 应用程序的可部署性。

注意:在 Java.Net 上可以找到用于本文的 Maven 3 项目 TestingEJBAndCDI,已使用 NetBeans 7 和 GlassFish v3.x 对其进行了测试。

区别对待(性能)

单元测试快速、精细。集成测试则缓慢、粗糙。我们可以利用单元测试和集成测试的自然属性来提高工作效率,而不是武断地以速度“快”或“慢”来进行区分。单元测试更精细,因此应先运行单元测试。通常,您会先编写小块功能,然后将这些功能集成到更大的子系统中。单元测试速度极快。在数毫秒内即可执行数百项单元测试。您可以使用单元测试更快速地进行迭代,而无需等待集成测试完成。

集成测试在单元测试执行成功之后才执行。由于单元测试经常会失败,因此,集成测试的执行不是那么频繁。通过这种对单元测试与集成测试的严格区分,每个回合可以节省几分钟时间(有时是几小时)。

工作效率测试

实用集成测试确实会提高工作效率。其唾手可得的成果体现在对 Java Persistence API (JPA) 映射和查询的测试上。将整个应用程序部署到服务器只为了测试映射和查询的语法是否正确,这样花的时间太长了。

幸运的是,JPA 持久性可以在单元测试中直接启动。引导所需的开销可以忽略不计。只需使用 EntityManagerFactory 创建 EntityManager。为了对 Prediction 实体执行映射测试,将一个实际的 EntityManager 实例注入 PredictionAudit 类中(参见清单 1)。

 public class PredictionAuditIT {
 	private PredictionAudit cut;
 	private EntityTransaction transaction;
 
 @Before
 public void initializeDependencies(){
 	cut = new PredictionAudit();
 cut.em = Persistence.createEntityManagerFactory("integration").createEntityManager();
 	this.transaction = cut.em.getTransaction();
 }
 @Test
 public void savingSuccessfulPrediction(){
 	final Result expectedResult = Result.BRIGHT;
	Prediction expected = new Prediction(expectedResult, true);
 	transaction.begin();
 	this.cut.onSuccessfulPrediction(expectedResult);
 	transaction.commit();
 	List<Prediction> allPredictions = this.cut.allPredictions();
 	assertNotNull(allPredictions);
  
	assertThat(allPredictions.size(),is(1));
 }
 @Test
 public void savingRolledBackPrediction(){
 	final Result expectedResult = Result.BRIGHT;
 	Prediction expected = new Prediction(expectedResult, false);
 	this.cut.onFailedPrediction(expectedResult);
 }
}

清单 1:注入非托管 EntityManager

由于 EntityManager 在容器之外运行,只能通过单元测试来管理事务。在这种情况下,声明性事务不可用。这进一步简化了测试,因为可以在测试方法中显式设置事务边界。可以使用显式 EntityTransaction#commit() 调用轻松刷新 EntityManager 缓存。刷新之后,数据在数据库中可用,并且可以对数据进行验证以用于测试目的(参见清单 1 中的方法 savingSuccessfulPrediction)。

独立 JPA 配置

EntityManager 是 JPA 规范的一部分,它已经包括在 glassfish-embedded-all 依赖项中。幸运的是,同一依赖项附带 EclipseLink 实现。您只需一个外部数据库来持久保存数据。Derby 数据库无需任何安装即可在服务器中以嵌入式和内存模式使用。

 	<dependency>
 		<groupId>org.apache.derby</groupId>
 		<artifactId>derbyclient</artifactId>
 		<version>10.7.1.1</version>
 		<scope>test</scope>
 	</dependency>

清单 2:“安装”Derby 数据库

Derby 保存在标准 Maven 信息库中,并可包含在单个依赖项中(参见清单 2)。将使用测试范围的依赖项,因为 Java 数据库连接 (JDBC) 驱动程序只在测试执行时才需要,不必将其部署或安装在服务器上。

因为我们将在容器外部启动单元测试,所以无法依赖 Java Transaction API (JTA) 事务和 javax.sql.DataSource 的可用性。

	<persistence version=“1.0” xmlns="http://java.sun.com/xml/ns/persistence" 
		xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
			xsi:schemaLocation="http://java.sun.com/xml/ns/persistence 
                                http://java.sun.com/xml/ns/persistence/persistence_1_0.xsd">
 		<persistence-unit name="integration" transaction-type="RESOURCE_LOCAL">
 			<class>com.abien.testing.oracle.entity.Prediction</class>
 			<exclude-unlisted-classes>true</exclude-unlisted-classes>
 			<properties>
 				<property name="javax.persistence.jdbc.url" value="jdbc:derby:memory:testDB;create=true"/>
 				<property name="javax.persistence.jdbc.driver" value="org.apache.derby.jdbc.EmbeddedDriver"/>
 				<property name="eclipselink.ddl-generation" value="create-tables"/>
 			</properties>
 		</persistence-unit>
	</persistence>

清单 3:单元测试特定的 persistence.xml 配置

src/test/java/META-INF 程序包中创建了一个额外的专用 persistence.xml,专门用于测试。因为没有部署过程,所以必须显式列出所有实体。而且 transaction-type 设置为 RESOURCE_LOCAL,这允许手动处理事务。EntityManager 通过配置好的 JDBC 驱动程序直接与数据库对话,而不是使用数据源声明。对于单元测试,嵌入式 Derby 数据库配置最为方便。EmbeddedDriver 支持两种连接字符串:文件持久性和内存持久性。对于 JPA 映射和查询测试,将采用内存连接字符串(参见清单 3)。所有表都是每次测试之前在内存中动态创建的,测试执行之后,这些表就消失。因为您不必在测试之后清除数据,所以对 JPA 冒烟测试而言,这是最方便的设置。

更复杂的 JPA 测试需要一组定义好的测试数据,而内存设置不能满足这一需求。Derby 数据库还可以使用文件取代内存来持久保存和加载数据。为此您只需修改连接字符串:

 <property name="javax.persistence.jdbc.url" value="jdbc:derby:./sample;create=true”/>

尤其是,需要预定义数据集的测试可以很方便地通过文件持久性配置来执行。在测试执行之前,必须将填充好的数据库复制到项目文件夹,并且在执行之后必须将其删除。因此,在每次运行之后,数据库都将被删除。您甚至无需担心清除或任何修改。

单元测试不是集成测试

您可能已经注意到 PredictionAuditIT 类中看起来有些奇怪的“IT”后缀。该后缀用于区分单元测试与集成测试。标准 Maven failsafe 插件执行所有以 ITITCase 结尾或以 IT 开头的类,但 Maven Surefire 插件和 JUnit 测试会忽略这些类。您只需添加以下依赖项即可区分集成测试与单元测试:

 <plugin>
 	<groupId>org.apache.maven.plugins</groupId>
 	<artifactId>maven-failsafe-plugin</artifactId>
 	<version>2.7.1</version>
 </plugin> 

清单 4:Failsafe 插件配置

单元测试在标准 mvn clean install 执行期间执行,还可以使用 mvn surefire:test 显式启动它们。集成测试也可以使用 execution 标记包括在 Maven 阶段中:

	<plugin>
	<groupId>org.apache.maven.plugins</groupId>
	<artifactId>maven-failsafe-plugin</artifactId>
	<version>2.7.1</version>
	<executions>
 		<execution>
  			<id>integration-test</id>
  			<phase>integration-test</phase>
  			<goals>
  				<goal>integration-test</goal>
  			</goals>
  		</execution>
  		<execution>
  			<id>verify</id>
  			<phase>verify</phase>
  			<goals>
  				<goal>verify</goal>
  			</goals>
  		</execution>
	</executions> 
 </plugin>

清单 5:Failsafe 插件的阶段注册

使用 execution 标记,可以通过 mvn installmvn verifymvn integration-test 命令自动执行 Failsafe 插件。首先执行单元测试,然后执行集成测试。

严格区分集成测试和单元测试将显著缩短周转周期。因此,单元测试使用模拟环境验证方法的功能,比集成测试要快几个数量级。执行单元测试之后,马上就可以得到反馈。

只有成功执行所有单元测试之后,才会触发速度天生较慢的集成测试。根据环境的不同,还可以为单元测试和集成测试设置专用的持续集成作业。然后,mvn clean install 作业将触发 mvn integration-tests 作业。作业区分甚至可以带来更大的灵活性;您可以重新执行每个作业,然后获得有关每个作业进度的通知。

嵌入式集成测试的杀手级用例

在所有测试之中,绝大多数测试不用启动容器就可以执行。可以使用本地创建的 EntityManager 轻松测试 JPA 持久性。另外,还可以通过方便有效的方式在容器外测试业务逻辑。通常,您甚至将模拟所有依赖类、服务和其他管道。

假定我们必须外部化两个错误消息并将它们保存在一个位置以便维护。字符串实例是可注入的,并且可以用于配置目的(参见清单 6)。

	@Inject
	String javaIsDeadError;
	@Inject
	String noConsultantError;
		  //…
		if(JAVA_IS_DEAD.equals(prediction)){
 			throw new IllegalStateException(this.javaIsDeadError);
 		}
		  //…
		if(company.isUnsatisfied()){
			throw new IllegalStateException(this.noConsultantError);

清单 6:通过字符串注入实现外部化

MessageProvider 类将维护配置并产生字段相关值(参见清单 7)。

	@Singleton
	public class MessageProvider {
			public static final String NO_CONSULTANT_ERROR = "No consultant to ask!";
			public static final String JAVA_IS_DEAD_MESSAGE = "Please perform a sanity / reality check";
 
 		private Map<String,String> defaults;
 
 		@PostConstruct
 		public void populateDefaults(){
 			this.defaults = new HashMap<String, String>(){{
 				put("javaIsDeadError", JAVA_IS_DEAD_MESSAGE);
 				put("noConsultantError", NO_CONSULTANT_ERROR);
 			}};
 		}
 
 		@Produces
 		public String getString(InjectionPoint ip){
 			String key = ip.getMember().getName();
 			return defaults.get(key);
 		}
	}

清单 7:通用配置器

如果您查看 MessageProvider(清单 7),就会发现没有什么要进行单元测试了。您可以模拟 InjectionPoint 参数或 Map defaults 字段对查找进行测试。唯一的关键部分是 getString 方法中依赖项注入本身背后的机制。字段名的提取、查找以及注入本身可以仅通过容器内的注入目标来合理地进行测试。注入目标是 OracleResource 类,它使用注入值来产生异常。(参见我的上一篇文章“Java EE 单元测试”。)您必须公开注入值,或者从异常提取消息来对该机制进行测试。您不能使用此方法来测试极端情况,例如未配置的字段。专为测试目的构建的辅助类可以为您提供更大的灵活性并显著简化测试。

public class Configurable {
	@Inject
	private String shouldNotExist;
	@Inject
	private String javaIsDeadError;
 
	public String getShouldNotExist() {
		return shouldNotExist;
 	}
 
 	public String getJavaIsDeadError() {
 		return javaIsDeadError;
 	}
}

清单 8:集成测试辅助类

Configurable 类(清单 8)位于文件夹 src/test/java 中,旨在简化 MessageProvider 类的集成测试。javaIsDeadError 字段需要注入,shouldNotExist 不是用来配置的,在测试之后,其值应为 null

外来工具可以帮助您

Arquillian 是一个有趣的外来工具,是一个作为 JUnit TestRunner 集成的开源测试框架。您可以完全控制部署和注入哪些类。Arquillian 执行 JUnit 测试,并且具有对 src/test/java 文件夹内容的完全访问权限。专用的测试辅助类,如 Configurable(参见清单 8),可用于简化测试,而无需与 src/main/java 中的生产代码一起打包。

Arquillian 包括一个运行器和容器部分。arquillian-junit 运行器在测试用例中执行测试并实现依赖注入。

<dependency>
	<groupId>org.jboss.arquillian</groupId>
	<artifactId>arquillian-junit</artifactId>
 	<version>1.0.0.Alpha5</version>
 	<scope>test</scope>
 </dependency>
 <dependency>
 	<groupId>org.jboss.arquillian.container</groupId>
 	<artifactId>arquillian-glassfish-embedded-3.1</artifactId>
 	<version>1.0.0.Alpha5</version>
 	<scope>test</scope>
 </dependency> 

清单 9:Arquillian Maven 3 配置

arquillian-glassfish-embedded-3.1 依赖项集成了一个应用服务器实现。您可以使用 arquillian-glassfish-embedded-3.1(以及 3.0)、
arquillian-jbossas-embedded-6(及早期版本)、Tomcat、Weld 或 Jetty(参见清单 9)。

 import org.jboss.shrinkwrap.api.*;
 import javax.inject.Inject;
 import org.jboss.arquillian.junit.Arquillian;
 import org.junit.*;
 import static org.junit.Assert.*;
 import static org.hamcrest.CoreMatchers.*;
  
 @RunWith(Arquillian.class)
 public class MessageProviderIT {
 	@Inject
 	MessageProvider messageProvider;
 	@Inject
 	Configurable configurable;
  
 	@Deployment
 	public static JavaArchive createArchiveAndDeploy() {
 		return ShrinkWrap.create(JavaArchive.class, "configuration.jar").
 			addClasses(MessageProvider.class, Configurable.class).
 			addAsManifestResource(
 			new ByteArrayAsset("<beans/>".getBytes()),
 			ArchivePaths.create("beans.xml"));
 }
  
 @Test
 public void injectionWithExistingConfiguration() {
 	String expected = MessageProvider.JAVA_IS_DEAD_MESSAGE;
 	String actual = configurable.getJavaIsDeadError();
 	assertNotNull(actual);
 	assertThat(actual,is(expected));
 }
 
 @Test
 public void injectionWithMissingConfiguration(){
 	String shouldNotExist = configurable.getShouldNotExist();
 	assertNull(shouldNotExist);
 }

清单 10:使用 Arquillian 的嵌入式集成测试

配置依赖项之后,可以使用 Arquillian 作为测试运行器。Arquillian 以透明方式执行单元测试。Maven、Ant,甚至您的 IDE 将只使用 Arquillian 而不是原始测试运行器来执行测试。尽管此间接方式对您是透明的,它仍允许 Arquillian 直接向测试注入已部署的上下文和依赖注入 (CDI) 或 Enterprise JavaBeans (EJB) 组件或其他 Java EE 资源。Arquillian 只是应用服务器实现上面的一个瘦层。它在后台启动 GlassFish、JBoss 或 Tomcat,在“原生”嵌入式应用服务器上执行测试。例如,它在后台使用嵌入式 GlassFish。

Arquillian 要求您先部署存档。在清单 10 中的 createArchiveAndDeploy 方法中,创建并部署了一个带有 MessageProvider 类和 Configurable 类以及空的 beans.xml 的 JAR 存档。已部署的类可以直接注入单元测试本身,这将极大简化测试。injectionWithExistingConfiguration 方法请求测试辅助类 Configurable 返回 javaIsDeadErrorshouldNotExist 值(参见清单 8)。您可以测试代码,就像测试在应用服务器内部执行一样。这只是一个幻觉,情况恰巧相反:测试将启动容器。

测试不可能发生的情况

如果依赖项 Instance<Consultant> company 未满足,结果将难以预料。在生产中这种情况实际上不可能发生,因为总是部署了足够的 Consultant 实现。这就无法在集成环境中测试此极端情况。使用 Arquillian,可以通过操纵部署单元来非常轻松地测试未满足和不确定的依赖项。

@RunWith(Arquillian.class)
public class OracleResourceIT {
 
	@Inject
	OracleResource cut;
 
 	@Deployment
 	public static JavaArchive createArchiveAndDeploy() {
 		return ShrinkWrap.create(JavaArchive.class, "oracle.jar").
 			addClasses(OracleResource.class,MessageProvider.class, Consultant.class).
 			addAsManifestResource(
 			new ByteArrayAsset("<beans/>".getBytes()),
 			ArchivePaths.create("beans.xml"));
 	}
 
 	@Test(expected=IllegalStateException.class)
 	public void predictFutureWithoutConsultants() throws Exception{
 		try {
 			cut.predictFutureOfJava();
 		} catch (EJBException e) {
 			throw e.getCausedByException();
 		}
	}
}

清单 11:测试不可能发生情况

只有绝对必要的类:OracleResourceMessageProviderConsultant,才随清单 11 中的方法 createArchiveAndDeploy 中创建的 oracle.jar 存档一起部署。尽管 OracleResource 类也使用 Event 发送预测 Result(参见清单 12),我们仍将忽略 PredictionAudit 事件监听器,不会部署它。

@Path("javafuture")
@Stateless
public class OracleResource {
 
 	@Inject
 	Instance<Consultant> company;
 
 	@Inject
 	Event<Result> eventListener;
 
 	@Inject
 	private String javaIsDeadError;
 	@Inject
 	private String noConsultantError;
//…business logic omitted
}

清单 12:OracleResource 所需的依赖项

Event 将被吞掉。predictFutureWithoutConsultants 方法调用 OracleResource#predictFutureOfJava 方法并期望在前提条件检查 checkConsultantAvailability 中引发 IllegalStateException(参见清单 13)。

	public String predictFutureOfJava(){
		checkConsultantAvailability();
		Consultant consultant = getConsultant();
		Result prediction = consultant.predictFutureOfJava();
		eventListener.fire(prediction);
		if(JAVA_IS_DEAD.equals(prediction)){
 			throw new IllegalStateException(this.javaIsDeadError);
 		}
 		return prediction.name();
	}
 
	void checkConsultantAvailability(){
		if(company.isUnsatisfied()){
			throw new IllegalStateException(this.noConsultantError);
		}
	}

清单 13:OracleResource 中的前提条件检查

有趣的是,抛出的是 javax.ejb.EJBException 而不是预期的 IllegalStateException(参见清单 14)。

 

WARNING: A system exception occurred during an invocation on EJB OracleResource method 
public java.lang.String com.abien.testing.oracle.boundary.OracleResource.predictFutureOfJava()
javax.ejb.EJBException
//omitting several lines of stacktrace…
	at $Proxy126.predictFutureOfJava(Unknown Source)

	at com.abien.testing.oracle.boundary.__EJB31_Generated__OracleResource__                    __Intf____Bean__.predictFutureOfJava(Unknown Source)

清单 14:使用 EJB 代理的注入堆栈跟踪

单元测试将通过其公共接口访问实际的 EJB 3.1 bean。IllegalStateException 是一个非强制异常,它导致当前事务回滚,并作为 javax.ejb.EJBException 传播。在测试中,我们必须从 EJBException 中解除 IllegalStateException 并重新抛出该异常。

测试回滚

也可以通过引入另一测试辅助类 TransactionRollbackValidator 来轻松测试回滚行为。它甚至可以是一个可以访问 SessionContext 的 EJB 3.1 bean。

@Stateless
public class TransactionRollbackValidator {
 
	@Resource
	SessionContext sc;
 
	@EJB 
	OracleResource os;
 
	public boolean isRollback(){
		try {
			os.predictFutureOfJava();
		} catch (Exception e) {
 			//swallow all exceptions intentionally
 		}
 
 		return sc.getRollbackOnly();
	}
}

清单 15:测试辅助类 EJB 3.1 Bean

TransactionRollbackValidator 测试类调用 OracleResource,吞掉所有异常,并返回当前回滚状态。我们只需稍微扩展 OracleResourceIT 的部署和测试部分,即可同时测试回滚行为(参见清单 16)。

@RunWith(Arquillian.class)
public class OracleResourceIT {
//other injections omitted
	@Inject
	TransactionRollbackValidator validator;
 
	@Deployment
	public static JavaArchive createArchiveAndDeploy() {
		return ShrinkWrap.create(JavaArchive.class, "oracle.jar").
			addClasses(TransactionRollbackValidator.class,
				OracleResource.class,MessageProvider.class, Consultant.class).
 			addAsManifestResource(
 			new ByteArrayAsset("<beans/>".getBytes()),
 			ArchivePaths.create("beans.xml"));
	}
	@Test
	public void rollbackWithoutConsultants(){
		assertTrue(validator.isRollback());
	}
	//already discussed unit test omitted
}

清单 16:测试回滚行为

TransactionRollbackValidator 是一个 EJB 3.1 bean,因此默认情况下,它将随 Required 事务属性一起部署。在此约定之下,TransactionRollbackValidator#isRollback 方法将始终在事务范围内执行。要么启动一个新事务,要么重用现有事务。在我们的测试用例中,TransactionRollbackValidator 在新事务中调用 OracleResource EJB 3.1 bean。这个全新启动的事务被传播到 OracleResource,后者将由于 consultant 不足而引发 EJBExceptionTransactionRollbackValidator 只是返回 SessionContext#getRollbackOnly 调用的结果(参见清单 15)。

如果不修改生产代码或不在 src/main/java 源文件夹中引入其他辅助类,这样的测试将不可能进行。Arquillian 这样的框架为您提供了一个难得的机会,可以轻松测试基础架构而不会使生产代码受到测试辅助类的污染。

……至于持续部署?

在容器内外测试代码可确保应用程序在明确定义的环境下只出现正确的行为。即使是一个小的服务器配置更改或遗忘的资源也会破坏部署。所有现代应用服务器均可通过无头 API 编写脚本或进行配置。通过代码从保存的配置重新构造服务器,不仅可以最大程度减少潜在错误,还可以让手动交互变得多余。您甚至可以在每次推送或提交时将应用程序转入生产。坚实的集成测试和单元测试是“持续部署”的首要前提。

过度使用会影响工作效率

在嵌入式容器内测试所有内容很方便,但效率不高。Java EE 6 组件是加了批注的传统 Java 对象 (POJO),可以轻松使用 Java SE 工具(如 JUnit、TestNG 或 Mockito)进行单元测试和模拟(参见我的上一篇文章“Java EE 单元测试”)。使用嵌入式容器测试业务逻辑不仅效率不高,而且观念上是错误的。单元测试应验证业务逻辑,而不是容器的行为。此外,大多数集成测试可以使用本地 EntityManager(参见清单 1)或模拟容器基础架构来轻松执行。只有小部分集成测试可以通过使用嵌入式容器或测试框架(如 Arquillian)实现而受益。尽管这些测试只代表整个集成测试库的一部分,但它们代表了终极杀手级用例。如果没有嵌入式容器,就不可能在不更改集成环境或不使用测试辅助类污染生产代码的情况下测试依赖于基础架构的业务逻辑。

另请参见

关于作者

顾问兼作家 Adam Bien 是 Java EE 6 和 7、EJB 3.X、JAX-RS、CDI 和 JPA 2.X JSR 专家组成员。他从 JDK 1.0 就开始使用 Java 技术,并在几个大型项目中使用了 servlet/EJB 1.0,目前是 Java SE 和 Java EE 项目的架构师和开发人员。他编辑了多本关于 JavaFX、J2EE 和 Java EE 的图书,并且是《Real World Java EE Patterns—Rethinking Best Practices》《Real World Java EE Night Hacks—Dissecting the Business Tier》两本书的作者。Adam 还是 Java Champion 和 JavaOne 2009 Rock Star。