在Java SE下測試CDI Bean和持久層 - relation

banq發表於2019-02-07

在測試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
使用CDI 2.0中標準化的bootstrap API在Java SE下啟動CDI容器是簡單的。所以我們可以在測試中簡單地使用該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提供了一種啟用它的方法),但為了與其他注入點保持一致,我更喜歡透過JSR 330定義的@Inject獲取實體管理器。這也允許建構函式注入而不是欄位注入。
為此,我們可以簡單地定義一個CDI生成器EntityManagerFactory:

@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());
    }
}


宣告式事務控制via @Transactional和事務性事件觀察器​​​​​​​
滿足我們原始要求的最後一個缺失部分是對@Transactional註釋和事務事件觀察者的支援。這個要複雜得多,因為它需要整合與JTA相容的事務管理器(Java Transaction API)。
在下文中,我們將使用Narayana,它也是WildFly中使用的事務管理器。要使Narayana工作,需要一個JNDI伺服器,它可以從中獲取JTA資料來源。此外,還需要焊接JTA模組。請參閱示例專案的pom.xml以獲取確切的工件ID和版本。
有了這些依賴關係,下一步就是將自定義ConnectionProvider插入Hibernate ORM,這可以確保Hibernate ORM與Connection使用Narayana管理的事務的物件一起工作。值得慶幸的是,我的同事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資料來源,TransactionalDriver當Hibernate ORM請求連線時,Narayana 會從中獲取它。此連線將使用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的TransactionServicesSPI:

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 /服務/ 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 ...
    }
}

我們還可以使用J他的@Transactional註釋從宣告式事務控制中受益:

@ApplicationScoped
public class TransactionalGreetingService {

    @Transactional(TxType.REQUIRED)
    public String greet(String name) {
        // ...
    }
}


greet()呼叫此方法時,它必須在事務上下文中執行,該事務上下文已在之前啟動或在需要時啟動。現在,如果您之前使用過事務CDI bean,您可能想知道關聯的方法攔截器在哪裡。事實證明,Narayana自帶CDI支援和為我們提供了所需要的一切:為不同的事務行為方法的攔截器(REQUIRED,MANDATORY等),以及作為與CDI容器註冊攔截器的行動式擴充套件。

配置Weld 啟動器
到目前為止,我們已經忽略了最後一個細節,這就是Weld將如何檢測我們測試所需的所有bean,無論是測試中的實際元件GreetingService,還是測試基礎設施,如EntityManagerProducer。最簡單的方法是讓Weld掃描類路徑本身並獲取它找到的所有bean。透過將新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();


這避免了類路徑掃描,但代價是增加了編寫和維護測試的工作量。另一種方法是使用該WeldaddPackages()方法並指定要包括在包的粒度中的內容。我的建議是採用類路徑掃描方法,如果掃描實際上不可行,則只切換到顯式列出所有類。

總結
在這篇文章中,我們探討了如何在普通Java SE環境中結合基於JPA的持久層測試應用程式的CDI bean。對於某些測試而言,這可能是一個有趣的中間點,您希望在完全隔離的情況下超越測試單個類,但同時又避免在Java EE中執行完整的整合測試(或者我應該說,Jakarta EE)容器。
這是說企業應用程式的所有測試都應該以所描述的方式實現嗎?當然不是。純單元測試是一個很好的選擇,以確定單個類的正確內部功能。完整的端到端整合測試非常有意義,可以確保應用程式的所有部分和層從上到下正確地協同工作。但是建議的替代方案可以是一個非常有用的工具,以確保業務邏輯和持久層的正確互動,而不會產生容器部署的開銷,其中包括測試正確的事務行為,事務觀察器方法和使用CDI服務的實體監聽器。
話雖如此,但為了實現這些測試,需要更少的膠水程式碼是可取的。雖然您可以在自定義JUnit規則中封裝所需基礎架構的管理,但理想情況下,這已經為我們提供了。所以我在Weld JUnit專案中開啟了一張票,討論了在專案中建立單獨的JPA / JTA模組的想法。只需將依賴項新增到此類模組,即可為您提供開始在Java SE下測試CDI bean和持久層所需的一切。如果您對此感興趣或者甚至想對此工作,請務必與Weld團隊取得聯絡。
您可以在我們的示例儲存庫中找到此部落格文章的完整原始碼。您的反饋非常受歡迎,只需在下面新增評論即可。期待您的迴音!​​​​​​​

相關文章