You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

非Spring Boot环境下JUnit测试H2数据库清理及异常排查

问题

未使用Spring Boot自动配置,在JUnit中配置H2数据库时遇到以下问题:
希望在每个测试用例之间清空所有数据(包括删除所有行并重置自增序列,不想用deleteAll方法),测试类继承负责H2连接配置的H2DatabaseConfig类。
单独运行每个测试用例正常,但批量运行所有测试时抛出异常:

jakarta.persistence.EntityExistsException: A different object with the same identifier value was already associated with the session : [com.example.database.entity.Team]

已知问题和父类H2DatabaseConfig的会话/连接创建方式有关,寻求修复思路。

相关代码

H2DatabaseConfig

public class H2DatabaseConfig {
    private static Server server;
    private static Connection connection;
    protected static EntityManagerFactory entityManagerFactory;
    private static EntityManager entityManager;
    @BeforeEach
    public void setUp() throws SQLException {
        server = Server.createTcpServer().start();
        connection = DriverManager.getConnection("jdbc:h2:mem:test;MODE=MYSQL", "root", "password");

        entityManagerFactory = Persistence.createEntityManagerFactory("entity-manager-test");
        entityManager = entityManagerFactory.createEntityManager();
        Container.initBeans(entityManager);
    }

    @AfterEach
    public void tearDown() throws SQLException {
        connection.close();
        server.stop();
    }
}

TestClass

class FormTeamAddControllerTest extends H2DatabaseConfig {

    private FormTeamAddController formTeamAddController;
    private TeamSavePort teamSavePort;

    @BeforeEach
    public void set() {
        formTeamAddController = new FormTeamAddController();
        teamSavePort = Container.getBean(TeamSavePort.class);
    }
    @Test
    public void shouldSaveTeam() {
        // when
        ValidatorResponse save = formTeamAddController.save("Lowe", "123");

        // then
        assertTrue(save.isOk());
        assertEquals(TeamInputValidator.msgTeamIsCreated("Lowe"), save.getMessage());
    }

    @Test
    public void shouldNotSaveTeamCauseDuplicateTeamName() {
        teamSavePort.save("Star", BigDecimal.valueOf(123));
        // when
        ValidatorResponse save = formTeamAddController.save("Star", "123");

        // then
        assertTrue(save.hasErrors());
        assertEquals(save.getErrors().get("name"), TeamInputValidator.errTeamNameExists());
    }

    @Test
    public void shouldNotSaveTeamCauseWrongName() {
        // when
        ValidatorResponse save1 = formTeamAddController.save(null, "123");
        ValidatorResponse save2 = formTeamAddController.save("  ", "123");
        ValidatorResponse save3 = formTeamAddController.save("", "123");

        // then
        assertTrue(save1.hasErrors());
        assertTrue(save2.hasErrors());
        assertTrue(save3.hasErrors());

        assertEquals(save1.getErrors().get("name"), TeamInputValidator.errTeamName());
        assertEquals(save2.getErrors().get("name"), TeamInputValidator.errTeamName());
        assertEquals(save3.getErrors().get("name"), TeamInputValidator.errTeamName());
    }

    @Test
    public void shouldNotSaveTeamCauseWrongMembershipFee() {
        // when
        ValidatorResponse save1 = formTeamAddController.save("Star", null);
        ValidatorResponse save2 = formTeamAddController.save("Star", "   ");
        ValidatorResponse save3 = formTeamAddController.save("Star", "");

        // then
        assertTrue(save1.hasErrors());
        assertTrue(save2.hasErrors());
        assertTrue(save3.hasErrors());

        assertEquals(TeamInputValidator.errTeamFee(), save1.getErrors().get("membershipPayment"));
        assertEquals(TeamInputValidator.errTeamFee(), save2.getErrors().get("membershipPayment"));
        assertEquals(TeamInputValidator.errTeamFee(), save3.getErrors().get("membershipPayment"));
    }

}

persistence.xml

<persistence-unit name="entity-manager-test" transaction-type="RESOURCE_LOCAL">
        <class>com.example.database.entity.Team</class>

        <properties>
            <property name="javax.persistence.jdbc.driver" value="org.h2.Driver" />
            <property name="javax.persistence.jdbc.url" value="jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;INIT=CREATE SCHEMA IF NOT EXISTS test" />
            <property name="jakarta.persistence.jdbc.user"   value="root" />
            <property name="jakarta.persistence.jdbc.password" value="password" />

            <!-- Generate the database schema -->
            <property name="javax.persistence.schema-generation.database.action" value="drop-and-create"/>

            <!-- Customize the schema generation behavior -->
            <property name="javax.persistence.schema-generation.create-source" value="metadata"/>
            <property name="javax.persistence.schema-generation.drop-source" value="metadata"/>
            <property name="javax.persistence.schema-generation.scripts.action" value="none"/>


        </properties>
    </persistence-unit>
解决思路

问题根源

  1. 静态资源未隔离:H2DatabaseConfig中的server、connection等核心资源都是静态变量,批量测试时这些资源不会在测试类间重置,前一个测试的EntityManager未彻底关闭,导致会话中残留的实体与新测试的实体ID冲突。
  2. 内存库共享:H2内存库jdbc:h2:mem:test在同进程中是全局共享的,前一个测试的数据未彻底清空,自增序列未重置,导致新测试插入的实体ID重复。
  3. 资源清理不彻底:现有tearDown仅关闭connection和server,未关闭EntityManager和EntityManagerFactory,静态引用导致资源泄漏。

修复步骤

1. 移除静态修饰符,实现测试资源隔离

将H2DatabaseConfig中的静态变量改为实例变量,确保每个测试用例拥有独立的连接和会话:

public class H2DatabaseConfig {
    private Server server;
    private Connection connection;
    protected EntityManagerFactory entityManagerFactory;
    private EntityManager entityManager;

    @BeforeEach
    public void setUp() throws SQLException {
        server = Server.createTcpServer().start();
        connection = DriverManager.getConnection("jdbc:h2:mem:test;MODE=MYSQL", "root", "password");

        entityManagerFactory = Persistence.createEntityManagerFactory("entity-manager-test");
        entityManager = entityManagerFactory.createEntityManager();
        Container.initBeans(entityManager);
    }

    @AfterEach
    public void tearDown() throws SQLException {
        // 优先关闭JPA资源
        if (entityManager != null && entityManager.isOpen()) {
            entityManager.close();
        }
        if (entityManagerFactory != null && entityManagerFactory.isOpen()) {
            entityManagerFactory.close();
        }
        connection.close();
        server.stop();
    }
}

2. 为每个测试生成独立H2内存库

修改JDBC URL,为每个测试生成唯一数据库名,避免数据共享:

@BeforeEach
public void setUp() throws SQLException {
    server = Server.createTcpServer().start();
    // 生成唯一数据库名
    String uniqueDbName = "test_" + UUID.randomUUID();
    String jdbcUrl = String.format("jdbc:h2:mem:%s;MODE=MYSQL;DB_CLOSE_ON_EXIT=FALSE", uniqueDbName);
    connection = DriverManager.getConnection(jdbcUrl, "root", "password");

    // 覆盖persistence.xml中的URL
    Map<String, String> props = new HashMap<>();
    props.put("javax.persistence.jdbc.url", jdbcUrl);
    entityManagerFactory = Persistence.createEntityManagerFactory("entity-manager-test", props);
    entityManager = entityManagerFactory.createEntityManager();
    Container.initBeans(entityManager);
}

3. 实现数据清空与序列重置

在@AfterEach中执行SQL语句,直接清空表并重置自增序列,替代deleteAll:

@AfterEach
public void tearDown() throws SQLException {
    if (connection != null && !connection.isClosed()) {
        try (Statement stmt = connection.createStatement()) {
            // 禁用外键约束避免清空失败
            stmt.execute("SET FOREIGN_KEY_CHECKS=0");
            // 获取所有表名
            ResultSet rs = stmt.executeQuery("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='TEST'");
            while (rs.next()) {
                String tableName = rs.getString("TABLE_NAME");
                // 清空表
                stmt.execute("TRUNCATE TABLE " + tableName);
                // 重置自增序列(H2语法)
                stmt.execute("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1");
            }
            stmt.execute("SET FOREIGN_KEY_CHECKS=1");
        }
    }

    // 关闭JPA及数据库资源
    if (entityManager != null && entityManager.isOpen()) {
        entityManager.close();
    }
    if (entityManagerFactory != null && entityManagerFactory.isOpen()) {
        entityManagerFactory.close();
    }
    connection.close();
    server.stop();
}

4. 重置容器Bean

确保Container.initBeans每次都使用新的EntityManager,若Container是单例模式,需在@BeforeEach中重置Bean实例,避免复用旧会话引用。


内容的提问来源于stack exchange,提问作者djulb

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.14 04:36:06