非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>
解决思路
问题根源
- 静态资源未隔离:
H2DatabaseConfig中的server、connection等核心资源都是静态变量,批量测试时这些资源不会在测试类间重置,前一个测试的EntityManager未彻底关闭,导致会话中残留的实体与新测试的实体ID冲突。 - 内存库共享:H2内存库
jdbc:h2:mem:test在同进程中是全局共享的,前一个测试的数据未彻底清空,自增序列未重置,导致新测试插入的实体ID重复。 - 资源清理不彻底:现有
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
相关产品推荐
相关产品推荐

