From 60965c798e9c485bbaf54e2a5b509a496f8864b3 Mon Sep 17 00:00:00 2001 From: Kevin Cooney Date: Fri, 22 Aug 2025 19:35:09 -0700 Subject: [PATCH] Introduce IsolatedNetworkTablesExtension This is the JUnit5 version of NetworkTableResource. --- .../IsolatedNetworkTablesExtension.java | 80 ++++++++++++ .../testing/junit/jupiter/StoreKey.java | 116 ++++++++++++++++++ .../junit/jupiter/ExtensionAssertions.java | 37 ++++++ .../IsolatedNetworkTablesExtensionTest.java | 41 +++++++ .../junit/jupiter/WPILibExtensionTest.java | 15 +-- 5 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 testing/src/main/java/com/team2813/lib2813/testing/junit/jupiter/IsolatedNetworkTablesExtension.java create mode 100644 testing/src/main/java/com/team2813/lib2813/testing/junit/jupiter/StoreKey.java create mode 100644 testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/ExtensionAssertions.java create mode 100644 testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/IsolatedNetworkTablesExtensionTest.java diff --git a/testing/src/main/java/com/team2813/lib2813/testing/junit/jupiter/IsolatedNetworkTablesExtension.java b/testing/src/main/java/com/team2813/lib2813/testing/junit/jupiter/IsolatedNetworkTablesExtension.java new file mode 100644 index 00000000..2de4a5fa --- /dev/null +++ b/testing/src/main/java/com/team2813/lib2813/testing/junit/jupiter/IsolatedNetworkTablesExtension.java @@ -0,0 +1,80 @@ +package com.team2813.lib2813.testing.junit.jupiter; + +import edu.wpi.first.networktables.NetworkTableInstance; +import edu.wpi.first.wpilibj.Preferences; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.extension.ExtensionContext.Store; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; + +/** + * JUnit Jupiter extension for providing an isolated NetworkTableInstance to tests. + * + *

Example use: + * + *

{@code
+ * @ExtendWith(IsolatedNetworkTablesExtension.class)
+ * public final class IntakeTest {
+ *
+ *   @Test
+ *   public void intakeCoral(NetworkTableInstance ntInstance)  {
+ *     // Do something with ntInstance
+ *   }
+ * }
+ * }
+ */ +public final class IsolatedNetworkTablesExtension + implements Extension, AfterEachCallback, ParameterResolver { + private static final StoreKey NETWORK_TABLE_INSTANCE_KEY = + StoreKey.of(NetworkTableInstance.class); + + @Override + public void afterEach(ExtensionContext context) { + // If this extension created a temporary NetworkTableInstance, close it. + var ntInstance = NETWORK_TABLE_INSTANCE_KEY.get(getStore(context)); + if (ntInstance != null) { + // Clear out the listener queue before destroying our temporary NetworkTableInstance. + // + // This works around a race condition in WPILib where a listener registered by Preferences can + // be called after the NetworkTableInstance was closed (see + // https://github.com/wpilibsuite/allwpilib/issues/8215). + if (!ntInstance.waitForListenerQueue(.1)) { + System.err.println( + "Timed out waiting for the NetworkTableInstance listener queue to empty (waited 100ms);" + + " JVM may crash"); + } + + Preferences.setNetworkTableInstance(NetworkTableInstance.getDefault()); + ntInstance.close(); + } + } + + @Override + public boolean supportsParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return NetworkTableInstance.class.equals(parameterContext.getParameter().getType()); + } + + @Override + public NetworkTableInstance resolveParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + Store store = getStore(extensionContext); + NetworkTableInstance ntInstance = + NETWORK_TABLE_INSTANCE_KEY.getOrComputeIfAbsent(store, NetworkTableInstance::create); + + ntInstance.startLocal(); + Preferences.setNetworkTableInstance(ntInstance); + return ntInstance; + } + + /** Gets the {@link Store} for this extension. */ + private Store getStore(ExtensionContext context) { + return context.getStore(Namespace.create(getClass(), context.getRequiredTestMethod())); + } +} diff --git a/testing/src/main/java/com/team2813/lib2813/testing/junit/jupiter/StoreKey.java b/testing/src/main/java/com/team2813/lib2813/testing/junit/jupiter/StoreKey.java new file mode 100644 index 00000000..745bf42a --- /dev/null +++ b/testing/src/main/java/com/team2813/lib2813/testing/junit/jupiter/StoreKey.java @@ -0,0 +1,116 @@ +package com.team2813.lib2813.testing.junit.jupiter; + +import java.util.function.Supplier; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Store; + +/** + * A type-safe key for use in a {@link Store}. + * + * @param the type for values stored in this store. + */ +final class StoreKey { + private final Class requiredType; + + /** + * Creates a key for storing data of the provided type. + * + *

Values returned from this method should almost always be stored in static final fields. + */ + public static StoreKey of(Class requiredType) { + return new StoreKey<>(requiredType); + } + + private StoreKey(Class requiredType) { + this.requiredType = requiredType; + } + + /** + * Gets the value that is stored under this key. + * + *

If no value is stored in the current {@link ExtensionContext} for this key, ancestors of the + * context will be queried for a value with this key in the {@code Namespace} used to create this + * store. + * + * @param store the store to get data from. + * @see #getOrDefault(Store, V) + */ + public V get(Store store) { + return store.get(this, requiredType); + } + + /** + * Gets the value of the specified required type that is stored under this key, or the supplied + * {@code defaultValue} if no value is found for this key in this store or in an ancestor. + * + *

If no value is stored in the current {@link ExtensionContext} for this, ancestors of the + * context will be queried for a value with this key in the {@code Namespace} used to create this + * store. + * + * @param store the store to get data from. + * @param defaultValue the default value. + * @return the value; potentially {@code null}. + * @see #get(Store) + */ + public V getOrDefault(Store store, V defaultValue) { + return store.getOrDefault(this, requiredType, defaultValue); + } + + /** + * Gets the value of the specified required type that is stored under this key. + * + *

If no value is stored in the current {@link ExtensionContext} for this key, ancestors of the + * context will be queried for a value with this key in the {@code Namespace} used to create this + * store. If no value is found for this key a new value will be computed by the {@code + * valueSupplier}, stored, and returned. + * + *

If {@code requiredType} implements {@link Store.CloseableResource} or {@link AutoCloseable} + * (unless the {@code junit.jupiter.extensions.store.close.autocloseable.enabled} configuration + * parameter is set to {@code false}), then the {@code close()} method will be invoked on the + * stored object when the store is closed. + * + * @param store the store to use to get and store the data. + * @param valueSupplier the function called to create a new value; never {@code null} but may + * return {@code null}. + * @return the value; potentially {@code null}. + * @see Store.CloseableResource + * @see AutoCloseable + */ + public V getOrComputeIfAbsent(Store store, Supplier valueSupplier) { + return store.getOrComputeIfAbsent(this, key -> valueSupplier.get(), requiredType); + } + + /** + * Stores a {@code value} for later retrieval under this key. + * + *

A stored {@code value} is visible in child {@link ExtensionContext ExtensionContexts} for + * the store's {@code Namespace} unless they overwrite it. + * + *

If the {@code value} is an instance of {@link Store.CloseableResource} or {@link + * AutoCloseable} (unless the {@code junit.jupiter.extensions.store.close.autocloseable.enabled} + * configuration parameter is set to {@code false}), then the {@code close()} method will be + * invoked on the stored object when the store is closed. + * + * @param store the store to put data into. + * @param value the value to store; may be {@code null}. + * @see Store.CloseableResource + * @see AutoCloseable + */ + public void put(Store store, V value) { + store.put(this, value); + } + + /** + * Removes the value of the specified required type that was previously stored under this key. + * + *

The value will only be removed in the current {@link ExtensionContext}, not in ancestors. In + * addition, the {@link Store.CloseableResource} and {@link AutoCloseable} API will not be honored + * for values that are manually removed via this method. + * + * @param store the store to remove data from. + * @return the previous value or {@code null} if no value was present for the specified key. + */ + public V remove(Store store) { + return store.remove(this, requiredType); + } +} diff --git a/testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/ExtensionAssertions.java b/testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/ExtensionAssertions.java new file mode 100644 index 00000000..696d4e29 --- /dev/null +++ b/testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/ExtensionAssertions.java @@ -0,0 +1,37 @@ +package com.team2813.lib2813.testing.junit.jupiter; + +import org.junit.platform.testkit.engine.EngineExecutionResults; +import org.junit.platform.testkit.engine.Events; + +/** + * A collection of utility methods that support asserting conditions in tests of JUnit Extensions. + */ +final class ExtensionAssertions { + + /** + * Asserts that the supplied {@code events} do not contain any failures. + * + * @param events Events fired during execution of a test plan on the JUnit Platform. + */ + public static void assertHasNoFailures(Events events) { + events.assertStatistics( + stats -> { + stats.skipped(0); + stats.failed(0); + }); + } + + /** + * Asserts that the supplied {@code results} do not contain any failures. + * + * @param results Results of executing a test plan on the JUnit Platform. + */ + public static void assertHasNoFailures(EngineExecutionResults results) { + assertHasNoFailures(results.containerEvents()); + assertHasNoFailures(results.testEvents()); + } + + private ExtensionAssertions() { + throw new AssertionError("Not instantiable"); + } +} diff --git a/testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/IsolatedNetworkTablesExtensionTest.java b/testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/IsolatedNetworkTablesExtensionTest.java new file mode 100644 index 00000000..505054da --- /dev/null +++ b/testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/IsolatedNetworkTablesExtensionTest.java @@ -0,0 +1,41 @@ +package com.team2813.lib2813.testing.junit.jupiter; + +import static com.google.common.truth.Truth.assertThat; +import static com.team2813.lib2813.testing.junit.jupiter.ExtensionAssertions.assertHasNoFailures; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; + +import edu.wpi.first.networktables.NetworkTableInstance; +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.extension.ExtendWith; +import org.junit.platform.testkit.engine.EngineExecutionResults; +import org.junit.platform.testkit.engine.EngineTestKit; + +/** Tests for {@link IsolatedNetworkTablesExtension}. */ +class IsolatedNetworkTablesExtensionTest { + + @ExtendWith(IsolatedNetworkTablesExtension.class) + @Tag("ignore-outside-testkit") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + public static class SampleTest { + + @Test + public void verifyProvidesNetworkTableParameter(NetworkTableInstance ntInstance) { + assertThat(ntInstance).isNotNull(); + assertThat(ntInstance.getHandle()) + .isNotEqualTo(NetworkTableInstance.getDefault().getHandle()); + } + } // end SampleTest + + @Test + void verifyExtension() { + // Act - Run tests in SampleTest + EngineExecutionResults results = + EngineTestKit.engine("junit-jupiter").selectors(selectClass(SampleTest.class)).execute(); + + // Assert - All tests in SampleTest pass + assertHasNoFailures(results); + } +} diff --git a/testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/WPILibExtensionTest.java b/testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/WPILibExtensionTest.java index 3fd1cfe5..9c7301ca 100644 --- a/testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/WPILibExtensionTest.java +++ b/testing/src/test/java/com/team2813/lib2813/testing/junit/jupiter/WPILibExtensionTest.java @@ -2,6 +2,7 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; +import static com.team2813.lib2813.testing.junit.jupiter.ExtensionAssertions.assertHasNoFailures; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; import edu.wpi.first.hal.HAL; @@ -20,7 +21,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.platform.testkit.engine.EngineExecutionResults; import org.junit.platform.testkit.engine.EngineTestKit; -import org.junit.platform.testkit.engine.Events; /** Tests for {@link WPILibExtension}. */ public class WPILibExtensionTest { @@ -129,19 +129,6 @@ private void withDriverStationTemporarilyEnabled(Runnable runnable) { } } - private void assertHasNoFailures(EngineExecutionResults results) { - assertHasNoFailures(results.containerEvents()); - assertHasNoFailures(results.testEvents()); - } - - private void assertHasNoFailures(Events events) { - events.assertStatistics( - stats -> { - stats.skipped(0); - stats.failed(0); - }); - } - private static class VerifiableCommand extends Command { private static final int EXPECTED_EXECUTION_COUNT = 4; private int initializedCount = 0;