在测试Java EE应用程序时,我们有很多工具和方案可供选择。根据特定测试的具体目标和要求,选项从单个类的简单单元测试到部署到容器(例如,通过Arquillian)的全面集成测试不等,这些测试可以通过诸如REST Assured之类的工具进行驱动。
在这篇文章中,我想讨论一种中间方案的测试方法:启动一个本地CDI容器和JPA运行时,连接到内存数据库。这样,您可以在纯Java SE环境下测试CDI Bean(例如,包含业务逻辑)与持久化层(例如,基于JPA的存储库)的结合。
这种方法允许测试单个类和组件如何与其他组件交互(例如,在测试业务逻辑时无需模拟存储库),同时仍然受益于快速的执行时间(无需容器管理和部署以及远程API调用)。该方案还可以允许测试应用程序可能依赖的服务,如拦截器、事件、事务语义等,否则可能需要部署到容器中。最后,由于所有内容都在本地VM中运行且不涉及远程进程,因此这些测试易于调试。
为了使这种方法有价值,以下功能应由测试基础设施启用
-
通过依赖注入获取CDI Bean,支持所有CDI特性,如拦截器、装饰器、事件等。
-
通过依赖注入获取JPA实体管理器
-
JPA实体监听器的依赖注入
-
通过
@Transactional
声明性事务控制 -
事务事件观察者(例如,在事务完成后运行的观察者)
在以下内容中,我们将看看如何满足这些要求。您可以在GitHub上的Hibernate 示例代码库中找到显示代码的完整版本。该示例项目使用Weld作为CDI容器,Hibernate ORM作为JPA提供者,以及H2作为数据库。请注意,本文主要关注CDI和持久层之间的交互,您也可以使用这种方法与任何其他数据库(如Postgres或MySQL)一起使用。
通过依赖注入获取CDI Bean
在Java SE下启动CDI容器是CDI 2.0中标准化的小型启动API的一个简单操作。因此,我们可以在测试中使用该API。可以考虑的一个替代方案是Weld JUnit,它是Weld(CDI参考实现)的一个小型扩展,旨在用于测试目的。Weld JUnit允许在测试类中注入依赖项,并在测试期间启用特定的CDI作用域。例如,在测试@RequestScoped
Bean时,这非常有用。
使用Weld JUnit的第一个简单测试可能看起来像这样(请注意,这里我使用的是JUnit 4 API,但Weld JUnit也提供了对JUnit 5的支持)
public class SimpleCdiTest {
@Rule
public WeldInitiator weld = WeldInitiator.from(GreetingService.class)
.activate(RequestScoped.class)
.inject(this)
.build();
@Inject
private GreetingService greeter;
@Test
public void helloWorld() {
assertThat(greeter.greet("Java")).isEqualTo("Hello, Java");
}
}
通过依赖注入获取JPA实体管理器
在下一步中,让我们看看如何通过依赖注入获取JPA实体管理器。通常,您会使用@PersistenceContext
注解来获取此类引用(实际上,Weld JUnit提供了启用该功能的方法),但我更喜欢通过@Inject
(由JSR 330定义)来获取实体管理器,以保持与其他注入点的一致性。这也允许使用构造函数注入而不是字段注入。
为此,我们可以简单地定义一个EntityManagerFactory
的CDI生产者,如下所示
@ApplicationScoped
public class EntityManagerFactoryProducer {
@Produces
@ApplicationScoped
public EntityManagerFactory produceEntityManagerFactory() {
return Persistence.createEntityManagerFactory("myPu", new HashMap<>());
}
public void close(@Disposes EntityManagerFactory entityManagerFactory) {
entityManagerFactory.close();
}
}
这使用JPA启动API构建了一个(应用作用域)实体管理器工厂。以类似的方式,可以生产请求作用域的实体管理器Bean
@ApplicationScoped
public class EntityManagerProducer {
@Inject
private EntityManagerFactory entityManagerFactory;
@Produces
@RequestScoped
public EntityManager produceEntityManager() {
return entityManagerFactory.createEntityManager();
}
public void close(@Disposes EntityManager entityManager) {
entityManager.close();
}
}
请注意,如果您已经在主代码中有这样的生产者,则必须将这些Bean注册为替代方案。
配置好生产者后,我们可以通过@Inject
将实体管理器注入到CDI Bean中
@ApplicationScoped
public class GreetingService {
private final EntityManager entityManager;
@Inject
public GreetingService(EntityManager entityManager) {
this.entityManager = entityManager;
}
// ...
}
JPA实体监听器中的依赖注入
JPA 2.1引入了对JPA实体监听器内CDI的支持。为了实现这一点,JPA提供者(例如Hibernate ORM)必须有一个当前CDI Bean管理器的引用。
在一个如WildFly这样的应用服务器中,容器会自动为我们完成这项连接。对于我们的测试设置,我们需要在启动JPA时自己传递Bean管理器引用。幸运的是,这并不复杂;在EntityManagerFactoryProducer
类中,我们可以通过@Inject
获取一个BeanManager
实例,然后使用"javax.persistence.bean.manager"属性键将其传递给JPA
@Inject
private BeanManager beanManager;
@Produces
@ApplicationScoped
public EntityManagerFactory produceEntityManagerFactory() {
Map<String, Object> props = new HashMap<>();
props.put("javax.persistence.bean.manager", beanManager);
return Persistence.createEntityManagerFactory("myPu", props);
}
这使得我们能够在JPA实体监听器内使用依赖注入
@ApplicationScoped
public class SomeListener {
private final GreetingService greetingService;
@Inject
public SomeListener(GreetingService greetingService) {
this.greetingService = greetingService;
}
@PostPersist
public void onPostPersist(TestEntity entity) {
greetingService.greet(entity.getName());
}
}
通过@Transactional
和事务事件观察器实现声明式事务控制
为了满足我们最初的要求,最后缺少的部分是对@Transactional
注解和事务事件观察器的支持。这稍微复杂一些,因为它需要与兼容JTA(Java事务API)的事务管理器的集成。
以下我们将使用 Narayana,它也是WildFly中使用的交易管理器。为了Narayana能够工作,需要一个JNDI服务器,从中它可以获取JTA数据源。此外,还需要Weld JTA模块。请参考示例项目的 pom.xml 文件以获取确切的Maven工件ID和版本。
放置好这些依赖项后,下一步是将自定义的 ConnectionProvider
插入Hibernate ORM,以确保Hibernate ORM能够与Narayana管理的 Connection
对象协同工作。幸运的是,我的同事Gytis Trikleris已经在GitHub上的Narayana示例中提供了这样的实现 实现。我将无耻地复制这个实现。
public class TransactionalConnectionProvider implements ConnectionProvider {
public static final String DATASOURCE_JNDI = "java:testDS";
public static final String USERNAME = "sa";
public static final String PASSWORD = "";
private final TransactionalDriver transactionalDriver;
public TransactionalConnectionProvider() {
transactionalDriver = new TransactionalDriver();
}
public static void bindDataSource() {
JdbcDataSource dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:db1;DB_CLOSE_DELAY=-1");
dataSource.setUser(USERNAME);
dataSource.setPassword(PASSWORD);
try {
InitialContext initialContext = new InitialContext();
initialContext.bind(DATASOURCE_JNDI, dataSource);
}
catch (NamingException e) {
throw new RuntimeException(e);
}
}
@Override
public Connection getConnection() throws SQLException {
Properties properties = new Properties();
properties.setProperty(TransactionalDriver.userName, USERNAME);
properties.setProperty(TransactionalDriver.password, PASSWORD);
return transactionalDriver.connect("jdbc:arjuna:" + DATASOURCE_JNDI, properties);
}
@Override
public void closeConnection(Connection connection) throws SQLException {
if (!connection.isClosed()) {
connection.close();
}
}
@Override
public boolean supportsAggressiveRelease() {
return false;
}
@Override
public boolean isUnwrappableAs(Class aClass) {
return getClass().isAssignableFrom(aClass);
}
@Override
public <T> T unwrap(Class<T> aClass) {
if (isUnwrappableAs(aClass)) {
return (T) this;
}
throw new UnknownUnwrapTypeException(aClass);
}
}
这会在JNDI中注册一个H2数据源,当Hibernate ORM请求连接时,Narayana的 TransactionalDriver
将从中获取它。此连接将使用JTA事务,无论事务是通过声明方式(通过 @Transactional
),通过注入的 UserTransaction
,还是使用实体管理器事务API来控制的。
必须在测试执行之前调用 bindDataSource()
方法。将此步骤封装在自定义的 JUnit规则 中是个好主意,这样就可以在不同的测试中轻松重复使用此设置。
public class JtaEnvironment extends ExternalResource {
private NamingBeanImpl NAMING_BEAN;
@Override
protected void before() throws Throwable {
NAMING_BEAN = new NamingBeanImpl();
NAMING_BEAN.start();
JNDIManager.bindJTAImplementation();
TransactionalConnectionProvider.bindDataSource();
}
@Override
protected void after() {
NAMING_BEAN.stop();
}
}
这将启动JNDI服务器,并将事务管理器和数据源绑定到JNDI树。在实际的测试类中,我们只需要创建该规则的实例,并用 @Rule
注解字段。
public class CdiJpaTest {
@ClassRule
public static JtaEnvironment jtaEnvironment = new JtaEnvironment();
@Rule
public WeldInitiator weld = ...;
@Test
public void someTest() {
// ...
}
}
下一步,必须将连接提供程序注册到Hibernate ORM中。这可以在 persistence.xml 中完成,但由于此提供程序仅在测试期间使用,所以更好的地方是我们的实体管理器工厂生产方法。
@Produces
@ApplicationScoped
public EntityManagerFactory produceEntityManagerFactory() {
Map<String, Object> props = new HashMap<>();
props.put("javax.persistence.bean.manager", beanManager);
props.put(Environment.CONNECTION_PROVIDER, TransactionalConnectionProvider.class);
return Persistence.createEntityManagerFactory("myPu", props);
}
为了将Weld与事务管理器连接起来,需要一个Weld的 TransactionServices SPI实现。
public class TestingTransactionServices implements TransactionServices {
@Override
public void cleanup() {
}
@Override
public void registerSynchronization(Synchronization synchronizedObserver) {
jtaPropertyManager.getJTAEnvironmentBean()
.getTransactionSynchronizationRegistry()
.registerInterposedSynchronization(synchronizedObserver);
}
@Override
public boolean isTransactionActive() {
try {
return com.arjuna.ats.jta.UserTransaction.userTransaction().getStatus() == Status.STATUS_ACTIVE;
}
catch (SystemException e) {
throw new RuntimeException(e);
}
}
@Override
public UserTransaction getUserTransaction() {
return com.arjuna.ats.jta.UserTransaction.userTransaction();
}
}
这允许Weld
-
注册JTA同步(这是使事务性观察者方法工作所使用的),
-
查询当前事务状态,
-
获取用户事务(以便启用
UserTransaction
对象的注入)。
使用服务加载机制选择 TransactionServices
实现,因此需要一个包含我们实现完全限定名称的文件 META-INF/services/org.jboss.weld.bootstrap.api.Service。
org.hibernate.demos.jpacditesting.support.TestingTransactionServices
有了这些,我们现在可以测试使用事务性观察者的代码了。
@ApplicationScoped
public class SomeObserver {
public void observes(@Observes(during=TransactionPhase.AFTER_COMPLETION) String event) {
// handle event ...
}
}
我们还可以使用JTA的 @Transactional
注解来利用声明式事务控制。
@ApplicationScoped
public class TransactionalGreetingService {
@Transactional(TxType.REQUIRED)
public String greet(String name) {
// ...
}
}
当调用此 greet()
方法时,它必须在事务上下文中运行,这要么是在之前启动的,要么是在需要时启动的。现在,如果您以前使用过事务性CDI豆,您可能会想知道相关的方法拦截器在哪里。实际上,Narayana提供了CDI支持,并为我们提供了所需的一切:不同事务行为(REQUIRED
、MANDATORY
等)的方法拦截器,以及一个可移植的扩展,该扩展将拦截器注册到CDI容器中。
配置Weld启动器
到目前为止,我们忽略了一个细节,那就是Weld将如何检测我们测试所需的全部豆,无论是实际的测试组件(如 GreetingService
),还是测试基础设施(如 EntityManagerProducer
)。最简单的方法是让Weld本身扫描类路径,并收集它找到的所有豆。这可以通过将一个新的 Weld
实例传递给 WeldInitiator
规则来实现。
public class CdiJpaTest {
@ClassRule
public static JtaEnvironment jtaEnvironment = new JtaEnvironment();
@Rule
public WeldInitiator weld = WeldInitiator.from(new Weld())
.activate(RequestScoped.class)
.inject(this)
.build();
@Inject
private EntityManager entityManager;
@Inject
private GreetingService greetingService;
@Test
public void someTest() {
// ...
}
}
这非常方便,但可能会对较大的类路径造成一些延迟,例如暴露你不想在特定测试中启用的替代Bean。因此,作为替代方案,可以在测试期间明确传递所有要使用的Bean类型。
@Rule
public WeldInitiator weld = WeldInitiator.from(
GreetingService.class,
TransactionalGreetingService.class,
EntityManagerProducer.class,
EntityManagerFactoryProducer.class,
TransactionExtension.class,
// ...
)
.activate(RequestScoped.class)
.inject(this)
.build();
这避免了类路径扫描,但代价是增加了编写和维持测试的工作量。另一种方法是使用Weld#addPackages()
方法,以包的粒度指定要包含的内容。我的建议是采用类路径扫描方法,只有在扫描实际上不可行时才切换到显式列出所有类。
总结
在这篇文章中,我们探讨了如何在纯Java SE环境中结合基于JPA的持久层测试应用程序的CDI Bean。这可以是在某些测试中非常有用的中间地带,在这些测试中,你希望超越完全隔离测试单个类,但同时又不愿意在Java EE(或者说,Jakarta EE)容器中运行完整的集成测试。
这是否意味着企业应用程序的所有测试都应该以这种方式实现?当然不是。纯单元测试是断言单个类正确内部功能的一个很好的选择。完整的端到端集成测试确保从上到下,应用程序的所有组件和层都能正确地协同工作。但建议的替代方案可以是一个非常有用的工具,以确保业务逻辑和持久层之间的正确交互,而无需承担容器部署等开销,例如测试正确的事务行为、事务观察者方法和实体监听器使用CDI服务。
话虽如此,如果能减少实现这些测试所需的胶水代码将会更好。虽然我们可以将所需基础设施的管理封装在自定义JUnit规则中,但理想情况下,这已经为我们提供了。因此,我在Weld JUnit项目中提交了一个问题,讨论在项目中创建单独的JPA/JTA模块的想法。简单地添加对该模块的依赖项,就可以获得开始测试CDI Bean和持久层所需的全部内容。如果你对此感兴趣,或者甚至想参与其中,请确保与Weld团队取得联系。
你可以在我们的示例仓库中找到这篇博客的完整源代码。欢迎提出反馈,只需在下面添加评论即可。期待你的回复!
非常感谢Guillaume Smet、Martin Kouba和Matej Novotny在撰写本文时的反馈。