This is an automated email from the ASF dual-hosted git repository.

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 55cb0a3c77 [#6505] improvement(test): Reuse core database fixtures by 
test class (#13518)
55cb0a3c77 is described below

commit 55cb0a3c77d3d3f2d7136fb42182eaece6ec8bc7
Author: Nevin Zheng <[email protected]>
AuthorDate: Mon Oct 19 01:45:17 2026 -0700

    [#6505] improvement(test): Reuse core database fixtures by test class 
(#13518)
    
    ### What changes were proposed in this pull request?
    
    Make core database tests cheap to set up and reset, without changing
    what each test sees.
    
    - **Reuse:** each test class builds its backend once (database,
    connection pool and schema) instead of once per test.
    `SqlSessionFactoryHelper` is process-global, so only one backend can be
    live per JVM. `BackendTestExtension` keeps it in a single slot in the
    JUnit root store, builds a new one when the test class or backend type
    changes, and closes the class's backend when the class finishes
    (`AfterAllCallback`).
    - **Reset:** before each test, the extension empties the shared
    database. One query finds the tables that have rows, and `DELETE` clears
    only those, in one transaction. The reset runs before the test's own
    `@BeforeEach`, so test setup always starts from empty tables.
    - **`@DatabaseFixture(resetStrategy = ...)`** picks how much is rebuilt
    before each test, on a class or a method (a method-level fixture
    replaces the class-level one):
    - `ResetStrategy.DELETE_ROWS` (default): deletes the rows of every
    non-empty table; `AUTO_INCREMENT` counters keep counting.
    - `ResetStrategy.TRUNCATE_TABLES`: truncates every table, so counters
    restart; slower.
    - `ResetStrategy.FRESH_DATABASE`: a new database and backend per test.
    Used by `TestSQLScripts`, `TestJobManagerMultiNode` and
    `TestRelationalEntityStoreBackendLockWaitTimeout`.
    - **Containers:** `AbstractEntityStorageTest` closes the Docker
    containers once at the end of the test plan, instead of after every
    test. `ContainerSuite` cleans the disk only when it starts a container,
    and `ContainerSuite.initialized()` is removed: the close hook registers
    on every run and closes whatever started.
    - **Docs:** `docs/how-to-test.md` lists which database each lane uses,
    how to run one class or method, and the reset strategies.
    
    `TestJDBCBackend` no longer resets tables itself. A test that needs an
    extra reset mid-test calls `resetDatabase()`. Test classes in
    `iceberg-rest-server` (2) and `plugins/tms` (6) extend `TestJDBCBackend`
    through core's test jar, so they get the same reuse and reset; they run
    in `build (17)`.
    
    The extension's own tests run through JUnit's `EngineTestKit` (new test
    dependency `org.junit.platform:junit-platform-testkit`, matching JUnit
    5.8.1). They run small fixture classes through a real JUnit engine and
    check the order of builds, resets and closes, which hand-mocked
    `ExtensionContext`s can't prove. The fixture classes are tagged
    `gravitino-test-fixture`, and core's build excludes that tag from its
    test tasks.
    
    ### Why are the changes needed?
    
    Core database tests rebuilt the backend and schema for nearly every
    test, then truncated all 46 tables before each one. On MySQL and
    PostgreSQL every `TRUNCATE` recreates the table's files and syncs them
    to disk, even when the table is empty: about 1.2 s per test on MySQL and
    0.3 s on PostgreSQL, measured in a container with the current schema.
    Together, rebuilding and truncating dominated each database lane.
    
    Fix: #6505
    
    Related to: #13515
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. The changes are test-only.
    
    ### How was this patch tested?
    
    - `TestBackendTestExtension` (EngineTestKit, 12 cases): reuse within a
    class; a class closing its backend when it finishes; each class closing
    its backend before the next starts, with `TRUNCATE_TABLES` reaching the
    backend; a backend-type change; a fresh class; a fresh method between
    shared ones; a shared method in a fresh class; a subclass inheriting
    `FRESH_DATABASE`; display names; a failed build failing only its test; a
    failed reset discarding the backend; a failed close during a
    replacement. It runs in `coreUnitTest`.
    - `TestResetStrategy` checks on each backend that `DELETE_ROWS` empties
    every table a test wrote to and keeps counters, and that
    `TRUNCATE_TABLES` restarts them.
    - All four lanes pass locally: `coreUnitTest`, `coreH2Test`,
    `coreMySQLTest` and `corePostgreSQLTest`, with 0 failures; so do the
    `iceberg-rest-server` and `plugins/tms` tests that extend
    `TestJDBCBackend`.
    
    Wall time on one machine (Apple Silicon, OrbStack), one round,
    `--rerun-tasks`, build cache off, measured on an earlier revision with
    the same reuse and reset behaviour:
    
    | Lane | `main` | Class reuse only | This PR | Speed-up |
    | --- | ---: | ---: | ---: | ---: |
    | H2 (`coreH2Test`) | 4m 30s | 1m 37s | 1m 39s | 2.7× |
    | MySQL (`coreMySQLTest`) | 33m 58s | 16m 07s | 6m 46s | 5.0× |
    | PostgreSQL (`corePostgreSQLTest`) | 16m 22s | 9m 15s | 6m 12s | 2.6× |
    | **Total** | **54m 50s** | **26m 59s** | **14m 37s** | **3.8×** |
    
    Backend builds per lane fall from 531 to 57. H2 gains only from reuse,
    because truncation is cheap in-process.
    
    CI job times (whole job, setup included), the latest `main` run against
    this PR on the same earlier revision:
    
    | Lane | `main` | This PR | Speed-up |
    | --- | ---: | ---: | ---: |
    | H2 (`core-test (17, h2)`) | 7m 29s | 5m 07s | 1.5× |
    | MySQL (`core-test (17, mysql)`) | 31m 09s | 9m 41s | 3.2× |
    | PostgreSQL (`core-test (17, postgresql)`) | 20m 24s | 7m 28s | 2.7× |
    
    This fixture is the template for a wider look at test-container usage in
    #13900.
    
    **Stack:** **this PR** → #13899 (lower durability)
---
 core/build.gradle.kts                              |   7 +
 .../gravitino/job/TestJobManagerMultiNode.java     |   3 +
 .../storage/AbstractEntityStorageTest.java         |  12 +-
 .../apache/gravitino/storage/TestSQLScripts.java   |   3 +
 .../storage/relational/BackendTestExtension.java   | 311 ++++++++++-----
 .../storage/relational/DatabaseFixture.java        |  45 +++
 .../storage/relational/DatabaseResetter.java       | 146 +++++++
 .../storage/relational/ResetStrategy.java          |  43 ++
 .../relational/TestBackendTestExtension.java       | 434 +++++++++++++++++++++
 .../storage/relational/TestJDBCBackend.java        |  71 +---
 ...elationalEntityStoreBackendLockWaitTimeout.java |  37 +-
 .../storage/relational/TestResetStrategy.java      |  95 +++++
 .../service/TestModelVersionMetaService.java       |   2 +-
 docs/how-to-test.md                                |  36 +-
 gradle/libs.versions.toml                          |   3 +
 .../AbstractIcebergCleanupJobStoreBackendTest.java |   3 +-
 .../service/cleanup/TestIcebergCleanupManager.java |   2 +-
 .../integration/test/container/ContainerSuite.java |   8 +-
 .../test/util/CloseContainerExtension.java         |  15 +-
 19 files changed, 1043 insertions(+), 233 deletions(-)

diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index 2c846b6e48..f281d658c9 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -80,6 +80,7 @@ dependencies {
   testImplementation(libs.awaitility)
   testImplementation(libs.junit.jupiter.api)
   testImplementation(libs.junit.jupiter.params)
+  testImplementation(libs.junit.platform.testkit)
   testImplementation(libs.mockito.core)
   testImplementation(libs.mockito.inline)
   testImplementation(libs.mysql.driver)
@@ -172,6 +173,12 @@ fun registerCoreTestTask(
   }
 }
 
+// Nested fixture classes TestBackendTestExtension runs through EngineTestKit; 
Gradle's class scan
+// would run them directly too. Keep in sync with 
TestBackendTestExtension.FIXTURE.
+tasks.withType<Test>().configureEach {
+  useJUnitPlatform { excludeTags("gravitino-test-fixture") }
+}
+
 registerCoreTestTask("coreUnitTest")
 registerCoreTestTask("coreH2Test", "h2")
 registerCoreTestTask("coreMySQLTest", "mysql")
diff --git 
a/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java 
b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java
index 487645a10d..94c84fd324 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java
@@ -51,7 +51,9 @@ import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.JobEntity;
 import org.apache.gravitino.meta.JobTemplateEntity;
 import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.storage.relational.DatabaseFixture;
 import org.apache.gravitino.storage.relational.RelationalEntityStore;
+import org.apache.gravitino.storage.relational.ResetStrategy;
 import org.apache.gravitino.storage.relational.TestJDBCBackend;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.NamespaceUtil;
@@ -66,6 +68,7 @@ import org.junit.jupiter.api.TestTemplate;
  * JobManager} and {@link LocalJobExecutor}, share the same relational 
metadata store. The status
  * pull and the cleanup are triggered manually, so that the test controls 
which node runs them when.
  */
+@DatabaseFixture(resetStrategy = ResetStrategy.FRESH_DATABASE)
 public class TestJobManagerMultiNode extends TestJDBCBackend {
 
   private static final String METALAKE = "metalake_job_multi_node";
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
 
b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
index b62d01b743..4e2ed6ea5e 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
@@ -83,8 +83,8 @@ import org.apache.gravitino.authorization.SecurableObjects;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NonEmptyEntityException;
 import org.apache.gravitino.file.Fileset;
-import org.apache.gravitino.integration.test.container.ContainerSuite;
 import org.apache.gravitino.integration.test.util.BaseIT;
+import org.apache.gravitino.integration.test.util.CloseContainerExtension;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.CatalogEntity;
@@ -111,13 +111,14 @@ import 
org.apache.gravitino.storage.relational.converters.PostgreSQLExceptionCon
 import 
org.apache.gravitino.storage.relational.converters.SQLExceptionConverterFactory;
 import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.ibatis.session.SqlSession;
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mockito;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 @CoreBackend.All
+@ExtendWith(CloseContainerExtension.class)
 abstract class AbstractEntityStorageTest {
   protected static final Logger LOG = 
LoggerFactory.getLogger(AbstractEntityStorageTest.class);
 
@@ -141,13 +142,6 @@ abstract class AbstractEntityStorageTest {
         .toArray(Object[][]::new);
   }
 
-  @AfterEach
-  void closeSuit() throws IOException {
-    // todo: refactor TestEntityStorage to extend TestJDBCBackend, otherwise, 
each test will start
-    // and stop the container suite.
-    ContainerSuite.getInstance().close();
-  }
-
   protected void init(String type, Config config) throws 
IllegalAccessException {
     Preconditions.checkArgument(StringUtils.isNotBlank(type));
     File dir = new File(DB_DIR);
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java 
b/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java
index e3ccdb44cc..813934acaa 100644
--- a/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java
+++ b/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java
@@ -38,6 +38,8 @@ import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.apache.gravitino.storage.relational.DatabaseFixture;
+import org.apache.gravitino.storage.relational.ResetStrategy;
 import org.apache.gravitino.storage.relational.TestJDBCBackend;
 import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.ibatis.session.SqlSession;
@@ -45,6 +47,7 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.TestTemplate;
 import org.opentest4j.AssertionFailedError;
 
+@DatabaseFixture(resetStrategy = ResetStrategy.FRESH_DATABASE)
 public class TestSQLScripts extends TestJDBCBackend {
 
   @TestTemplate
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java
index d442fb3113..0ff8685823 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java
@@ -34,14 +34,11 @@ import static 
org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_M
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.util.ArrayList;
+import java.sql.SQLException;
 import java.util.Collections;
 import java.util.List;
-import java.util.Optional;
-import java.util.UUID;
-import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Stream;
+import javax.annotation.Nullable;
 import org.apache.commons.io.FileUtils;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Config;
@@ -51,54 +48,67 @@ import org.apache.gravitino.integration.test.util.BaseIT;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.service.EntityIdService;
 import org.junit.jupiter.api.extension.AfterAllCallback;
-import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.AfterEachCallback;
 import org.junit.jupiter.api.extension.BeforeEachCallback;
 import org.junit.jupiter.api.extension.Extension;
 import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.jupiter.api.extension.TestTemplateInvocationContext;
 import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider;
+import org.junit.platform.commons.support.AnnotationSupport;
 import org.mockito.Mockito;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+/**
+ * Runs each {@code @TestTemplate} against a relational backend and injects it 
into {@link
+ * TestJDBCBackend} tests.
+ *
+ * <p>The backend is the one {@link BackendTestSelector} names, which each 
core database lane sets.
+ * With none selected, tests run on H2, plus MySQL and PostgreSQL when {@code 
dockerTest=true}.
+ *
+ * <p>{@code SqlSessionFactoryHelper} is process-global, so only one backend 
can be live in a JVM.
+ * The extension keeps it in a single slot and reuses it for the tests of one 
class, emptying it
+ * before each test per the test's {@link DatabaseFixture}. It builds a new 
backend when the test
+ * class or backend type changes and for every {@link 
ResetStrategy#FRESH_DATABASE} test, and closes
+ * the class's backend when the class finishes.
+ *
+ * <p>The slot is read and replaced without locking, so tests must run one at 
a time: the core
+ * database lanes run in one JVM with JUnit parallel execution off.
+ */
 public class BackendTestExtension
-    implements TestTemplateInvocationContextProvider, BeforeAllCallback, 
AfterAllCallback {
+    implements TestTemplateInvocationContextProvider, AfterAllCallback {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(BackendTestExtension.class);
   private static final String DOCKER_TEST_FLAG = "dockerTest";
 
   private static final ExtensionContext.Namespace NAMESPACE =
       ExtensionContext.Namespace.create(BackendTestExtension.class);
-  private static final String STORE_KEY = "BACKEND_MAP";
+  // Root store: one slot serves every class, and JUnit closes a leftover when 
the run ends.
+  private static final String CURRENT_BACKEND = "CURRENT_BACKEND";
 
-  @Override
-  public void beforeAll(ExtensionContext context) {
-    // Initialize a Map and store it at the Class level.
-    // Key: backendType ("h2", "mysql"...), Value: RelationalBackend instance
-    context.getStore(NAMESPACE).put(STORE_KEY, new ConcurrentHashMap<String, 
RelationalBackend>());
+  private final BackendFactory backendFactory;
+
+  public BackendTestExtension() {
+    this(new DefaultBackendFactory());
+  }
+
+  BackendTestExtension(BackendFactory backendFactory) {
+    this.backendFactory = backendFactory;
   }
 
   @Override
-  @SuppressWarnings("unchecked")
-  public void afterAll(ExtensionContext context) {
-    // Test class ended, close all started Backend
-    ConcurrentHashMap<String, RelationalBackend> map =
-        (ConcurrentHashMap<String, RelationalBackend>) 
context.getStore(NAMESPACE).get(STORE_KEY);
-
-    if (map != null) {
-      map.forEach(
-          (type, backend) -> {
-            try {
-              LOG.info("Tearing down backend: {}", type);
-              backend.close();
-              // H2 special cleaning logic
-              if ("h2".equals(type) && backend instanceof H2BackendWrapper) {
-                ((H2BackendWrapper) backend).cleanFile();
-              }
-            } catch (Exception e) {
-              LOG.error("Failed to close backend {}", type, e);
-            }
-          });
+  public void afterAll(ExtensionContext context) throws Exception {
+    ExtensionContext.Store store = context.getRoot().getStore(NAMESPACE);
+    LiveBackend current = store.get(CURRENT_BACKEND, LiveBackend.class);
+    if (current != null && current.testClass == 
context.getRequiredTestClass()) {
+      closeCurrent(store);
+    }
+  }
+
+  private static void closeCurrent(ExtensionContext.Store store) throws 
IOException {
+    LiveBackend current = store.remove(CURRENT_BACKEND, LiveBackend.class);
+    if (current != null) {
+      current.close();
     }
   }
 
@@ -111,35 +121,28 @@ public class BackendTestExtension
   public Stream<TestTemplateInvocationContext> 
provideTestTemplateInvocationContexts(
       ExtensionContext context) {
     String testMethodName = context.getRequiredTestMethod().getName();
-    Optional<String> selectedBackend = BackendTestSelector.selectedBackend();
-    if (selectedBackend.isPresent()) {
-      LOG.info("Running tests with the selected {} backend.", 
selectedBackend.get());
-      return Stream.of(selectedBackend.get())
-          .map(backendType -> new BackendInvocationContext(testMethodName, 
backendType));
-    }
-
-    List<String> backendsToTest = new ArrayList<>();
-    backendsToTest.add("h2"); // Always test with H2
-
-    String dockerTest = System.getenv(DOCKER_TEST_FLAG);
-    if ("true".equalsIgnoreCase(dockerTest)) {
-      backendsToTest.add("mysql");
-      backendsToTest.add("postgresql");
-      LOG.info("Running tests with H2, MySQL, and PostgreSQL backends.");
-    } else {
-      LOG.info(
-          "Running tests with H2 backend only. Set env var 'dockerTest=true' 
to include all backends.");
-    }
-
-    return backendsToTest.stream()
+    return backends().stream()
         .map(backendType -> new BackendInvocationContext(testMethodName, 
backendType));
   }
 
-  private static class BackendInvocationContext implements 
TestTemplateInvocationContext {
+  List<String> backends() {
+    List<String> backends =
+        BackendTestSelector.selectedBackend()
+            .map(List::of)
+            .orElseGet(
+                () ->
+                    "true".equalsIgnoreCase(System.getenv(DOCKER_TEST_FLAG))
+                        ? List.of("h2", "mysql", "postgresql")
+                        : List.of("h2"));
+    LOG.info("Running tests against {}.", backends);
+    return backends;
+  }
+
+  private class BackendInvocationContext implements 
TestTemplateInvocationContext {
     private final String testMethodName;
     private final String backendType;
 
-    public BackendInvocationContext(String testMethodName, String backendType) 
{
+    private BackendInvocationContext(String testMethodName, String 
backendType) {
       this.testMethodName = testMethodName;
       this.backendType = backendType;
     }
@@ -158,31 +161,141 @@ public class BackendTestExtension
     }
   }
 
-  private static class BackendSetupCallback implements BeforeEachCallback {
+  @FunctionalInterface
+  interface BackendFactory {
+    LiveBackend create(String backendType, Class<?> testClass) throws 
Exception;
+  }
+
+  private class BackendSetupCallback implements BeforeEachCallback, 
AfterEachCallback {
     private final String backendType;
-    private final BaseIT baseIT = new BaseIT();
 
-    public BackendSetupCallback(String backendType) {
+    private BackendSetupCallback(String backendType) {
       this.backendType = backendType;
     }
 
     @Override
     public void beforeEach(ExtensionContext context) throws Exception {
+      ExtensionContext.Store store = context.getRoot().getStore(NAMESPACE);
+      Class<?> testClass = context.getRequiredTestClass();
+      ResetStrategy resetStrategy = resetStrategy(context);
+      boolean fresh = resetStrategy == ResetStrategy.FRESH_DATABASE;
+      LiveBackend current = store.get(CURRENT_BACKEND, LiveBackend.class);
+      if (current != null
+          && (fresh
+              || current.testClass != testClass
+              || !current.backendType.equals(backendType))) {
+        // Only one backend can be live (see the class Javadoc), so the old 
one goes first.
+        closeCurrent(store);
+        current = null;
+      }
+      if (current == null) {
+        current = backendFactory.create(backendType, testClass);
+        store.put(CURRENT_BACKEND, current);
+      }
+      // Other core tests overwrite these globals, so point them back before 
every test.
+      current.bindGravitinoEnv();
+      if (!fresh) {
+        // Runs before the test's own @BeforeEach, so its setup always starts 
from empty tables.
+        resetOrDiscard(store, current, resetStrategy);
+      }
 
-      // Lazy loading: If the backend of this type has not started, start it
-      RelationalBackend backend = startBackend(backendType);
-
-      // Inject into the test instance
       Object testInstance = context.getRequiredTestInstance();
       if (testInstance instanceof TestJDBCBackend) {
-        LOG.info("Injecting {} backend into test instance", backendType);
-        ((TestJDBCBackend) testInstance).setBackend(backend);
+        ((TestJDBCBackend) testInstance).setBackend(current.backend);
         ((TestJDBCBackend) testInstance).setBackendType(backendType);
       }
     }
 
-    private RelationalBackend startBackend(String type) throws Exception {
-      LOG.info("Initializing backend resource: {}", type);
+    @Override
+    public void afterEach(ExtensionContext context) throws Exception {
+      if (resetStrategy(context) == ResetStrategy.FRESH_DATABASE) {
+        closeCurrent(context.getRoot().getStore(NAMESPACE));
+      }
+    }
+
+    // A reset that fails (e.g. on a lock an earlier test left) would fail 
every later test the same
+    // way, so drop the backend and let the next test build a clean one.
+    private void resetOrDiscard(
+        ExtensionContext.Store store, LiveBackend current, ResetStrategy 
resetStrategy)
+        throws Exception {
+      try {
+        current.reset(resetStrategy);
+      } catch (Exception e) {
+        try {
+          closeCurrent(store);
+        } catch (IOException closeFailure) {
+          e.addSuppressed(closeFailure);
+        }
+        throw e;
+      }
+    }
+
+    private ResetStrategy resetStrategy(ExtensionContext context) {
+      return AnnotationSupport.findAnnotation(
+              context.getRequiredTestMethod(), DatabaseFixture.class)
+          .or(
+              () ->
+                  AnnotationSupport.findAnnotation(
+                      context.getRequiredTestClass(), DatabaseFixture.class))
+          .map(DatabaseFixture::resetStrategy)
+          .orElse(ResetStrategy.DELETE_ROWS);
+    }
+  }
+
+  /**
+   * Closes with the root store, in reverse insertion order, so before the 
containers {@code
+   * CloseContainerExtension} registered earlier.
+   */
+  static class LiveBackend implements ExtensionContext.Store.CloseableResource 
{
+    private final String backendType;
+    private final RelationalBackend backend;
+    private final Config config;
+    @Nullable private final File h2Directory;
+    private final Class<?> testClass;
+
+    LiveBackend(
+        String backendType,
+        Class<?> testClass,
+        RelationalBackend backend,
+        Config config,
+        @Nullable File h2Directory) {
+      this.backendType = backendType;
+      this.testClass = testClass;
+      this.backend = backend;
+      this.config = config;
+      this.h2Directory = h2Directory;
+    }
+
+    void bindGravitinoEnv() throws IllegalAccessException {
+      FieldUtils.writeField(GravitinoEnv.getInstance(), "config", config, 
true);
+      FieldUtils.writeField(
+          GravitinoEnv.getInstance(), "idGenerator", 
RandomIdGenerator.INSTANCE, true);
+      EntityIdService.initialize(new RelationalEntityStoreIdResolver());
+    }
+
+    void reset(ResetStrategy resetStrategy) throws SQLException {
+      DatabaseResetter.reset(backendType, resetStrategy);
+    }
+
+    @Override
+    public void close() throws IOException {
+      LOG.info("Tearing down backend: {}", backendType);
+      try {
+        backend.close();
+      } finally {
+        if (h2Directory != null) {
+          FileUtils.deleteDirectory(h2Directory);
+        }
+      }
+    }
+  }
+
+  private static class DefaultBackendFactory implements BackendFactory {
+    private final BaseIT baseIT = new BaseIT();
+
+    @Override
+    public LiveBackend create(String backendType, Class<?> testClass) throws 
Exception {
+      LOG.info("Initializing backend resource: {}", backendType);
       Config config = Mockito.mock(Config.class);
       
Mockito.when(config.get(Configs.ENTITY_STORE)).thenReturn(Configs.RELATIONAL_ENTITY_STORE);
       Mockito.when(config.get(Configs.ENTITY_RELATIONAL_STORE))
@@ -194,20 +307,15 @@ public class BackendTestExtension
           .thenReturn(DEFAULT_RELATIONAL_JDBC_BACKEND_MAX_WAIT_MILLISECONDS);
 
       Mockito.when(config.get(CACHE_ENABLED)).thenReturn(true);
-
-      FieldUtils.writeField(GravitinoEnv.getInstance(), "config", config, 
true);
-      FieldUtils.writeField(
-          GravitinoEnv.getInstance(), "idGenerator", 
RandomIdGenerator.INSTANCE, true);
-
-      RelationalBackend backend = new JDBCBackend();
-      if ("mysql".equals(type)) {
+      File h2Directory = null;
+      if ("mysql".equals(backendType)) {
         String url = baseIT.startAndInitMySQLBackend();
         
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_URL)).thenReturn(url);
         
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_USER)).thenReturn("root");
         
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_PASSWORD)).thenReturn("root");
         Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER))
             .thenReturn("com.mysql.cj.jdbc.Driver");
-      } else if ("postgresql".equals(type)) {
+      } else if ("postgresql".equals(backendType)) {
         String url = baseIT.startAndInitPGBackend();
         
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_URL)).thenReturn(url);
         
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_USER)).thenReturn("root");
@@ -215,43 +323,34 @@ public class BackendTestExtension
         Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER))
             .thenReturn("org.postgresql.Driver");
       } else {
-        // H2 Logic
-        String uuid = UUID.randomUUID().toString().replace("-", "");
-        String jdbcPath = "/tmp/gravitino_jdbc_test_h2_" + uuid;
-        File dir = new File(jdbcPath);
-        if (!dir.exists() && !dir.mkdirs()) throw new IOException("Create dir 
failed");
-
+        h2Directory = 
Files.createTempDirectory("gravitino_jdbc_test_h2_").toFile();
         Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_URL))
-            
.thenReturn(String.format("jdbc:h2:file:%s;DB_CLOSE_DELAY=-1;MODE=MYSQL", 
jdbcPath));
+            .thenReturn(
+                String.format(
+                    "jdbc:h2:file:%s;DB_CLOSE_DELAY=-1;MODE=MYSQL",
+                    // Inside the directory, so deleting it removes H2's 
.mv.db files too.
+                    new File(h2Directory, "gravitino").getPath()));
         
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_USER)).thenReturn("root");
         
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_PASSWORD)).thenReturn("123456");
         
Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER)).thenReturn("org.h2.Driver");
-
-        // Wrap it with a Wrapper so that files can be deleted during the 
clean process
-        backend = new H2BackendWrapper(config, jdbcPath);
       }
 
-      // close the backend before initializing to make sure the singleton 
sqlSession
-      // has been cleared.
-      backend.close();
-      backend.initialize(config);
-      EntityIdService.initialize(new RelationalEntityStoreIdResolver());
-      return backend;
-    }
-  }
-
-  // A simple Wrapper solves the H2 file cleanup issue.
-  public static class H2BackendWrapper extends JDBCBackend {
-    private final String path;
-
-    public H2BackendWrapper(Config config, String path) {
-      this.path = path;
-      super.initialize(config);
-    }
-
-    public void cleanFile() throws IOException {
-      if (Files.exists(Paths.get(path))) {
-        FileUtils.deleteDirectory(new File(path));
+      RelationalBackend backend = new JDBCBackend();
+      LiveBackend created = new LiveBackend(backendType, testClass, backend, 
config, h2Directory);
+      try {
+        created.bindGravitinoEnv();
+        // close() first: it drops the global SqlSessionFactoryHelper session 
another test (e.g.
+        // TestEntityStorage) may have left open.
+        backend.close();
+        backend.initialize(config);
+        return created;
+      } catch (Exception e) {
+        try {
+          created.close();
+        } catch (IOException closeFailure) {
+          e.addSuppressed(closeFailure);
+        }
+        throw e;
       }
     }
   }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java
new file mode 100644
index 0000000000..fc3cba666f
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.storage.relational;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Declares how much of the database is rebuilt before each test of a class or 
method. A
+ * method-level fixture replaces the class-level one. Without the annotation, 
a test class shares
+ * one database and {@link ResetStrategy#DELETE_ROWS} empties it before each 
test.
+ *
+ * <p>{@code @Inherited} applies to classes only: a subclass method that 
overrides an annotated
+ * method must repeat the annotation.
+ */
+@Documented
+@Inherited
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+public @interface DatabaseFixture {
+
+  /** What is rebuilt before each test. */
+  ResetStrategy resetStrategy() default ResetStrategy.DELETE_ROWS;
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseResetter.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseResetter.java
new file mode 100644
index 0000000000..cc076d7361
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseResetter.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.storage.relational;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.session.SqlSession;
+
+/**
+ * Empties the live backend's tables before a test, as its {@link 
ResetStrategy} asks. {@link
+ * ResetStrategy#FRESH_DATABASE} has nothing to empty, so it is rejected.
+ */
+final class DatabaseResetter {
+
+  private static final int RESET_TIMEOUT_SECONDS = 30;
+
+  private DatabaseResetter() {}
+
+  static void reset(String backendType, ResetStrategy resetStrategy) throws 
SQLException {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Statement statement = sqlSession.getConnection().createStatement()) {
+      // A lock left by an earlier test would block the reset forever 
(PostgreSQL has no default
+      // lock timeout); fail this test instead.
+      statement.setQueryTimeout(RESET_TIMEOUT_SECONDS);
+      try {
+        List<String> allTables = tables(backendType, statement);
+        switch (resetStrategy) {
+          case DELETE_ROWS:
+            deleteRows(sqlSession.getConnection(), statement, 
nonEmptyTables(statement, allTables));
+            break;
+          case TRUNCATE_TABLES:
+            // Every table, not only non-empty ones: an earlier DELETE_ROWS 
test may have emptied a
+            // table but left its counter running.
+            truncate(backendType, statement, allTables);
+            break;
+          default:
+            throw new IllegalArgumentException("Nothing to reset for " + 
resetStrategy);
+        }
+      } finally {
+        // H2 applies a statement's timeout to its whole session, and the pool 
keeps the
+        // connection, so the test that runs next would inherit it.
+        statement.setQueryTimeout(0);
+      }
+    }
+  }
+
+  private static void deleteRows(Connection connection, Statement statement, 
List<String> tables)
+      throws SQLException {
+    if (tables.isEmpty()) {
+      return;
+    }
+    // One transaction, so the deletes share one commit instead of one fsync 
per table.
+    connection.setAutoCommit(false);
+    try {
+      for (String table : tables) {
+        statement.execute("DELETE FROM " + table);
+      }
+      connection.commit();
+    } catch (SQLException e) {
+      connection.rollback();
+      throw e;
+    } finally {
+      connection.setAutoCommit(true);
+    }
+  }
+
+  private static void truncate(String backendType, Statement statement, 
List<String> tables)
+      throws SQLException {
+    if (tables.isEmpty()) {
+      return;
+    }
+    if ("postgresql".equalsIgnoreCase(backendType)) {
+      statement.execute(
+          tables.stream()
+              .map(table -> "TRUNCATE TABLE " + table + " RESTART IDENTITY 
CASCADE;")
+              .collect(Collectors.joining("\n", "DO $$ BEGIN\n", "\nEND 
$$;")));
+      return;
+    }
+    // H2 keeps identity counters on TRUNCATE unless asked; MySQL always 
restarts them.
+    String restart = "h2".equalsIgnoreCase(backendType) ? " RESTART IDENTITY" 
: "";
+    for (String table : tables) {
+      statement.execute("TRUNCATE TABLE " + table + restart);
+    }
+  }
+
+  private static List<String> tables(String backendType, Statement statement) 
throws SQLException {
+    String query =
+        "postgresql".equalsIgnoreCase(backendType)
+            ? "SELECT table_name FROM information_schema.tables"
+                + " WHERE table_schema = current_schema() AND table_type = 
'BASE TABLE'"
+            : "SHOW TABLES";
+    List<String> tables = new ArrayList<>();
+    try (ResultSet rs = statement.executeQuery(query)) {
+      while (rs.next()) {
+        tables.add(rs.getString(1));
+      }
+    }
+    return tables;
+  }
+
+  // One query finds the tables a test wrote to, so DELETE_ROWS issues one 
DELETE per dirty table
+  // rather than one per table.
+  private static List<String> nonEmptyTables(Statement statement, List<String> 
tables)
+      throws SQLException {
+    List<String> dirtyTables = new ArrayList<>();
+    if (tables.isEmpty()) {
+      return dirtyTables;
+    }
+    // Table names come from the database's own catalog, not from input, so 
formatting them in is
+    // safe.
+    String query =
+        tables.stream()
+            .map(table -> String.format("(SELECT '%s' FROM %s LIMIT 1)", 
table, table))
+            .collect(Collectors.joining(" UNION ALL "));
+    try (ResultSet rs = statement.executeQuery(query)) {
+      while (rs.next()) {
+        dirtyTables.add(rs.getString(1));
+      }
+    }
+    return dirtyTables;
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/ResetStrategy.java 
b/core/src/test/java/org/apache/gravitino/storage/relational/ResetStrategy.java
new file mode 100644
index 0000000000..c6f88b0185
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/ResetStrategy.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.storage.relational;
+
+/**
+ * What is rebuilt before each database-backed test, cheapest first. The first 
two share the test
+ * class's database; {@link #FRESH_DATABASE} builds its own.
+ */
+public enum ResetStrategy {
+  /** Rows are deleted from every non-empty table. {@code AUTO_INCREMENT} 
counters keep counting. */
+  DELETE_ROWS,
+
+  /**
+   * Every table is truncated, which restarts its {@code AUTO_INCREMENT} 
counters. Slower than
+   * {@link #DELETE_ROWS} because {@code TRUNCATE} recreates each table; use 
it only when a test
+   * relies on the counters.
+   */
+  TRUNCATE_TABLES,
+
+  /**
+   * A new database (a new schema on PostgreSQL) and backend for the test, 
closed afterwards. Use it
+   * when a test changes the schema, closes the backend itself, or leaves 
writers running after it
+   * ends. Costs a full schema build per test.
+   */
+  FRESH_DATABASE
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java
new file mode 100644
index 0000000000..091e6b82ed
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java
@@ -0,0 +1,434 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.storage.relational;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.platform.testkit.engine.EventConditions.displayName;
+import static org.junit.platform.testkit.engine.EventConditions.event;
+import static 
org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
+import static org.junit.platform.testkit.engine.EventConditions.test;
+import static 
org.junit.platform.testkit.engine.TestExecutionResultConditions.message;
+import static org.mockito.Mockito.mock;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.gravitino.Config;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.platform.engine.DiscoverySelector;
+import org.junit.platform.engine.discovery.DiscoverySelectors;
+import org.junit.platform.testkit.engine.EngineTestKit;
+import org.junit.platform.testkit.engine.Events;
+
+/**
+ * Runs small fixture classes through a real JUnit engine and checks, from a 
log of what the
+ * extension did, when it builds, resets and closes backends.
+ */
+class TestBackendTestExtension {
+
+  // Gradle would otherwise discover and run these nested fixtures directly; 
core/build.gradle.kts
+  // excludes the tag. Keep the two in sync.
+  private static final String FIXTURE = "gravitino-test-fixture";
+
+  private static final List<String> LOG = new ArrayList<>();
+  private static boolean failNextCreate;
+  private static boolean failNextReset;
+  private static boolean failNextClose;
+
+  @BeforeEach
+  void resetFake() {
+    LOG.clear();
+    failNextCreate = false;
+    failNextReset = false;
+    failNextClose = false;
+  }
+
+  @Test
+  void testClassReusesOneBackend() {
+    run(SharedFixture.class).assertStatistics(stats -> 
stats.succeeded(2).failed(0));
+
+    assertEquals(
+        List.of(
+            "create h2 SharedFixture",
+            "reset DELETE_ROWS",
+            "reset DELETE_ROWS",
+            "close h2 SharedFixture"),
+        LOG);
+  }
+
+  @Test
+  void testEachClassClosesItsBackendBeforeTheNextStarts() {
+    run(SharedFixture.class, TruncateFixture.class)
+        .assertStatistics(stats -> stats.succeeded(3).failed(0));
+
+    assertEquals(
+        List.of(
+            "create h2 SharedFixture",
+            "reset DELETE_ROWS",
+            "reset DELETE_ROWS",
+            "close h2 SharedFixture",
+            "create h2 TruncateFixture",
+            "reset TRUNCATE_TABLES",
+            "close h2 TruncateFixture"),
+        LOG);
+  }
+
+  @Test
+  void testClassClosesItsBackendWhenItFinishes() {
+    run(SharedFixture.class, UnmanagedFixture.class)
+        .assertStatistics(stats -> stats.succeeded(3).failed(0));
+
+    // UnmanagedFixture has no extension, so only SharedFixture's own afterAll 
can close it first.
+    assertEquals(
+        List.of(
+            "create h2 SharedFixture",
+            "reset DELETE_ROWS",
+            "reset DELETE_ROWS",
+            "close h2 SharedFixture",
+            "unmanaged test"),
+        LOG);
+  }
+
+  @Test
+  void testResetRunsBeforeTheTestsOwnBeforeEach() {
+    run(BeforeEachFixture.class).assertStatistics(stats -> 
stats.succeeded(1).failed(0));
+
+    assertEquals(
+        List.of(
+            "create h2 BeforeEachFixture",
+            "reset DELETE_ROWS",
+            "test's @BeforeEach",
+            "close h2 BeforeEachFixture"),
+        LOG);
+  }
+
+  @Test
+  void testBackendTypeChangeReplacesTheBackend() {
+    run(TwoBackendFixture.class).assertStatistics(stats -> 
stats.succeeded(2).failed(0));
+
+    assertEquals(
+        List.of(
+            "create h2 TwoBackendFixture",
+            "reset DELETE_ROWS",
+            "close h2 TwoBackendFixture",
+            "create mysql TwoBackendFixture",
+            "reset DELETE_ROWS",
+            "close mysql TwoBackendFixture"),
+        LOG);
+  }
+
+  @Test
+  void testFreshClassGetsABackendPerTest() {
+    run(FreshFixture.class).assertStatistics(stats -> 
stats.succeeded(2).failed(0));
+
+    assertEquals(
+        List.of(
+            "create h2 FreshFixture",
+            "close h2 FreshFixture",
+            "create h2 FreshFixture",
+            "close h2 FreshFixture"),
+        LOG);
+  }
+
+  @Test
+  void testSubclassInheritsFreshDatabase() {
+    run(InheritedFreshFixture.class).assertStatistics(stats -> 
stats.succeeded(3).failed(0));
+
+    assertEquals(3, count("create h2 InheritedFreshFixture"));
+    assertEquals(0, LOG.stream().filter(entry -> 
entry.startsWith("reset")).count());
+    assertEquals(3, count("close h2 InheritedFreshFixture"));
+  }
+
+  @Test
+  void testFreshMethodReplacesTheSharedBackend() {
+    run(MixedFixture.class).assertStatistics(stats -> 
stats.succeeded(3).failed(0));
+
+    assertEquals(
+        List.of(
+            "create h2 MixedFixture",
+            "reset DELETE_ROWS",
+            "close h2 MixedFixture",
+            "create h2 MixedFixture",
+            "close h2 MixedFixture",
+            "create h2 MixedFixture",
+            "reset DELETE_ROWS",
+            "close h2 MixedFixture"),
+        LOG);
+  }
+
+  @Test
+  void testSharedMethodInsideFreshClass() {
+    run(FreshClassWithSharedMethodFixture.class)
+        .assertStatistics(stats -> stats.succeeded(3).failed(0));
+
+    assertEquals(
+        List.of(
+            "create h2 FreshClassWithSharedMethodFixture",
+            "reset DELETE_ROWS",
+            "reset DELETE_ROWS",
+            "close h2 FreshClassWithSharedMethodFixture",
+            "create h2 FreshClassWithSharedMethodFixture",
+            "close h2 FreshClassWithSharedMethodFixture"),
+        LOG);
+  }
+
+  @Test
+  void testFailedCreateFailsOnlyThatTest() {
+    failNextCreate = true;
+    Events events = run(SharedFixture.class);
+
+    events.assertStatistics(stats -> stats.succeeded(1).failed(1));
+    events
+        .assertThatEvents()
+        .haveExactly(1, event(test("first"), 
finishedWithFailure(message("create failed"))));
+
+    assertEquals(
+        List.of(
+            "create failed",
+            "create h2 SharedFixture",
+            "reset DELETE_ROWS",
+            "close h2 SharedFixture"),
+        LOG);
+  }
+
+  @Test
+  void testFailedResetDiscardsTheBackend() {
+    failNextReset = true;
+    Events events = run(SharedFixture.class);
+
+    events.assertStatistics(stats -> stats.succeeded(1).failed(1));
+    events
+        .assertThatEvents()
+        .haveExactly(1, event(test("first"), 
finishedWithFailure(message("reset failed"))));
+
+    assertEquals(
+        List.of(
+            "create h2 SharedFixture",
+            "reset failed",
+            "close h2 SharedFixture",
+            "create h2 SharedFixture",
+            "reset DELETE_ROWS",
+            "close h2 SharedFixture"),
+        LOG);
+  }
+
+  @Test
+  void testFailedCloseOnReplacementFailsTheReplacingTest() {
+    failNextClose = true;
+    Events events = run(TwoBackendFixture.class);
+
+    events.assertStatistics(stats -> stats.succeeded(1).failed(1));
+    events
+        .assertThatEvents()
+        .haveExactly(
+            1,
+            event(
+                test(),
+                displayName("first[MYSQL Backend]"),
+                finishedWithFailure(message("close failed"))));
+
+    // The failed close leaves the slot empty, so nothing is left to close at 
the end.
+    assertEquals(
+        List.of("create h2 TwoBackendFixture", "reset DELETE_ROWS", "close h2 
TwoBackendFixture"),
+        LOG);
+  }
+
+  @Test
+  void testDisplayNameNamesTheBackend() {
+    List<String> names =
+        run(TwoBackendFixture.class).started().stream()
+            .map(event -> event.getTestDescriptor().getDisplayName())
+            .collect(Collectors.toList());
+
+    assertEquals(List.of("first[H2 Backend]", "first[MYSQL Backend]"), names);
+  }
+
+  private static Events run(Class<?>... fixtures) {
+    return EngineTestKit.engine("junit-jupiter")
+        // Tests assert the cross-class order of LOG; system properties must 
not change it.
+        .enableImplicitConfigurationParameters(false)
+        .configurationParameter(
+            "junit.jupiter.testclass.order.default", 
"org.junit.jupiter.api.ClassOrderer$ClassName")
+        .selectors(
+            Arrays.stream(fixtures)
+                .map(DiscoverySelectors::selectClass)
+                .toArray(DiscoverySelector[]::new))
+        .execute()
+        .testEvents();
+  }
+
+  private static long count(String entry) {
+    return LOG.stream().filter(entry::equals).count();
+  }
+
+  private static BackendTestExtension fakeExtension(String... backends) {
+    return new BackendTestExtension(
+        (backendType, testClass) -> {
+          if (failNextCreate) {
+            failNextCreate = false;
+            LOG.add("create failed");
+            throw new IllegalStateException("create failed");
+          }
+          String name = backendType + " " + testClass.getSimpleName();
+          LOG.add("create " + name);
+          return new BackendTestExtension.LiveBackend(
+              backendType, testClass, mock(RelationalBackend.class), 
mock(Config.class), null) {
+            @Override
+            void bindGravitinoEnv() {}
+
+            @Override
+            void reset(ResetStrategy resetStrategy) {
+              if (failNextReset) {
+                failNextReset = false;
+                LOG.add("reset failed");
+                throw new IllegalStateException("reset failed");
+              }
+              LOG.add("reset " + resetStrategy);
+            }
+
+            @Override
+            public void close() throws IOException {
+              LOG.add("close " + name);
+              if (failNextClose) {
+                failNextClose = false;
+                throw new IOException("close failed");
+              }
+            }
+          };
+        }) {
+      @Override
+      List<String> backends() {
+        return List.of(backends);
+      }
+    };
+  }
+
+  @Tag(FIXTURE)
+  @TestMethodOrder(MethodOrderer.MethodName.class)
+  static class SharedFixture {
+    @RegisterExtension static final BackendTestExtension EXTENSION = 
fakeExtension("h2");
+
+    @TestTemplate
+    void first() {}
+
+    @TestTemplate
+    void second() {}
+  }
+
+  @Tag(FIXTURE)
+  static class BeforeEachFixture {
+    @RegisterExtension static final BackendTestExtension EXTENSION = 
fakeExtension("h2");
+
+    @BeforeEach
+    void setUp() {
+      LOG.add("test's @BeforeEach");
+    }
+
+    @TestTemplate
+    void first() {}
+  }
+
+  @Tag(FIXTURE)
+  static class UnmanagedFixture {
+    @Test
+    void first() {
+      LOG.add("unmanaged test");
+    }
+  }
+
+  @Tag(FIXTURE)
+  @DatabaseFixture(resetStrategy = ResetStrategy.TRUNCATE_TABLES)
+  static class TruncateFixture {
+    @RegisterExtension static final BackendTestExtension EXTENSION = 
fakeExtension("h2");
+
+    @TestTemplate
+    void first() {}
+  }
+
+  @Tag(FIXTURE)
+  static class TwoBackendFixture {
+    @RegisterExtension static final BackendTestExtension EXTENSION = 
fakeExtension("h2", "mysql");
+
+    @TestTemplate
+    void first() {}
+  }
+
+  @Tag(FIXTURE)
+  @TestMethodOrder(MethodOrderer.MethodName.class)
+  @DatabaseFixture(resetStrategy = ResetStrategy.FRESH_DATABASE)
+  static class FreshFixture {
+    @RegisterExtension static final BackendTestExtension EXTENSION = 
fakeExtension("h2");
+
+    @TestTemplate
+    void first() {}
+
+    @TestTemplate
+    void second() {}
+  }
+
+  @Tag(FIXTURE)
+  @TestMethodOrder(MethodOrderer.MethodName.class)
+  static class InheritedFreshFixture extends FreshFixture {
+    @TestTemplate
+    void third() {}
+  }
+
+  @Tag(FIXTURE)
+  @TestMethodOrder(MethodOrderer.MethodName.class)
+  static class MixedFixture {
+    @RegisterExtension static final BackendTestExtension EXTENSION = 
fakeExtension("h2");
+
+    @TestTemplate
+    void first() {}
+
+    @TestTemplate
+    @DatabaseFixture(resetStrategy = ResetStrategy.FRESH_DATABASE)
+    void second() {}
+
+    @TestTemplate
+    void third() {}
+  }
+
+  @Tag(FIXTURE)
+  @TestMethodOrder(MethodOrderer.MethodName.class)
+  @DatabaseFixture(resetStrategy = ResetStrategy.FRESH_DATABASE)
+  static class FreshClassWithSharedMethodFixture {
+    @RegisterExtension static final BackendTestExtension EXTENSION = 
fakeExtension("h2");
+
+    @TestTemplate
+    @DatabaseFixture(resetStrategy = ResetStrategy.DELETE_ROWS)
+    void first() {}
+
+    @TestTemplate
+    @DatabaseFixture(resetStrategy = ResetStrategy.DELETE_ROWS)
+    void second() {}
+
+    @TestTemplate
+    void third() {}
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
index 41d08dff85..894937a1e6 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
@@ -29,7 +29,6 @@ import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.time.Instant;
-import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -80,10 +79,19 @@ import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.gravitino.utils.NamespaceUtil;
 import org.apache.ibatis.session.SqlSession;
-import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.TestInstance;
 import org.junit.jupiter.api.extension.ExtendWith;
 
+/**
+ * Base class for tests that run against a real relational backend. Write each 
test as a
+ * {@code @TestTemplate}: {@link BackendTestExtension} runs it once per 
backend and sets {@link
+ * #backend} and {@link #backendType} before it. A plain {@code @Test} gets 
neither, nor a reset.
+ *
+ * <p>The class shares one database; before each test, and before the test's 
own
+ * {@code @BeforeEach}, the tables written to are emptied ({@link 
ResetStrategy#DELETE_ROWS}). Use
+ * {@link DatabaseFixture} on the class or a method to choose another {@link 
ResetStrategy}. The
+ * backend is not built yet in {@code @BeforeAll}.
+ */
 @CoreBackend.All
 @TestInstance(TestInstance.Lifecycle.PER_CLASS)
 @ExtendWith({
@@ -106,59 +114,12 @@ public abstract class TestJDBCBackend {
     this.backendType = backendType;
   }
 
-  @BeforeEach
-  public void init() throws SQLException {
-    truncateAllTables();
-  }
-
-  private void truncateAllTables() throws SQLException {
-    try (SqlSession sqlSession =
-        
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true)) 
{
-      try (Connection connection = sqlSession.getConnection()) {
-        try (Statement statement = connection.createStatement()) {
-          if ("postgresql".equalsIgnoreCase(backendType)) {
-            truncateAllTablesForPostgreSQL(connection);
-          } else {
-            String query = "SHOW TABLES";
-            List<String> tableList = new ArrayList<>();
-            try (ResultSet rs = statement.executeQuery(query)) {
-              while (rs.next()) {
-                tableList.add(rs.getString(1));
-              }
-            }
-            for (String table : tableList) {
-              statement.execute("TRUNCATE TABLE " + table);
-            }
-          }
-        }
-      }
-    }
-  }
-
-  private void truncateAllTablesForPostgreSQL(Connection connection) throws 
SQLException {
-    List<String> tableList = new ArrayList<>();
-    try (Statement statement = connection.createStatement()) {
-      String query =
-          "SELECT table_name FROM information_schema.tables WHERE table_schema 
= current_schema()";
-      try (ResultSet rs = statement.executeQuery(query)) {
-        while (rs.next()) {
-          tableList.add(rs.getString(1));
-        }
-      }
-
-      if (tableList.isEmpty()) {
-        return;
-      }
-
-      // Merge all TRUNCATE operations into a single DO block to improve 
efficiency
-      StringBuilder pgTruncateCommand = new StringBuilder("DO $$ BEGIN\n");
-      for (String table : tableList) {
-        pgTruncateCommand.append(
-            String.format("TRUNCATE TABLE %s RESTART IDENTITY CASCADE;", 
table));
-      }
-      pgTruncateCommand.append("END $$;");
-      statement.execute(pgTruncateCommand.toString());
-    }
+  /**
+   * Empties the shared database mid-test ({@link ResetStrategy#DELETE_ROWS}), 
for a test that runs
+   * several scenarios in a loop.
+   */
+  protected void resetDatabase() throws SQLException {
+    DatabaseResetter.reset(backendType, ResetStrategy.DELETE_ROWS);
   }
 
   protected BaseMetalake createAndInsertMakeLake(String metalakeName) throws 
IOException {
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreBackendLockWaitTimeout.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreBackendLockWaitTimeout.java
index 6ee5afb5d0..73f3392409 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreBackendLockWaitTimeout.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreBackendLockWaitTimeout.java
@@ -18,15 +18,12 @@
  */
 package org.apache.gravitino.storage.relational;
 
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.sql.Connection;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.util.List;
 import org.apache.commons.dbcp2.BasicDataSource;
-import org.apache.commons.io.FileUtils;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.Namespace;
@@ -39,7 +36,6 @@ import 
org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.gravitino.storage.relational.session.SqlSessions;
 import org.apache.ibatis.mapping.Environment;
 import org.apache.ibatis.session.Configuration;
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.TestTemplate;
@@ -50,40 +46,9 @@ import org.mockito.Mockito;
  * Verifies real backend lock timeouts, transaction cleanup, and retries 
through the entity store.
  */
 @Tag("gravitino-docker-test")
+@DatabaseFixture(resetStrategy = ResetStrategy.FRESH_DATABASE)
 public class TestRelationalEntityStoreBackendLockWaitTimeout extends 
TestJDBCBackend {
 
-  @AfterEach
-  void closeBackend() throws Exception {
-    String databasePath = null;
-    try {
-      if ("h2".equals(backendType)) {
-        BasicDataSource source =
-            (BasicDataSource)
-                SqlSessionFactoryHelper.getInstance()
-                    .getSqlSessionFactory()
-                    .getConfiguration()
-                    .getEnvironment()
-                    .getDataSource();
-        databasePath = 
source.getUrl().substring("jdbc:h2:file:".length()).split(";")[0];
-        // The shared fixture uses DB_CLOSE_DELAY=-1, so close the engine 
before deleting its files.
-        try (Connection connection = source.getConnection();
-            Statement statement = connection.createStatement()) {
-          statement.execute("SHUTDOWN");
-        }
-      }
-    } finally {
-      try {
-        backend.close();
-      } finally {
-        if (databasePath != null) {
-          FileUtils.deleteDirectory(Path.of(databasePath).toFile());
-          Files.deleteIfExists(Path.of(databasePath + ".mv.db"));
-          Files.deleteIfExists(Path.of(databasePath + ".trace.db"));
-        }
-      }
-    }
-  }
-
   @TestTemplate
   void testDropTimeoutPreservesTableAndRetrySucceeds() throws Exception {
     createParents();
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestResetStrategy.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestResetStrategy.java
new file mode 100644
index 0000000000..1a8098bbe2
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestResetStrategy.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.storage.relational;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.session.SqlSession;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.TestTemplate;
+
+/**
+ * Checks that {@link ResetStrategy#DELETE_ROWS} and {@link 
ResetStrategy#TRUNCATE_TABLES} empty the
+ * tables a test wrote to and leave counters as their Javadoc says; {@code 
TestBackendTestExtension}
+ * covers {@link ResetStrategy#FRESH_DATABASE}.
+ */
+public class TestResetStrategy extends TestJDBCBackend {
+
+  @TestTemplate
+  void testDeleteRowsEmptiesEveryWrittenTable() throws SQLException {
+    insertChangeLogRow();
+    execute(
+        "INSERT INTO table_metrics (table_identifier, metric_name, metric_ts, 
metric_value)"
+            + " VALUES ('table', 'metric', 0, 'value')");
+    DatabaseResetter.reset(backendType, ResetStrategy.DELETE_ROWS);
+    Assertions.assertEquals(0L, rowCount("entity_change_log"));
+    Assertions.assertEquals(0L, rowCount("table_metrics"));
+  }
+
+  @TestTemplate
+  void testDeleteRowsKeepsCounting() throws SQLException {
+    long before = insertChangeLogRow();
+    DatabaseResetter.reset(backendType, ResetStrategy.DELETE_ROWS);
+    Assertions.assertTrue(insertChangeLogRow() > before);
+  }
+
+  @TestTemplate
+  void testTruncateTablesRestartsCounters() throws SQLException {
+    insertChangeLogRow();
+    DatabaseResetter.reset(backendType, ResetStrategy.TRUNCATE_TABLES);
+    Assertions.assertEquals(1L, insertChangeLogRow());
+  }
+
+  private static void execute(String sql) throws SQLException {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Statement statement = sqlSession.getConnection().createStatement()) {
+      statement.executeUpdate(sql);
+    }
+  }
+
+  private static long rowCount(String table) throws SQLException {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Statement statement = sqlSession.getConnection().createStatement();
+        ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM " + 
table)) {
+      Assertions.assertTrue(rs.next());
+      return rs.getLong(1);
+    }
+  }
+
+  private static long insertChangeLogRow() throws SQLException {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Statement statement = sqlSession.getConnection().createStatement()) {
+      statement.executeUpdate(
+          "INSERT INTO entity_change_log"
+              + " (metalake_name, entity_type, entity_full_name, operate_type, 
created_at)"
+              + " VALUES ('metalake', 'TABLE', 'table', 3, 0)",
+          Statement.RETURN_GENERATED_KEYS);
+      try (ResultSet keys = statement.getGeneratedKeys()) {
+        Assertions.assertTrue(keys.next());
+        return keys.getLong(1);
+      }
+    }
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestModelVersionMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestModelVersionMetaService.java
index a83f646909..903c751128 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestModelVersionMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestModelVersionMetaService.java
@@ -988,7 +988,7 @@ public class TestModelVersionMetaService extends 
TestJDBCBackend {
   @TestTemplate
   public void testDeleteModelVersionsInDeletion() throws IOException, 
SQLException {
     for (String param : new String[] {"model", "schema", "catalog", 
"metalake"}) {
-      init();
+      resetDatabase();
       performDeletionTestLogic(param);
     }
   }
diff --git a/docs/how-to-test.md b/docs/how-to-test.md
index 2e56705488..b80a329a94 100644
--- a/docs/how-to-test.md
+++ b/docs/how-to-test.md
@@ -37,26 +37,44 @@ This command runs all the unit tests and skips the 
integration tests.
 
 ### Core test lanes
 
-`core` tests run in four lanes, one CI job each:
+`core` tests run in four lanes, one CI job each. Each database lane selects 
its database through the `gravitino.core.test.backend` system property:
 
-| Lane | Runs | Needs Docker |
-| --- | --- | --- |
-| `./gradlew :core:coreUnitTest` | tests with no database backend | no |
-| `./gradlew :core:coreH2Test` | classes tagged for H2 | no |
-| `./gradlew :core:coreMySQLTest -PskipDockerTests=false` | classes tagged for 
MySQL | yes |
-| `./gradlew :core:corePostgreSQLTest -PskipDockerTests=false` | classes 
tagged for PostgreSQL | yes |
+| Lane | Runs | Database | Needs Docker |
+| --- | --- | --- | --- |
+| `./gradlew :core:coreUnitTest` | tests with no database backend | none | no |
+| `./gradlew :core:coreH2Test` | classes tagged for H2 | H2 | no |
+| `./gradlew :core:coreMySQLTest -PskipDockerTests=false` | classes tagged for 
MySQL | MySQL | yes |
+| `./gradlew :core:corePostgreSQLTest -PskipDockerTests=false` | classes 
tagged for PostgreSQL | PostgreSQL | yes |
 
 A class joins a database lane through `@CoreBackend.H2`, `@CoreBackend.MySQL`, 
`@CoreBackend.PostgreSQL` or `@CoreBackend.All`. Subclasses of 
`TestJDBCBackend` and `AbstractEntityStorageTest` inherit `@CoreBackend.All`. A 
class with none of these runs in `coreUnitTest`. A class tagged 
`gravitino-docker-test` with none of these runs in no lane, and 
`TestCoreLaneMembership` fails until you add one.
 
-When you add or move a core test, check that it runs in the lane you expect:
+To run one class or method, add `--tests` to its lane:
 
 ```shell
 ./gradlew :core:coreH2Test --tests 
'org.apache.gravitino.storage.relational.service.TestYourService'
+./gradlew :core:coreMySQLTest -PskipDockerTests=false --tests 
'org.apache.gravitino.storage.relational.service.TestYourService.testInsert'
 ```
 
 If the class is not in that lane, Gradle fails with "No tests found for given 
includes".
 
-In CI, `build (17)` skips core tests; their results appear under the 
`core-test (17, <lane>)` checks. `./gradlew :core:test` still runs all core 
tests in one task locally (H2 only unless you pass `-PskipDockerTests=false`), 
but it is deprecated and no CI job runs core tests through it.
+#### Database reuse and reset
+
+In a database lane, each test class shares one database per backend. 
`@DatabaseFixture(resetStrategy = ...)` picks how much is rebuilt before each 
test, from cheapest to most isolated:
+
+- `ResetStrategy.DELETE_ROWS` (the default) deletes the rows of every 
non-empty table; `AUTO_INCREMENT` counters keep counting.
+- `ResetStrategy.TRUNCATE_TABLES` truncates every table, so the counters 
restart; it is slower, since `TRUNCATE` recreates each table.
+- `ResetStrategy.FRESH_DATABASE` gives each test a new database and backend. 
Use it when a test changes the schema, closes the backend itself, or leaves 
writers running after it ends.
+
+```java
+@DatabaseFixture(resetStrategy = ResetStrategy.FRESH_DATABASE)
+public class TestSQLScripts extends TestJDBCBackend { ... }
+```
+
+A method-level `@DatabaseFixture` replaces the class-level one. A class's 
backend is closed when the class finishes.
+
+#### `:core:test`
+
+In CI, `build (17)` skips core tests; their results appear under the 
`core-test (17, <lane>)` checks. `./gradlew :core:test` runs all core tests in 
one task. It is deprecated, and no CI job uses it. It selects no database: 
every database test runs against H2, and with `-PskipDockerTests=false` also 
against MySQL and PostgreSQL, building a new backend at every change of 
database. Without Docker, it runs the database tests like `coreH2Test`.
 
 ## Run the Integration Tests
 
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 589b91b10d..0c90a42a6f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -23,7 +23,9 @@ azure-identity = "1.13.1"
 azure-storage-file-datalake = "12.20.0"
 reactor-netty-http = "1.2.1"
 reactor-netty-core = "1.2.1"
+# JUnit Jupiter 5.x ships with JUnit Platform 1.x of the same minor; bump them 
together.
 junit = "5.8.1"
+junit-platform = "1.8.1"
 jackson = "2.15.2"
 snakeyaml = "2.0"
 gson = "2.11.0"
@@ -220,6 +222,7 @@ lombok = { group = "org.projectlombok", name = "lombok", 
version.ref = "lombok"
 junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", 
version.ref = "junit" }
 junit-jupiter-params = { group = "org.junit.jupiter", name = 
"junit-jupiter-params", version.ref = "junit" }
 junit-jupiter-engine = { group = "org.junit.jupiter", name = 
"junit-jupiter-engine", version.ref = "junit" }
+junit-platform-testkit = { group = "org.junit.platform", name = 
"junit-platform-testkit", version.ref = "junit-platform" }
 slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
 slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = 
"slf4j" }
 slf4j-jdk14 = { group = "org.slf4j", name = "slf4j-jdk14", version.ref = 
"slf4j" }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/AbstractIcebergCleanupJobStoreBackendTest.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/AbstractIcebergCleanupJobStoreBackendTest.java
index 005653da64..ae2b81d8e6 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/AbstractIcebergCleanupJobStoreBackendTest.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/AbstractIcebergCleanupJobStoreBackendTest.java
@@ -34,7 +34,8 @@ import org.junit.jupiter.api.TestTemplate;
 /**
  * Shared cleanup-store test logic exercised against the same relational 
backend matrix as core
  * metadata service tests. {@link TestJDBCBackend} initializes H2 by default, 
adds MySQL and
- * PostgreSQL when {@code dockerTest=true}, and truncates all backend tables 
before each invocation.
+ * PostgreSQL when {@code dockerTest=true}, and empties the tables written to 
before each
+ * invocation.
  */
 abstract class AbstractIcebergCleanupJobStoreBackendTest extends 
TestJDBCBackend {
 
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/TestIcebergCleanupManager.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/TestIcebergCleanupManager.java
index a525dd3fa6..0029a89b1e 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/TestIcebergCleanupManager.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/cleanup/TestIcebergCleanupManager.java
@@ -63,7 +63,7 @@ import org.junit.jupiter.api.TestTemplate;
 /**
  * Exercises {@link IcebergCleanupManager} against the same relational backend 
matrix as the cleanup
  * store: {@link TestJDBCBackend} initializes H2 by default, adds MySQL and 
PostgreSQL when {@code
- * dockerTest=true}, and truncates all backend tables before each invocation.
+ * dockerTest=true}, and empties the tables written to before each invocation.
  */
 class TestIcebergCleanupManager extends TestJDBCBackend {
 
diff --git 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java
 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java
index 88e4cae939..7be314f041 100644
--- 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java
+++ 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java
@@ -132,10 +132,6 @@ public class ContainerSuite implements Closeable {
     }
   }
 
-  public static boolean initialized() {
-    return initialized;
-  }
-
   public static ContainerSuite getInstance() {
     if (instance == null) {
       synchronized (ContainerSuite.class) {
@@ -386,10 +382,10 @@ public class ContainerSuite implements Closeable {
   }
 
   public void startMySQLContainer(TestDatabaseName testDatabaseName) {
-    ITUtils.cleanDisk();
     if (mySQLContainer == null) {
       synchronized (ContainerSuite.class) {
         if (mySQLContainer == null) {
+          ITUtils.cleanDisk();
           initIfNecessary();
           // Start MySQL container
           MySQLContainer.Builder mysqlBuilder =
@@ -443,10 +439,10 @@ public class ContainerSuite implements Closeable {
   }
 
   public void startPostgreSQLContainer(TestDatabaseName testDatabaseName, 
PGImageName pgImageName) {
-    ITUtils.cleanDisk();
     if (!pgContainerMap.containsKey(pgImageName)) {
       synchronized (ContainerSuite.class) {
         if (!pgContainerMap.containsKey(pgImageName)) {
+          ITUtils.cleanDisk();
           initIfNecessary();
           // Start PostgreSQL container
           PostgreSQLContainer.Builder pgBuilder =
diff --git 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/CloseContainerExtension.java
 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/CloseContainerExtension.java
index a37a964700..28ebc741d7 100644
--- 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/CloseContainerExtension.java
+++ 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/CloseContainerExtension.java
@@ -32,21 +32,18 @@ import org.slf4j.LoggerFactory;
 public class CloseContainerExtension implements BeforeAllCallback {
   @Override
   public void beforeAll(ExtensionContext extensionContext) {
-    // Ensure that the container suite is initialized before closing it
-    if (ContainerSuite.initialized()) {
-      synchronized (CloseContainerExtension.class) {
-        extensionContext
-            .getRoot()
-            .getStore(ExtensionContext.Namespace.GLOBAL)
-            .getOrComputeIfAbsent(CloseableContainer.class);
-      }
-    }
+    // Store.getOrComputeIfAbsent is atomic, so concurrent classes register 
the closer once.
+    extensionContext
+        .getRoot()
+        .getStore(ExtensionContext.Namespace.GLOBAL)
+        .getOrComputeIfAbsent(CloseableContainer.class);
   }
 
   private static class CloseableContainer implements 
ExtensionContext.Store.CloseableResource {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(CloseableContainer.class);
     private static final ContainerSuite CONTAINER_SUITE = 
ContainerSuite.getInstance();
 
+    // Log instead of failing: a container teardown error must not fail an 
otherwise green run.
     @Override
     public void close() {
       try {

Reply via email to