You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opennlp.apache.org by rz...@apache.org on 2026/10/02 08:37:11 UTC

(opennlp) branch minor-dedup-test-support created (now ba27c7d9d)

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

rzo1 pushed a change to branch minor-dedup-test-support
in repository https://gitbox.apache.org/repos/asf/opennlp.git


      at ba27c7d9d Minor: Move StringUtil tests to opennlp-api and share test fixtures

This branch includes the following new commits:

     new ba27c7d9d Minor: Move StringUtil tests to opennlp-api and share test fixtures

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(opennlp) 01/01: Minor: Move StringUtil tests to opennlp-api and share test fixtures

Posted by rz...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch minor-dedup-test-support
in repository https://gitbox.apache.org/repos/asf/opennlp.git

commit ba27c7d9d0bbbe645b8e99e597cb1119db23072c
Author: Richard Zowalla <rz...@apache.org>
AuthorDate: Fri Oct 2 10:31:28 2026 +0200

    Minor: Move StringUtil tests to opennlp-api and share test fixtures
    
    StringUtilTest moves from opennlp-tools to opennlp-api/src/test.
    StringUtil.isBlank rejects null with IllegalArgumentException.
    opennlp-api publishes a test-jar with RestoreDefaultLocale,
    ResetWhitespaceMode and PinWhitespaceMode, used by 15 test classes.
---
 opennlp-api/pom.xml                                | 17 ++++-
 .../main/java/opennlp/tools/util/StringUtil.java   |  3 +-
 .../test/java/opennlp/tools/PinWhitespaceMode.java | 46 +++++++++++++
 .../java/opennlp/tools/PinWhitespaceModeTest.java  | 62 +++++++++++++++++
 .../java/opennlp/tools/ResetWhitespaceMode.java    | 46 +++++++++++++
 .../opennlp/tools/ResetWhitespaceModeTest.java     | 58 ++++++++++++++++
 .../java/opennlp/tools/RestoreDefaultLocale.java   | 77 ++++++++++++++++++++++
 .../opennlp/tools/RestoreDefaultLocaleTest.java    | 68 +++++++++++++++++++
 .../opennlp/tools/WhitespaceModeExtension.java     | 74 +++++++++++++++++++++
 .../opennlp/tools/util/CompatibilityModeTest.java  |  4 ++
 .../opennlp/tools/util/StringUtilLocaleTest.java   | 19 ++----
 .../java/opennlp/tools/util/StringUtilTest.java    | 16 ++---
 .../opennlp/tools/util/WhitespaceModeTest.java     |  4 ++
 opennlp-core/opennlp-cli/pom.xml                   |  7 ++
 .../cmdline/FineGrainedReportListenerTest.java     | 10 +--
 opennlp-core/opennlp-formats/pom.xml               |  7 ++
 .../tools/formats/ad/ADSentenceStreamTest.java     |  8 +--
 opennlp-core/opennlp-ml/opennlp-ml-maxent/pom.xml  |  8 +++
 .../tools/ml/maxent/BasicContextGeneratorTest.java |  8 +--
 opennlp-core/opennlp-runtime/pom.xml               |  7 ++
 .../tools/lemmatizer/DictionaryLemmatizerTest.java | 18 ++---
 .../tools/lemmatizer/LemmatizerMELocaleTest.java   | 20 ++----
 .../SentenceDetectorMESpanMappingTest.java         | 18 ++---
 .../tokenize/DefaultTokenContextGeneratorTest.java | 12 +---
 .../tools/tokenize/SimpleTokenizerTest.java        | 12 +---
 .../tools/tokenize/WhitespaceTokenizerTest.java    | 12 +---
 opennlp-eval-tests/pom.xml                         |  6 ++
 .../opennlp/tools/eval/SourceForgeModelEval.java   | 23 ++-----
 opennlp-extensions/opennlp-morfologik/pom.xml      |  7 ++
 .../opennlp/morfologik/MorfologikLocaleTest.java   | 19 ++----
 opennlp-tools/pom.xml                              |  7 ++
 .../ChunkerDetailedFMeasureListenerTest.java       | 10 +--
 .../opennlp/tools/util/TrainingParametersTest.java | 17 +----
 pom.xml                                            |  7 ++
 34 files changed, 562 insertions(+), 175 deletions(-)

diff --git a/opennlp-api/pom.xml b/opennlp-api/pom.xml
index 862534f2d..c57ac3d2c 100644
--- a/opennlp-api/pom.xml
+++ b/opennlp-api/pom.xml
@@ -56,5 +56,20 @@
       <scope>test</scope>
     </dependency>
   </dependencies>
-  
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-jar-plugin</artifactId>
+        <executions>
+          <execution>
+            <goals>
+              <goal>test-jar</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
 </project>
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
index b1c772d51..cb2afc7d8 100644
--- a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
+++ b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
@@ -581,9 +581,10 @@ public class StringUtil {
    *
    * @param theString The {@link CharSequence} to examine. Must not be {@code null}.
    * @return {@code true} if {@code theString} is empty or all whitespace.
-   * @throws NullPointerException Thrown if {@code theString} is {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code theString} is {@code null}.
    */
   public static boolean isBlank(CharSequence theString) {
+    requireNonNullArg(theString, "theString");
     for (int i = 0; i < theString.length(); ) {
       final int codePoint = Character.codePointAt(theString, i);
       if (!isWhitespace(codePoint)) {
diff --git a/opennlp-api/src/test/java/opennlp/tools/PinWhitespaceMode.java b/opennlp-api/src/test/java/opennlp/tools/PinWhitespaceMode.java
new file mode 100644
index 000000000..160b5891b
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/PinWhitespaceMode.java
@@ -0,0 +1,46 @@
+/*
+ * 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 opennlp.tools;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.parallel.ResourceLock;
+
+import opennlp.tools.util.WhitespaceMode;
+
+/**
+ * Pins the {@link WhitespaceMode} for all tests of the annotated class, for example to run
+ * models trained under {@link WhitespaceMode#LEGACY}. The mode is active before the first
+ * {@code @BeforeAll} method, is restored after each test, and is reset to property resolution
+ * after the last test. The class also holds the {@link ResetWhitespaceMode#RESOURCE} lock.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@ExtendWith(WhitespaceModeExtension.class)
+@ResourceLock(ResetWhitespaceMode.RESOURCE)
+public @interface PinWhitespaceMode {
+
+  /**
+   * @return The {@link WhitespaceMode} to activate for the annotated class.
+   */
+  WhitespaceMode value();
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/PinWhitespaceModeTest.java b/opennlp-api/src/test/java/opennlp/tools/PinWhitespaceModeTest.java
new file mode 100644
index 000000000..a3737ad1f
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/PinWhitespaceModeTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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 opennlp.tools;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+import opennlp.tools.util.WhitespaceMode;
+
+/**
+ * Tests for {@link PinWhitespaceMode}. The tests run in a fixed order, so the second one
+ * observes the mode the first one left behind.
+ */
+@PinWhitespaceMode(WhitespaceMode.LEGACY)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class PinWhitespaceModeTest {
+
+  private static WhitespaceMode modeInBeforeAll;
+
+  @BeforeAll
+  static void captureModeInBeforeAll() {
+    modeInBeforeAll = WhitespaceMode.current();
+  }
+
+  @Test
+  @Order(1)
+  void testPinIsActiveBeforeBeforeAllMethods() {
+    Assertions.assertEquals(WhitespaceMode.LEGACY, modeInBeforeAll);
+  }
+
+  @Test
+  @Order(2)
+  void testChangesMode() {
+    WhitespaceMode.setActive(WhitespaceMode.UNICODE);
+    Assertions.assertEquals(WhitespaceMode.UNICODE, WhitespaceMode.current());
+  }
+
+  @Test
+  @Order(3)
+  void testRestoresPinAfterEachTest() {
+    Assertions.assertEquals(WhitespaceMode.LEGACY, WhitespaceMode.current());
+  }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/ResetWhitespaceMode.java b/opennlp-api/src/test/java/opennlp/tools/ResetWhitespaceMode.java
new file mode 100644
index 000000000..f2c4d4a93
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/ResetWhitespaceMode.java
@@ -0,0 +1,46 @@
+/*
+ * 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 opennlp.tools;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.parallel.ResourceLock;
+
+import opennlp.tools.util.WhitespaceMode;
+
+/**
+ * Resets the {@link WhitespaceMode} to property resolution around the tests of the annotated
+ * class, so a test may call {@link WhitespaceMode#setActive(WhitespaceMode)} without leaking
+ * the mode into later tests. The class also holds the {@link #RESOURCE} lock.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@ExtendWith(WhitespaceModeExtension.class)
+@ResourceLock(ResetWhitespaceMode.RESOURCE)
+public @interface ResetWhitespaceMode {
+
+  /**
+   * The {@link ResourceLock} key that serializes the test classes which change the
+   * {@link WhitespaceMode}.
+   */
+  String RESOURCE = "opennlp.tools.util.WhitespaceMode";
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/ResetWhitespaceModeTest.java b/opennlp-api/src/test/java/opennlp/tools/ResetWhitespaceModeTest.java
new file mode 100644
index 000000000..83e883348
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/ResetWhitespaceModeTest.java
@@ -0,0 +1,58 @@
+/*
+ * 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 opennlp.tools;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+import opennlp.tools.util.WhitespaceMode;
+
+/**
+ * Tests for {@link ResetWhitespaceMode}. The tests run in a fixed order, so the second one
+ * observes the mode the first one left behind.
+ */
+@ResetWhitespaceMode
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class ResetWhitespaceModeTest {
+
+  private static WhitespaceMode resolvedMode;
+
+  @BeforeAll
+  static void captureResolvedMode() {
+    resolvedMode = WhitespaceMode.current();
+  }
+
+  @Test
+  @Order(1)
+  void testChangesMode() {
+    WhitespaceMode changed = resolvedMode == WhitespaceMode.LEGACY
+        ? WhitespaceMode.UNICODE : WhitespaceMode.LEGACY;
+    WhitespaceMode.setActive(changed);
+    Assertions.assertEquals(changed, WhitespaceMode.current());
+  }
+
+  @Test
+  @Order(2)
+  void testResetsModeAfterEachTest() {
+    Assertions.assertEquals(resolvedMode, WhitespaceMode.current());
+  }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/RestoreDefaultLocale.java b/opennlp-api/src/test/java/opennlp/tools/RestoreDefaultLocale.java
new file mode 100644
index 000000000..30902fbb3
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/RestoreDefaultLocale.java
@@ -0,0 +1,77 @@
+/*
+ * 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 opennlp.tools;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.Locale;
+
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.jupiter.api.parallel.Resources;
+
+/**
+ * Restores the JVM default {@link Locale} after each test of the annotated class, so a test
+ * may call {@link Locale#setDefault(Locale)} without leaking the change into later tests.
+ * The class also holds the {@link Resources#LOCALE} lock, so it never runs concurrently with
+ * other tests that read or change the default locale.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@ExtendWith(RestoreDefaultLocale.DefaultLocaleExtension.class)
+@ResourceLock(Resources.LOCALE)
+public @interface RestoreDefaultLocale {
+
+  /**
+   * Turkish folds {@code 'I'} to the dotless {@code 'ı'} (U+0131) instead of {@code 'i'},
+   * which makes it the canonical probe for case mapping that depends on the default locale.
+   */
+  Locale TURKISH = Locale.of("tr", "TR");
+
+  /**
+   * Saves the default {@link Locale} before each test and restores it afterward.
+   */
+  final class DefaultLocaleExtension implements BeforeEachCallback, AfterEachCallback {
+
+    private static final ExtensionContext.Namespace NAMESPACE =
+        ExtensionContext.Namespace.create(DefaultLocaleExtension.class);
+
+    private static final String DEFAULT_LOCALE_KEY = "defaultLocale";
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void beforeEach(ExtensionContext context) {
+      context.getStore(NAMESPACE).put(DEFAULT_LOCALE_KEY, Locale.getDefault());
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void afterEach(ExtensionContext context) {
+      Locale.setDefault(context.getStore(NAMESPACE).get(DEFAULT_LOCALE_KEY, Locale.class));
+    }
+  }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/RestoreDefaultLocaleTest.java b/opennlp-api/src/test/java/opennlp/tools/RestoreDefaultLocaleTest.java
new file mode 100644
index 000000000..52fab9524
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/RestoreDefaultLocaleTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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 opennlp.tools;
+
+import java.util.Locale;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+/**
+ * Tests for the {@link RestoreDefaultLocale} extension. The tests run in a fixed order, so
+ * the second one observes the default locale the first one left behind.
+ */
+@RestoreDefaultLocale
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class RestoreDefaultLocaleTest {
+
+  private static Locale initialLocale;
+
+  @BeforeAll
+  static void captureInitialLocale() {
+    initialLocale = Locale.getDefault();
+  }
+
+  /**
+   * Pins the property that makes {@link RestoreDefaultLocale#TURKISH} a useful probe: the
+   * upper case {@code 'I'} lowercases to the dotless {@code 'ı'} (U+0131).
+   */
+  @Test
+  @Order(1)
+  void testTurkishLowercasesToDotlessI() {
+    Assertions.assertEquals("ı", "I".toLowerCase(RestoreDefaultLocale.TURKISH));
+  }
+
+  @Test
+  @Order(2)
+  void testChangesDefaultLocale() {
+    Locale changed = initialLocale.equals(RestoreDefaultLocale.TURKISH)
+        ? Locale.GERMANY : RestoreDefaultLocale.TURKISH;
+    Locale.setDefault(changed);
+    Assertions.assertEquals(changed, Locale.getDefault());
+  }
+
+  @Test
+  @Order(3)
+  void testRestoresDefaultLocaleAfterEachTest() {
+    Assertions.assertEquals(initialLocale, Locale.getDefault());
+  }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/WhitespaceModeExtension.java b/opennlp-api/src/test/java/opennlp/tools/WhitespaceModeExtension.java
new file mode 100644
index 000000000..8a95d9400
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/WhitespaceModeExtension.java
@@ -0,0 +1,74 @@
+/*
+ * 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 opennlp.tools;
+
+import org.junit.jupiter.api.extension.AfterAllCallback;
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.platform.commons.support.AnnotationSupport;
+
+import opennlp.tools.util.WhitespaceMode;
+
+/**
+ * Keeps the process-wide {@link WhitespaceMode} of a test class from leaking into other
+ * tests. Registered through {@link ResetWhitespaceMode} or {@link PinWhitespaceMode}.
+ * <p>
+ * Without a {@link PinWhitespaceMode} on the test class, the mode is reset to property
+ * resolution before the first test and after each test. With one, the pinned mode is
+ * activated before the first {@code @BeforeAll} method of the class and again after each
+ * test. In both cases the mode is reset to property resolution after the last test.
+ */
+final class WhitespaceModeExtension
+    implements BeforeAllCallback, AfterEachCallback, AfterAllCallback {
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public void beforeAll(ExtensionContext context) {
+    activatePinOrReset(context);
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public void afterEach(ExtensionContext context) {
+    activatePinOrReset(context);
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public void afterAll(ExtensionContext context) {
+    WhitespaceMode.reset();
+  }
+
+  /**
+   * Activates the mode of the {@link PinWhitespaceMode} on the test class, or resets the
+   * mode to property resolution if the class has none.
+   *
+   * @param context The {@link ExtensionContext} of the current test or test class.
+   */
+  private void activatePinOrReset(ExtensionContext context) {
+    AnnotationSupport.findAnnotation(context.getRequiredTestClass(), PinWhitespaceMode.class)
+        .ifPresentOrElse(pin -> WhitespaceMode.setActive(pin.value()), WhitespaceMode::reset);
+  }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/util/CompatibilityModeTest.java b/opennlp-api/src/test/java/opennlp/tools/util/CompatibilityModeTest.java
index 5ba98af0c..a0611e252 100644
--- a/opennlp-api/src/test/java/opennlp/tools/util/CompatibilityModeTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/util/CompatibilityModeTest.java
@@ -20,9 +20,12 @@ package opennlp.tools.util;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.CsvSource;
 
+import opennlp.tools.ResetWhitespaceMode;
+
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -30,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 /**
  * Tests for the {@link CompatibilityMode} class.
  */
+@ResourceLock(ResetWhitespaceMode.RESOURCE)
 public class CompatibilityModeTest {
 
   /**
diff --git a/opennlp-api/src/test/java/opennlp/tools/util/StringUtilLocaleTest.java b/opennlp-api/src/test/java/opennlp/tools/util/StringUtilLocaleTest.java
index a58db568c..b53010aca 100644
--- a/opennlp-api/src/test/java/opennlp/tools/util/StringUtilLocaleTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/util/StringUtilLocaleTest.java
@@ -19,29 +19,18 @@ package opennlp.tools.util;
 
 import java.util.Locale;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import opennlp.tools.RestoreDefaultLocale;
+
 /**
  * Tests that the {@link StringUtil} operations feeding the lemmatizer's shortest edit script
  * are independent of the JVM's default {@link Locale}.
  */
+@RestoreDefaultLocale
 public class StringUtilLocaleTest {
 
-  /**
-   * Turkish folds {@code 'I'} to the dotless {@code 'ı'} (U+0131) instead of {@code 'i'},
-   * which makes it the canonical probe for an unqualified {@code String#toLowerCase()}.
-   */
-  private static final Locale TURKISH = Locale.of("tr", "TR");
-
-  private final Locale defaultLocale = Locale.getDefault();
-
-  @AfterEach
-  void restoreDefaultLocale() {
-    Locale.setDefault(defaultLocale);
-  }
-
   /**
    * The shortest edit script becomes an outcome label inside a trained lemmatizer model.
    * If it varied with the default locale, a model trained on a Turkish JVM would carry
@@ -49,7 +38,7 @@ public class StringUtilLocaleTest {
    */
   @Test
   void testGetShortestEditScriptIsIndependentOfDefaultLocale() {
-    Locale.setDefault(TURKISH);
+    Locale.setDefault(RestoreDefaultLocale.TURKISH);
 
     Assertions.assertEquals("D0s", StringUtil.getShortestEditScript("IMPORTS", "import"));
     Assertions.assertEquals("R2ioR1cuI1s", StringUtil.getShortestEditScript("MICE", "mouse"));
diff --git a/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java b/opennlp-api/src/test/java/opennlp/tools/util/StringUtilTest.java
similarity index 99%
rename from opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
rename to opennlp-api/src/test/java/opennlp/tools/util/StringUtilTest.java
index 7acdc8847..4c77f3248 100644
--- a/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/util/StringUtilTest.java
@@ -24,7 +24,6 @@ import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -32,6 +31,7 @@ import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
+import opennlp.tools.ResetWhitespaceMode;
 import opennlp.tools.util.normalizer.UnicodeWhitespace;
 import opennlp.tools.util.normalizer.UnicodeWhitespace.RelatedCharacter;
 import opennlp.tools.util.normalizer.UnicodeWhitespace.WhitespaceCharacter;
@@ -39,6 +39,7 @@ import opennlp.tools.util.normalizer.UnicodeWhitespace.WhitespaceCharacter;
 /**
  * Tests for the {@link StringUtil} class.
  */
+@ResetWhitespaceMode
 public class StringUtilTest {
 
   private static final int[] INFO_SEPARATORS = {0x001C, 0x001D, 0x001E, 0x001F};
@@ -47,15 +48,6 @@ public class StringUtilTest {
   private static final int DESERET_SMALL_BEE = 0x1043A;
   private static final int GRINNING_FACE = 0x1F600; // emoji, two chars
 
-  /**
-   * Restores {@link WhitespaceMode} property resolution after each test, so no mode
-   * state leaks.
-   */
-  @AfterEach
-  void resetWhitespaceMode() {
-    WhitespaceMode.reset();
-  }
-
   private static List<WhitespaceCharacter> whitespace() {
     return UnicodeWhitespace.all();
   }
@@ -882,6 +874,8 @@ public class StringUtilTest {
 
   @Test
   void testIsBlankWithNullString() {
-    Assertions.assertThrows(NullPointerException.class, () -> StringUtil.isBlank(null));
+    IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
+        () -> StringUtil.isBlank(null));
+    Assertions.assertEquals("theString must not be null", e.getMessage());
   }
 }
diff --git a/opennlp-api/src/test/java/opennlp/tools/util/WhitespaceModeTest.java b/opennlp-api/src/test/java/opennlp/tools/util/WhitespaceModeTest.java
index f3bc41b5b..018480e38 100644
--- a/opennlp-api/src/test/java/opennlp/tools/util/WhitespaceModeTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/util/WhitespaceModeTest.java
@@ -19,6 +19,9 @@ package opennlp.tools.util;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
+
+import opennlp.tools.ResetWhitespaceMode;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -27,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 /**
  * Tests for the {@link WhitespaceMode} class.
  */
+@ResourceLock(ResetWhitespaceMode.RESOURCE)
 public class WhitespaceModeTest {
 
   /**
diff --git a/opennlp-core/opennlp-cli/pom.xml b/opennlp-core/opennlp-cli/pom.xml
index b96a77c36..5a12eec66 100644
--- a/opennlp-core/opennlp-cli/pom.xml
+++ b/opennlp-core/opennlp-cli/pom.xml
@@ -63,6 +63,13 @@
     </dependency>
 
     <!-- TEST scoped -->
+    <dependency>
+      <groupId>org.apache.opennlp</groupId>
+      <artifactId>opennlp-api</artifactId>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
+
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter-api</artifactId>
diff --git a/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/FineGrainedReportListenerTest.java b/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/FineGrainedReportListenerTest.java
index 36f6dca6d..4ff256b41 100644
--- a/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/FineGrainedReportListenerTest.java
+++ b/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/FineGrainedReportListenerTest.java
@@ -21,10 +21,10 @@ import java.io.ByteArrayOutputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.Locale;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import opennlp.tools.RestoreDefaultLocale;
 import opennlp.tools.cmdline.postag.POSTaggerFineGrainedReportListener;
 import opennlp.tools.postag.POSSample;
 
@@ -32,15 +32,9 @@ import opennlp.tools.postag.POSSample;
  * Tests that the figures written by a {@link FineGrainedReportListener} do not change with
  * the JVM's default {@link Locale}.
  */
+@RestoreDefaultLocale
 public class FineGrainedReportListenerTest {
 
-  private final Locale defaultLocale = Locale.getDefault();
-
-  @AfterEach
-  void restoreDefaultLocale() {
-    Locale.setDefault(defaultLocale);
-  }
-
   @Test
   void testReportIsIndependentOfDefaultLocale() throws Exception {
     Locale.setDefault(Locale.GERMANY);
diff --git a/opennlp-core/opennlp-formats/pom.xml b/opennlp-core/opennlp-formats/pom.xml
index 1b3a70f0a..376241d7b 100644
--- a/opennlp-core/opennlp-formats/pom.xml
+++ b/opennlp-core/opennlp-formats/pom.xml
@@ -35,6 +35,13 @@
       <groupId>${project.groupId}</groupId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.opennlp</groupId>
+      <artifactId>opennlp-api</artifactId>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
+
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter-api</artifactId>
diff --git a/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/ad/ADSentenceStreamTest.java b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/ad/ADSentenceStreamTest.java
index 09a8c73f3..737199a1a 100644
--- a/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/ad/ADSentenceStreamTest.java
+++ b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/ad/ADSentenceStreamTest.java
@@ -26,7 +26,6 @@ import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Timeout;
@@ -36,6 +35,7 @@ import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
+import opennlp.tools.ResetWhitespaceMode;
 import opennlp.tools.formats.ad.ADSentenceStream.Sentence;
 import opennlp.tools.formats.ad.ADSentenceStream.SentenceParser;
 import opennlp.tools.formats.ad.ADSentenceStream.SentenceParser.Leaf;
@@ -47,6 +47,7 @@ import opennlp.tools.util.ObjectStreamUtils;
 import opennlp.tools.util.PlainTextByLineStream;
 import opennlp.tools.util.WhitespaceMode;
 
+@ResetWhitespaceMode
 public class ADSentenceStreamTest {
 
   private static final String SOURCE = "SOURCE: ref=\"1\" source=\"SELVA 1\"";
@@ -678,11 +679,6 @@ public class ADSentenceStreamTest {
     Assertions.assertEquals(List.of(ola, "mundo", "."), lexemes(sentences.get(0)));
   }
 
-  @AfterEach
-  void resetWhitespaceMode() {
-    WhitespaceMode.reset();
-  }
-
   private static Stream<Arguments> whitespaceLinesInEveryMode() {
     List<Arguments> rows = new ArrayList<>();
     for (WhitespaceMode mode : WhitespaceMode.values()) {
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-maxent/pom.xml b/opennlp-core/opennlp-ml/opennlp-ml-maxent/pom.xml
index 5c2871549..eb3bf8f7b 100644
--- a/opennlp-core/opennlp-ml/opennlp-ml-maxent/pom.xml
+++ b/opennlp-core/opennlp-ml/opennlp-ml-maxent/pom.xml
@@ -40,6 +40,14 @@
             <groupId>org.apache.opennlp</groupId>
             <artifactId>opennlp-ml-commons</artifactId>
         </dependency>
+
+        <!-- TEST scoped -->
+        <dependency>
+            <groupId>org.apache.opennlp</groupId>
+            <artifactId>opennlp-api</artifactId>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
 </project>
\ No newline at end of file
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-maxent/src/test/java/opennlp/tools/ml/maxent/BasicContextGeneratorTest.java b/opennlp-core/opennlp-ml/opennlp-ml-maxent/src/test/java/opennlp/tools/ml/maxent/BasicContextGeneratorTest.java
index ac9338737..d23ea07fe 100644
--- a/opennlp-core/opennlp-ml/opennlp-ml-maxent/src/test/java/opennlp/tools/ml/maxent/BasicContextGeneratorTest.java
+++ b/opennlp-core/opennlp-ml/opennlp-ml-maxent/src/test/java/opennlp/tools/ml/maxent/BasicContextGeneratorTest.java
@@ -19,7 +19,6 @@ package opennlp.tools.ml.maxent;
 
 import java.util.stream.Stream;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -27,8 +26,10 @@ import org.junit.jupiter.params.provider.MethodSource;
 import org.junit.jupiter.params.provider.NullAndEmptySource;
 import org.junit.jupiter.params.provider.ValueSource;
 
+import opennlp.tools.ResetWhitespaceMode;
 import opennlp.tools.util.WhitespaceMode;
 
+@ResetWhitespaceMode
 public class BasicContextGeneratorTest {
 
   private static final String[] NONE = new String[0];
@@ -47,11 +48,6 @@ public class BasicContextGeneratorTest {
   private static final String HIGH_SURROGATE = "\uD83D";
   private static final String LOW_SURROGATE = "\uDE00";
 
-  @AfterEach
-  void resetWhitespaceMode() {
-    WhitespaceMode.reset();
-  }
-
   private static Stream<Arguments> literalSeparators() {
     return Stream.of(
         Arguments.of(",", "a,b,c", new String[] {"a", "b", "c"}),
diff --git a/opennlp-core/opennlp-runtime/pom.xml b/opennlp-core/opennlp-runtime/pom.xml
index 031e28c63..e18fd70bd 100644
--- a/opennlp-core/opennlp-runtime/pom.xml
+++ b/opennlp-core/opennlp-runtime/pom.xml
@@ -53,6 +53,13 @@
     </dependency>
 
     <!-- TEST scoped -->
+    <dependency>
+      <groupId>org.apache.opennlp</groupId>
+      <artifactId>opennlp-api</artifactId>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
+
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter-api</artifactId>
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/DictionaryLemmatizerTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/DictionaryLemmatizerTest.java
index e57dcdc97..95cdc8589 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/DictionaryLemmatizerTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/DictionaryLemmatizerTest.java
@@ -22,22 +22,17 @@ import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.Locale;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
-public class DictionaryLemmatizerTest {
+import opennlp.tools.RestoreDefaultLocale;
 
-  /**
-   * Turkish folds {@code 'I'} to the dotless {@code 'ı'} (U+0131) instead of {@code 'i'}.
-   */
-  private static final Locale TURKISH = Locale.of("tr", "TR");
+@RestoreDefaultLocale
+public class DictionaryLemmatizerTest {
 
   private static DictionaryLemmatizer dictionaryLemmatizer;
 
-  private final Locale defaultLocale = Locale.getDefault();
-
   @BeforeAll
   static void loadDictionary() throws Exception {
     dictionaryLemmatizer = new DictionaryLemmatizer(
@@ -62,11 +57,6 @@ public class DictionaryLemmatizerTest {
     }
   }
 
-  @AfterEach
-  void restoreDefaultLocale() {
-    Locale.setDefault(defaultLocale);
-  }
-
   /**
    * The dictionary is read verbatim but looked up folded, so the fold has to match the
    * casing of the dictionary file rather than whatever locale the JVM happens to run in.
@@ -77,7 +67,7 @@ public class DictionaryLemmatizerTest {
     final DictionaryLemmatizer lemmatizer = new DictionaryLemmatizer(
         new ByteArrayInputStream(entries.getBytes(StandardCharsets.UTF_8)));
 
-    Locale.setDefault(TURKISH);
+    Locale.setDefault(RestoreDefaultLocale.TURKISH);
 
     Assertions.assertArrayEquals(new String[] {"Illinois", "index"},
         lemmatizer.lemmatize(new String[] {"Illinois", "INDICES"}, new String[] {"NNP", "NNS"}));
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerMELocaleTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerMELocaleTest.java
index 70f20ce07..13f7889ed 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerMELocaleTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerMELocaleTest.java
@@ -19,10 +19,11 @@ package opennlp.tools.lemmatizer;
 
 import java.util.Locale;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import opennlp.tools.RestoreDefaultLocale;
+
 /**
  * Tests that the lemma class encoding and decoding of {@link LemmatizerME} do not depend
  * on the JVM's default {@link Locale}.
@@ -30,32 +31,21 @@ import org.junit.jupiter.api.Test;
  * Encoding produces the outcome labels that are persisted in a trained model, decoding
  * consumes them at inference time. Both sides therefore have to agree across JVMs.
  */
+@RestoreDefaultLocale
 public class LemmatizerMELocaleTest {
 
-  /**
-   * Turkish folds {@code 'I'} to the dotless {@code 'ı'} (U+0131) instead of {@code 'i'}.
-   */
-  private static final Locale TURKISH = Locale.of("tr", "TR");
-
   private static final String[] TOKENS = {"MICE", "INDICES"};
   private static final String[] LEMMAS = {"mouse", "index"};
 
   /** The lemma classes an existing, English trained model contains for {@link #TOKENS}. */
   private static final String[] LEMMA_CLASSES = {"R2ioR1cuI1s", "D3iD2cR0sx"};
 
-  private final Locale defaultLocale = Locale.getDefault();
-
-  @AfterEach
-  void restoreDefaultLocale() {
-    Locale.setDefault(defaultLocale);
-  }
-
   /**
    * Training on a Turkish JVM must not write different outcome labels into the model.
    */
   @Test
   void testEncodeLemmasIsIndependentOfDefaultLocale() {
-    Locale.setDefault(TURKISH);
+    Locale.setDefault(RestoreDefaultLocale.TURKISH);
 
     Assertions.assertArrayEquals(LEMMA_CLASSES, LemmatizerME.encodeLemmas(TOKENS, LEMMAS));
   }
@@ -65,7 +55,7 @@ public class LemmatizerMELocaleTest {
    */
   @Test
   void testDecodeLemmasIsIndependentOfDefaultLocale() {
-    Locale.setDefault(TURKISH);
+    Locale.setDefault(RestoreDefaultLocale.TURKISH);
 
     Assertions.assertArrayEquals(LEMMAS, LemmatizerME.decodeLemmas(TOKENS, LEMMA_CLASSES));
   }
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java
index aa3f5fd70..c8a659b79 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java
@@ -21,11 +21,11 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 
-import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
+import opennlp.tools.PinWhitespaceMode;
 import opennlp.tools.dictionary.Dictionary;
 import opennlp.tools.util.Span;
 import opennlp.tools.util.WhitespaceMode;
@@ -35,7 +35,11 @@ import opennlp.tools.util.WhitespaceMode;
  * {@link SentenceDetectorME#sentPosDetect(CharSequence)} (OPENNLP-205). These pin the mapping
  * behavior, including edge cases, so the mapping can be refactored without silently changing
  * results; every expectation was captured from the behavior of the pre-refactoring implementation.
+ * <p>
+ * The features of {@link DefaultSDContextGenerator} depend on the active {@link WhitespaceMode},
+ * so training is pinned to {@link WhitespaceMode#LEGACY} to reproduce the expectations.
  */
+@PinWhitespaceMode(WhitespaceMode.LEGACY)
 public class SentenceDetectorMESpanMappingTest extends AbstractSentenceDetectorTest {
 
   private static final String SECOND = "There are many tests, this is the second.";
@@ -45,9 +49,6 @@ public class SentenceDetectorMESpanMappingTest extends AbstractSentenceDetectorT
 
   @BeforeAll
   static void prepareResources() throws IOException {
-    // DefaultSDContextGenerator's features depend on the active WhitespaceMode, so training
-    // must be pinned to WhitespaceMode.LEGACY to reproduce the expectations pinned below.
-    WhitespaceMode.setActive(WhitespaceMode.LEGACY);
     Dictionary abb = loadAbbDictionary(Locale.ENGLISH);
     tokenEnd = new SentenceDetectorME(
         train(new SentenceDetectorFactory("eng", true, abb, null), Locale.ENGLISH));
@@ -310,13 +311,4 @@ public class SentenceDetectorMESpanMappingTest extends AbstractSentenceDetectorT
     assertSpans(tokenEnd, input, new Span(0, 5), new Span(6, 7));
     assertSpans(noTokenEnd, input, new Span(0, 5), new Span(6, 7));
   }
-
-  /**
-   * Reverts the {@link WhitespaceMode} pin, so later test classes sharing this JVM fork
-   * resolve the mode from the system property again.
-   */
-  @AfterAll
-  static void resetWhitespaceMode() {
-    WhitespaceMode.reset();
-  }
 }
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DefaultTokenContextGeneratorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DefaultTokenContextGeneratorTest.java
index 326487ffe..ebe54de13 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DefaultTokenContextGeneratorTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DefaultTokenContextGeneratorTest.java
@@ -20,28 +20,20 @@ package opennlp.tools.tokenize;
 import java.util.Arrays;
 import java.util.List;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import opennlp.tools.ResetWhitespaceMode;
 import opennlp.tools.util.WhitespaceMode;
 
 /**
  * Tests for the {@link DefaultTokenContextGenerator} class.
  */
+@ResetWhitespaceMode
 public class DefaultTokenContextGeneratorTest {
 
   private final DefaultTokenContextGenerator cg = new DefaultTokenContextGenerator();
 
-  /**
-   * Restores {@link WhitespaceMode} property resolution after each test, so no mode
-   * state leaks.
-   */
-  @AfterEach
-  void resetWhitespaceMode() {
-    WhitespaceMode.reset();
-  }
-
   /**
    * The {@code _ws} feature suffix (whitespace class; underscore is only a name separator,
    * as in {@code f1_ws}) follows the active {@link WhitespaceMode}. These features are part
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/SimpleTokenizerTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/SimpleTokenizerTest.java
index 7ea9768d5..37ddb68c1 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/SimpleTokenizerTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/SimpleTokenizerTest.java
@@ -17,29 +17,21 @@
 
 package opennlp.tools.tokenize;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import opennlp.tools.ResetWhitespaceMode;
 import opennlp.tools.util.WhitespaceMode;
 
 /**
  * Tests for the {@link SimpleTokenizer} class.
  */
+@ResetWhitespaceMode
 public class SimpleTokenizerTest {
 
   // The SimpleTokenizer is thread safe
   private final SimpleTokenizer mTokenizer = SimpleTokenizer.INSTANCE;
 
-  /**
-   * Restores {@link WhitespaceMode} property resolution after each test, so no mode
-   * state leaks.
-   */
-  @AfterEach
-  void resetWhitespaceMode() {
-    WhitespaceMode.reset();
-  }
-
   /**
    * Tests if it can tokenize whitespace separated tokens.
    */
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WhitespaceTokenizerTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WhitespaceTokenizerTest.java
index d22466cf2..ee5a4459f 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WhitespaceTokenizerTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WhitespaceTokenizerTest.java
@@ -17,26 +17,18 @@
 
 package opennlp.tools.tokenize;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import opennlp.tools.ResetWhitespaceMode;
 import opennlp.tools.util.WhitespaceMode;
 
 /**
  * Tests for the {@link WhitespaceTokenizer} class.
  */
+@ResetWhitespaceMode
 public class WhitespaceTokenizerTest {
 
-  /**
-   * Restores {@link WhitespaceMode} property resolution after each test, so no mode
-   * state leaks.
-   */
-  @AfterEach
-  void resetWhitespaceMode() {
-    WhitespaceMode.reset();
-  }
-
   @Test
   void testOneToken() {
     Assertions.assertEquals("one", WhitespaceTokenizer.INSTANCE.tokenize("one")[0]);
diff --git a/opennlp-eval-tests/pom.xml b/opennlp-eval-tests/pom.xml
index 981a2edbf..924fbca81 100644
--- a/opennlp-eval-tests/pom.xml
+++ b/opennlp-eval-tests/pom.xml
@@ -51,6 +51,12 @@
       <type>test-jar</type>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.opennlp</groupId>
+      <artifactId>opennlp-api</artifactId>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>org.apache.opennlp</groupId>
       <artifactId>opennlp-dl</artifactId>
diff --git a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
index ff444cfcc..e843256ef 100644
--- a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
+++ b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
@@ -27,11 +27,11 @@ import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
 
-import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
+import opennlp.tools.PinWhitespaceMode;
 import opennlp.tools.chunker.Chunker;
 import opennlp.tools.chunker.ChunkerME;
 import opennlp.tools.chunker.ChunkerModel;
@@ -86,7 +86,11 @@ import opennlp.tools.util.WhitespaceMode;
  * - models-sf/en-pos-maxent.bin<br>
  * - models-sf/en-pos-perceptron.bin<br>
  * - models-sf/en-parser-chunking.bin.bin<br>
+ * <p>
+ * The SourceForge models were trained under the legacy whitespace definition, so decoding
+ * is pinned to {@link WhitespaceMode#LEGACY} before any corpus access.
  */
+@PinWhitespaceMode(WhitespaceMode.LEGACY)
 public class SourceForgeModelEval extends AbstractEvalTest {
 
   private static class LeipzigTestSample {
@@ -161,25 +165,8 @@ public class SourceForgeModelEval extends AbstractEvalTest {
     }
   }
 
-  /**
-   * Reverts the {@link WhitespaceMode} override pinned in {@link #verifyTrainingData()}, so
-   * later test classes sharing this JVM fork resolve the mode from the system property again
-   * instead of inheriting this class's pin.
-   */
-  @AfterAll
-  static void resetWhitespaceMode() {
-    WhitespaceMode.reset();
-  }
-
-  /**
-   * Pins {@link WhitespaceMode#LEGACY} before any corpus access: the SourceForge models
-   * evaluated here were trained under the legacy whitespace definition, so decoding must use
-   * the same definition. The pin stays first in this method because JUnit does not order
-   * multiple {@code @BeforeAll} methods.
-   */
   @BeforeAll
   static void verifyTrainingData() throws Exception {
-    WhitespaceMode.setActive(WhitespaceMode.LEGACY);
     verifyTrainingData(new LeipzigTestSampleStream(25, SimpleTokenizer.INSTANCE,
             new MarkableFileInputStreamFactory(new File(getOpennlpDataDir(),
                 "leipzig/eng_news_2010_300K-sentences.txt"))),
diff --git a/opennlp-extensions/opennlp-morfologik/pom.xml b/opennlp-extensions/opennlp-morfologik/pom.xml
index e506dc60a..f283f3bfe 100644
--- a/opennlp-extensions/opennlp-morfologik/pom.xml
+++ b/opennlp-extensions/opennlp-morfologik/pom.xml
@@ -69,6 +69,13 @@
       <artifactId>slf4j-api</artifactId>
     </dependency>
 
+    <dependency>
+      <groupId>org.apache.opennlp</groupId>
+      <artifactId>opennlp-api</artifactId>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
+
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter-api</artifactId>
diff --git a/opennlp-extensions/opennlp-morfologik/src/test/java/opennlp/morfologik/MorfologikLocaleTest.java b/opennlp-extensions/opennlp-morfologik/src/test/java/opennlp/morfologik/MorfologikLocaleTest.java
index a5ba15c63..47361e4aa 100644
--- a/opennlp-extensions/opennlp-morfologik/src/test/java/opennlp/morfologik/MorfologikLocaleTest.java
+++ b/opennlp-extensions/opennlp-morfologik/src/test/java/opennlp/morfologik/MorfologikLocaleTest.java
@@ -21,12 +21,12 @@ import java.nio.file.Path;
 import java.util.Locale;
 
 import morfologik.stemming.Dictionary;
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import opennlp.morfologik.lemmatizer.MorfologikLemmatizer;
 import opennlp.morfologik.tagdict.MorfologikTagDictionary;
+import opennlp.tools.RestoreDefaultLocale;
 
 /**
  * Tests that the case insensitive lookups into a prebuilt Morfologik FSA dictionary do not
@@ -35,25 +35,14 @@ import opennlp.morfologik.tagdict.MorfologikTagDictionary;
  * The FSA is built once and shipped; folding the query with the default locale makes the
  * same dictionary answer differently on different JVMs.
  */
+@RestoreDefaultLocale
 public class MorfologikLocaleTest extends AbstractMorfologikTest {
 
-  /**
-   * Turkish folds {@code 'I'} to the dotless {@code 'ı'} (U+0131) instead of {@code 'i'}.
-   */
-  private static final Locale TURKISH = Locale.of("tr", "TR");
-
-  private final Locale defaultLocale = Locale.getDefault();
-
-  @AfterEach
-  void restoreDefaultLocale() {
-    Locale.setDefault(defaultLocale);
-  }
-
   @Test
   public void testLemmatizeIsIndependentOfDefaultLocale() throws Exception {
     final MorfologikLemmatizer lemmatizer = new MorfologikLemmatizer(createLocaleDictionary());
 
-    Locale.setDefault(TURKISH);
+    Locale.setDefault(RestoreDefaultLocale.TURKISH);
 
     final String[] lemmas = lemmatizer.lemmatize(
         new String[] {"Illinois", "INDICES"}, new String[] {"PROP", "NOUN"});
@@ -66,7 +55,7 @@ public class MorfologikLocaleTest extends AbstractMorfologikTest {
     final MorfologikTagDictionary tagDictionary =
         new MorfologikTagDictionary(Dictionary.read(createLocaleDictionary()), false);
 
-    Locale.setDefault(TURKISH);
+    Locale.setDefault(RestoreDefaultLocale.TURKISH);
 
     Assertions.assertArrayEquals(new String[] {"PROP"}, tagDictionary.getTags("Illinois"));
     Assertions.assertArrayEquals(new String[] {"NOUN"}, tagDictionary.getTags("INDICES"));
diff --git a/opennlp-tools/pom.xml b/opennlp-tools/pom.xml
index feeb432b1..a24d897cd 100644
--- a/opennlp-tools/pom.xml
+++ b/opennlp-tools/pom.xml
@@ -87,6 +87,13 @@
     </dependency>
 
     <!-- TEST scoped -->
+    <dependency>
+      <groupId>org.apache.opennlp</groupId>
+      <artifactId>opennlp-api</artifactId>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
+
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter-api</artifactId>
diff --git a/opennlp-tools/src/test/java/opennlp/tools/chunker/ChunkerDetailedFMeasureListenerTest.java b/opennlp-tools/src/test/java/opennlp/tools/chunker/ChunkerDetailedFMeasureListenerTest.java
index de790cd71..408cf02b4 100644
--- a/opennlp-tools/src/test/java/opennlp/tools/chunker/ChunkerDetailedFMeasureListenerTest.java
+++ b/opennlp-tools/src/test/java/opennlp/tools/chunker/ChunkerDetailedFMeasureListenerTest.java
@@ -23,23 +23,17 @@ import java.io.InputStreamReader;
 import java.nio.charset.StandardCharsets;
 import java.util.Locale;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import opennlp.tools.RestoreDefaultLocale;
 import opennlp.tools.cmdline.chunker.ChunkerDetailedFMeasureListener;
 import opennlp.tools.formats.ResourceAsStreamFactory;
 import opennlp.tools.util.PlainTextByLineStream;
 
+@RestoreDefaultLocale
 public class ChunkerDetailedFMeasureListenerTest {
 
-  private final Locale defaultLocale = Locale.getDefault();
-
-  @AfterEach
-  void restoreDefaultLocale() {
-    Locale.setDefault(defaultLocale);
-  }
-
   @Test
   void testEvaluator() throws IOException {
 
diff --git a/opennlp-tools/src/test/java/opennlp/tools/util/TrainingParametersTest.java b/opennlp-tools/src/test/java/opennlp/tools/util/TrainingParametersTest.java
index 99505e9c4..7e2937053 100644
--- a/opennlp-tools/src/test/java/opennlp/tools/util/TrainingParametersTest.java
+++ b/opennlp-tools/src/test/java/opennlp/tools/util/TrainingParametersTest.java
@@ -22,26 +22,15 @@ import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
 
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import opennlp.tools.RestoreDefaultLocale;
 import opennlp.tools.ml.EventTrainer;
 
+@RestoreDefaultLocale
 public class TrainingParametersTest {
 
-  /**
-   * Turkish folds {@code 'I'} to the dotless {@code 'ı'} (U+0131) instead of {@code 'i'}.
-   */
-  private static final Locale TURKISH = Locale.of("tr", "TR");
-
-  private final Locale defaultLocale = Locale.getDefault();
-
-  @AfterEach
-  void restoreDefaultLocale() {
-    Locale.setDefault(defaultLocale);
-  }
-
   @Test
   void testConstructors() throws Exception {
     TrainingParameters tp1 =
@@ -149,7 +138,7 @@ public class TrainingParametersTest {
         { "-model" , "en-token-test.bin" , "-lang" , "en" , "-data" ,
             "en-token.train" , "-encoding" , "UTF-8" , "-cutoff" , "10" , "-iterations" , "50" };
 
-    Locale.setDefault(TURKISH);
+    Locale.setDefault(RestoreDefaultLocale.TURKISH);
     TrainingParameters tr = TrainingParameters.setParams(args);
 
     Assertions.assertEquals(50 ,
diff --git a/pom.xml b/pom.xml
index c16ba104d..5442a8e83 100644
--- a/pom.xml
+++ b/pom.xml
@@ -142,6 +142,13 @@
 				<groupId>${project.groupId}</groupId>
 				<version>${project.version}</version>
 			</dependency>
+
+			<dependency>
+				<artifactId>opennlp-api</artifactId>
+				<groupId>${project.groupId}</groupId>
+				<version>${project.version}</version>
+				<type>test-jar</type>
+			</dependency>
 			
 			<dependency>
 				<artifactId>opennlp-ml-commons</artifactId>