Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ protected void before() {
prevInstance = Preferences.getNetworkTable().getInstance();
tempInstance = NetworkTableInstance.create();
Preferences.setNetworkTableInstance(tempInstance);
tempInstance.waitForListenerQueue(1);
removePreferencesListener();
}

Expand All @@ -52,7 +53,7 @@ protected void after() {
// 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 (!tempInstance.waitForListenerQueue(4)) {
if (!tempInstance.waitForListenerQueue(.4)) {
System.err.println(
"Timed out waiting for the NetworkTableInstance listener queue to empty (waited 400ms);"
+ " will not close temporary NetworkTableInstance");
Expand All @@ -74,7 +75,7 @@ private static void removePreferencesListener() {
NetworkTableListener listener = (NetworkTableListener) listnerField.get(null);
listnerField.set(null, null);
listener.close();
} catch (NoSuchFieldException | IllegalAccessException e) {
} catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) {
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2025 Prospect Robotics SWENext Club
Copyright 2025-2026 Prospect Robotics SWENext Club

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -22,16 +22,15 @@
import com.team2813.lib2813.control.Motor;
import com.team2813.lib2813.testing.FakeMotor;
import com.team2813.lib2813.testing.junit.jupiter.CommandTester;
import com.team2813.lib2813.testing.junit.jupiter.WPILibExtension;
import com.team2813.lib2813.testing.junit.jupiter.InitWPILib;
import edu.wpi.first.wpilibj2.command.Command;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedClass;
import org.junit.jupiter.params.provider.EnumSource;

@ParameterizedClass
@EnumSource(ControlMode.class)
@ExtendWith(WPILibExtension.class)
@InitWPILib
public final class ParameterizedIntakeSubsystemTest {

private static class ConcreteParameterizedIntakeSubsystem extends ParameterizedIntakeSubsystem {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
/**
* Allows tests to run commands.
*
* <p>Tests can get an instance by using {@link WPILibExtension}.
* <p>Tests can get an instance by using {@link InitWPILib}.
*
* @since 2.0.0
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
Copyright 2026 Prospect Robotics SWENext Club

Licensed 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 com.team2813.lib2813.testing.junit.jupiter;

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;

/**
* JUnit Jupiter annotation used to signal tests that depends on WPILib.
*
* <p>Also provides a {@link CommandTester} for tests.
*
* <p>Example use:
*
* <pre>{@code
* @InitWPILib
* public final class FlightSubsystemTest {
*
* @Test
* public void initiallyNotInAir() {
* var flight = new FlightSubsystem();
*
* assertThat(flight.inAir()).isFalse();
* }
*
* @Test
* public void takesFlight(CommandTester commandTester) {
* var flight = new FlightSubsystem();
* Command takeOff = flight.createTakeOffCommandCommand();
*
* commandTester.runUntilComplete(takeOff);
*
* assertThat(flight.inAir()).isTrue();
* }
* }
* }</pre>
*
* @since 2.0.0
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(InitWPILibExtension.class)
public @interface InitWPILib {}
Original file line number Diff line number Diff line change
Expand Up @@ -30,39 +30,8 @@
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;

/**
* JUnit Jupiter extension for testing code that depends on WPILib.
*
* <p>Also provides a {@link CommandTester} for tests.
*
* <p>Example use:
*
* <pre>{@code
* @ExtendWith(WPILibExtension.class)
* public final class FlightSubsystemTest {
*
* @Test
* public void initiallyNotInAir() {
* var flight = new FlightSubsystem();
*
* assertThat(flight.inAir()).isFalse();
* }
*
* @Test
* public void takesFlight(CommandTester commandTester) {
* var flight = new FlightSubsystem();
* Command takeOff = flight.createTakeOffCommandCommand();
*
* commandTester.runUntilComplete(takeOff);
*
* assertThat(flight.inAir()).isTrue();
* }
* }
* }</pre>
*
* @since 2.0.0
*/
public final class WPILibExtension
/** JUnit Jupiter extension for testing code that depends on WPILib. */
final class InitWPILibExtension
implements Extension,
AfterAllCallback,
AfterEachCallback,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
Copyright 2026 Prospect Robotics SWENext Club

Licensed 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 com.team2813.lib2813.testing.junit.jupiter;

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;

/**
* JUnit Jupiter annotation used to signal tests that need an isolated NetworkTableInstance.
*
* <p>Example use:
*
* <pre>{@code
* @ProvideUniqueNetworkTableInstance
* public final class IntakeTest {
*
* @Test
* public void intakeCoral(NetworkTableInstance ntInstance) {
* // Do something with ntInstance
* }
* }
* }</pre>
*
* @since 2.0.0
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(ProvideUniqueNetworkTableInstanceExtension.class)
public @interface ProvideUniqueNetworkTableInstance {

/**
* How long to wait for the listener queue to empty before destroying the temporary network table
* instance.
*/
double waitForListenerQueueSeconds() default 0.6;

/**
* Whether to call {@link
* edu.wpi.first.wpilibj.Preferences#setNetworkTableInstance(edu.wpi.first.networktables.NetworkTableInstance)}
* with the temporary network table instance before starting each test.
*/
boolean replacePreferencesNetworkTable() default false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import edu.wpi.first.wpilibj.Preferences;
import java.lang.reflect.Field;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.Extension;
import org.junit.jupiter.api.extension.ExtensionContext;
Expand All @@ -28,37 +29,39 @@
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.junit.platform.commons.support.AnnotationSupport;

/**
* JUnit Jupiter extension for providing an isolated NetworkTableInstance to tests.
*
* <p>Example use:
*
* <pre>{@code
* @ExtendWith(IsolatedNetworkTablesExtension.class)
* public final class IntakeTest {
*
* @Test
* public void intakeCoral(NetworkTableInstance ntInstance) {
* // Do something with ntInstance
* }
* }
* }</pre>
*
* @since 2.0.0
*/
public final class IsolatedNetworkTablesExtension
implements Extension, BeforeEachCallback, AfterEachCallback, ParameterResolver {
private static final Namespace NAMESPACE = Namespace.create(IsolatedNetworkTablesExtension.class);
/** JUnit Jupiter extension for providing an isolated NetworkTableInstance to tests. */
final class ProvideUniqueNetworkTableInstanceExtension
implements Extension,
BeforeAllCallback,
BeforeEachCallback,
AfterEachCallback,
ParameterResolver {
private static final Namespace NAMESPACE =
Namespace.create(ProvideUniqueNetworkTableInstanceExtension.class);
private static final StoreKey<Data> DATA_KEY = StoreKey.of(Data.class);
private static final StoreKey<ProvideUniqueNetworkTableInstance> ANNOTATION_KEY =
StoreKey.of(ProvideUniqueNetworkTableInstance.class);

@Override
public void beforeAll(ExtensionContext context) {
Store store = context.getStore(NAMESPACE);
ANNOTATION_KEY.put(store, getAnnotation(context));
}

@Override
public void beforeEach(ExtensionContext context) {
Store store = context.getStore(NAMESPACE);
NetworkTableInstance ntInstance =
DATA_KEY.getOrComputeIfAbsent(store, Data::create).testInstance;
Preferences.setNetworkTableInstance(ntInstance);
removePreferencesListener();

ProvideUniqueNetworkTableInstance annotation = ANNOTATION_KEY.get(store);
if (annotation.replacePreferencesNetworkTable()) {
Preferences.setNetworkTableInstance(ntInstance);
ntInstance.waitForListenerQueue(1);
removePreferencesListener();
}
}

@Override
Expand All @@ -67,17 +70,22 @@ public void afterEach(ExtensionContext context) {
Store store = context.getStore(NAMESPACE);
Data data = DATA_KEY.remove(store);
if (data != null) {
Preferences.setNetworkTableInstance(data.prevInstance);
ProvideUniqueNetworkTableInstance annotation = ANNOTATION_KEY.get(store);
if (!data.prevInstance.equals(Preferences.getNetworkTable().getInstance())) {
Preferences.setNetworkTableInstance(data.prevInstance);
}

// 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 (!data.testInstance.waitForListenerQueue(.4)) {
System.err.println(
"Timed out waiting for the NetworkTableInstance listener queue to empty (waited 400ms);"
+ " will not close temporary NetworkTableInstance");
double timeout = annotation.waitForListenerQueueSeconds();
if (!data.testInstance.waitForListenerQueue(timeout)) {
System.err.printf(
"Timed out waiting for the NetworkTableInstance listener queue to empty (waited"
+ " %dms); will not close temporary NetworkTableInstance%n",
Math.round(timeout * 1000));
} else {
data.testInstance.close();
}
Expand Down Expand Up @@ -110,6 +118,17 @@ static Data create() {
}
}

private static ProvideUniqueNetworkTableInstance getAnnotation(ExtensionContext context) {
return AnnotationSupport.findAnnotation(
context.getRequiredTestClass(),
ProvideUniqueNetworkTableInstance.class,
context.getEnclosingTestClasses())
.orElseThrow(
() ->
new IllegalStateException(
"Could not find an enclosed class annotated with @IsolatedNetworkTables"));
}

/**
* Removes the listener installed by {@link
* Preferences#setNetworkTableInstance(NetworkTableInstance)}.
Expand All @@ -123,7 +142,7 @@ private static void removePreferencesListener() {
NetworkTableListener listener = (NetworkTableListener) listnerField.get(null);
listnerField.set(null, null);
listener.close();
} catch (NoSuchFieldException | IllegalAccessException e) {
} catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) {
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2025 Prospect Robotics SWENext Club
Copyright 2025-2026 Prospect Robotics SWENext Club

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -15,8 +15,11 @@
*/
package com.team2813.lib2813.testing.junit.jupiter;

import java.util.List;
import org.junit.platform.testkit.engine.EngineExecutionResults;
import org.junit.platform.testkit.engine.Event;
import org.junit.platform.testkit.engine.Events;
import org.opentest4j.MultipleFailuresError;

/**
* A collection of utility methods that support asserting conditions in tests of JUnit Extensions.
Expand All @@ -28,7 +31,14 @@ final class ExtensionAssertions {
*
* @param events Events fired during execution of a test plan on the JUnit Platform.
*/
public static void assertHasNoFailures(Events events) {
public static void assertHasNoFailures(Events events, String category) {
if (!events.failed().list().isEmpty()) {
List<AssertionError> failures =
events.failed().stream().map(Event::toString).map(AssertionError::new).toList();
throw new MultipleFailuresError(
String.format("Expected no failed events with category '%s'", category), failures);
}

events.assertStatistics(
stats -> {
stats.skipped(0);
Expand All @@ -42,8 +52,8 @@ public static void assertHasNoFailures(Events events) {
* @param results Results of executing a test plan on the JUnit Platform.
*/
public static void assertHasNoFailures(EngineExecutionResults results) {
assertHasNoFailures(results.containerEvents());
assertHasNoFailures(results.testEvents());
assertHasNoFailures(results.containerEvents(), "container");
assertHasNoFailures(results.testEvents(), "test");
}

private ExtensionAssertions() {
Expand Down
Loading