diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryEntityGraphTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryEntityGraphTest.java index fc705c10..156a98b4 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryEntityGraphTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryEntityGraphTest.java @@ -5,8 +5,6 @@ import java.util.HashSet; import java.util.Set; import org.hibernate.Hibernate; -import org.hibernate.SessionFactory; -import org.hibernate.stat.Statistics; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; @@ -15,7 +13,7 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.test.annotations.DatabaseTest; import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; -import jakarta.persistence.EntityManager; +import com.digitalsanctuary.spring.user.test.config.StatementCountInspector; /** * Database-slice tests verifying that {@link UserRepository#findWithRolesByEmail(String)} eagerly loads the @@ -62,13 +60,6 @@ private void persistUserWithRolesAndPrivileges(String email) { entityManager.clear(); } - private Statistics statistics() { - EntityManager em = entityManager.getEntityManager(); - Statistics stats = em.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics(); - stats.setStatisticsEnabled(true); - return stats; - } - @Test void shouldInitializeRolesAndPrivilegesWhenLoadedViaEntityGraphFinder() { persistUserWithRolesAndPrivileges("graph@test.com"); @@ -121,8 +112,9 @@ void shouldNotInitializeRolesWhenLoadedViaPlainFindByEmail() { void shouldLoadRolesAndPrivilegesInBoundedQueryCountViaEntityGraphFinder() { persistUserWithRolesAndPrivileges("bounded@test.com"); - Statistics stats = statistics(); - stats.clear(); + // Count prepared statements per-thread rather than reading the SessionFactory-global Statistics counter, + // which is polluted by tests running concurrently on other threads under JUnit parallel execution (GH-337). + StatementCountInspector.reset(); User user = userRepository.findWithRolesByEmail("bounded@test.com"); // Force full traversal to prove no additional (N+1) queries are issued for privileges. @@ -132,10 +124,12 @@ void shouldLoadRolesAndPrivilegesInBoundedQueryCountViaEntityGraphFinder() { } assertThat(privilegeCount).isPositive(); - // A single entity-graph fetch should not degenerate into a per-role / per-privilege N+1 explosion. - // Allow a small bound to tolerate join-table fetch strategy differences across Hibernate versions. - assertThat(stats.getPrepareStatementCount()) + // Upper bound: a single entity-graph fetch should not degenerate into a per-role / per-privilege N+1 + // explosion. A small bound tolerates join-table fetch strategy differences across Hibernate versions. + // Lower bound: at least one statement must be counted, so a broken/unregistered inspector (which would + // read 0) fails loudly instead of passing as a false green. + assertThat(StatementCountInspector.getCount()) .as("EntityGraph finder should load roles + privileges in a bounded number of queries") - .isLessThanOrEqualTo(3); + .isBetween(1, 3); } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/test/config/StatementCountInspector.java b/src/test/java/com/digitalsanctuary/spring/user/test/config/StatementCountInspector.java new file mode 100644 index 00000000..78599e5a --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/test/config/StatementCountInspector.java @@ -0,0 +1,54 @@ +package com.digitalsanctuary.spring.user.test.config; + +import java.io.Serializable; +import org.hibernate.resource.jdbc.spi.StatementInspector; + +/** + * A Hibernate {@link StatementInspector} that counts prepared JDBC statements on a per-thread basis. + * + *
+ * The test suite runs with JUnit 5 parallel execution, and {@code @DataJpaTest} classes share a single cached Spring context (and therefore a + * single {@code SessionFactory} and its {@code SessionFactory}-global {@code Statistics} object). Reading + * {@code Statistics#getPrepareStatementCount()} to guard against N+1 query explosions is therefore polluted by statements issued from tests running + * concurrently on other threads: the counter is neither session-scoped nor transactional, so {@code @DataJpaTest} rollback does not isolate it. + * + *
+ * Because each parallel test executes on its own thread and JDBC connection, this inspector records its count in a {@link ThreadLocal}, yielding a + * stable, per-test measurement that is immune to concurrent pollution. A single instance is registered on the shared test {@code SessionFactory} via + * the {@code hibernate.session_factory.statement_inspector} property (see {@code application-test.properties}); the counter state is {@code static} so + * the inspecting instance created by Hibernate and the assertions in a test share the same per-thread counter. + * + *
+ * Usage: call {@link #reset()} immediately before the code under measurement, then assert on {@link #getCount()}.
+ */
+public final class StatementCountInspector implements StatementInspector, Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ // The counter is intentionally left set (rather than removed) for the calling thread's lifetime: JUnit's parallel
+ // executor reuses a bounded worker-thread pool, so at most one boxed Integer lingers per pool thread, and every
+ // measurement calls reset() first, so a stale value from a prior test can never leak into a new one.
+ private static final ThreadLocal