在Java SE下测试CDI Bean和持久化层

作者:    |       讨论

在测试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支持,并为我们提供了所需的一切:不同事务行为(REQUIREDMANDATORY 等)的方法拦截器,以及一个可移植的扩展,该扩展将拦截器注册到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在撰写本文时的反馈。


返回顶部