Supports two formats:
+ *Auto-detects the format by inspecting the first non-whitespace character.
+ */ +public final class JsonDatasetLoader implements DatasetLoader { + + private static final Logger LOG = LoggerFactory.getLogger(JsonDatasetLoader.class); + private static final TypeReference{@code
+ * @AgentTest
+ * @Metric(value = AnswerRelevancyMetric.class, threshold = 0.7)
+ * void testRelevancy(AgentTestCase testCase) {
+ * testCase.setActualOutput(agent.ask(testCase.getInput()));
+ * }
+ * }
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Test
+@Tag("eval")
+@ExtendWith(AgentEvalExtension.class)
+public @interface AgentTest {
+}
diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/DatasetSource.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/DatasetSource.java
new file mode 100644
index 0000000..40a6b5b
--- /dev/null
+++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/DatasetSource.java
@@ -0,0 +1,30 @@
+package com.agenteval.junit5.annotation;
+
+import com.agenteval.junit5.extension.DatasetArgumentsProvider;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Provides {@link com.agenteval.core.model.AgentTestCase} arguments from a JSON dataset file.
+ *
+ * {@code
+ * @ParameterizedTest
+ * @DatasetSource("datasets/golden-set.json")
+ * @Metric(value = AnswerRelevancyMetric.class, threshold = 0.7)
+ * void testWithDataset(AgentTestCase testCase) { ... }
+ * }
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@ArgumentsSource(DatasetArgumentsProvider.class)
+public @interface DatasetSource {
+
+ /**
+ * Classpath resource path to the JSON dataset file.
+ */
+ String value();
+}
diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metric.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metric.java
new file mode 100644
index 0000000..a8b3aff
--- /dev/null
+++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metric.java
@@ -0,0 +1,42 @@
+package com.agenteval.junit5.annotation;
+
+import com.agenteval.core.metric.EvalMetric;
+import com.agenteval.junit5.extension.AgentEvalExtension;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Declares an evaluation metric to apply to an {@code @AgentTest} method.
+ *
+ * Repeatable — multiple metrics can be applied to a single test method.
+ * + *{@code
+ * @AgentTest
+ * @Metric(value = AnswerRelevancyMetric.class, threshold = 0.7)
+ * @Metric(value = FaithfulnessMetric.class, threshold = 0.8)
+ * void testQuality(AgentTestCase testCase) { ... }
+ * }
+ *
+ * A threshold of {@code -1.0} means "use the metric's default threshold".
+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(Metrics.class) +@ExtendWith(AgentEvalExtension.class) +public @interface Metric { + + /** + * The metric class to instantiate. + */ + Class extends EvalMetric> value(); + + /** + * Score threshold (0.0–1.0). Use {@code -1.0} to defer to the metric's default. + */ + double threshold() default -1.0; +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metrics.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metrics.java new file mode 100644 index 0000000..65e11ba --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metrics.java @@ -0,0 +1,19 @@ +package com.agenteval.junit5.annotation; + +import com.agenteval.junit5.extension.AgentEvalExtension; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Container annotation for repeated {@link Metric} annotations. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(AgentEvalExtension.class) +public @interface Metrics { + Metric[] value(); +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentAssertions.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentAssertions.java new file mode 100644 index 0000000..06359c5 --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentAssertions.java @@ -0,0 +1,33 @@ +package com.agenteval.junit5.assertion; + +import com.agenteval.core.model.AgentTestCase; + +/** + * Static entry point for fluent agent test case assertions. + * + *{@code
+ * AgentAssertions.assertThat(testCase)
+ * .meetsMetric(new AnswerRelevancyMetric(judge, 0.7))
+ * .hasToolCalls()
+ * .calledTool("SearchOrders")
+ * .neverCalledTool("DeleteOrder")
+ * .outputContains("refund");
+ * }
+ *
+ * Works independently of {@code @AgentTest} and the extension — usable + * in any JUnit test.
+ */ +public final class AgentAssertions { + + private AgentAssertions() {} + + /** + * Begins a fluent assertion chain for the given test case. + * + * @param testCase the test case to assert against + * @return a fluent assertion object + */ + public static AgentTestCaseAssert assertThat(AgentTestCase testCase) { + return new AgentTestCaseAssert(testCase); + } +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentTestCaseAssert.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentTestCaseAssert.java new file mode 100644 index 0000000..625977c --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentTestCaseAssert.java @@ -0,0 +1,116 @@ +package com.agenteval.junit5.assertion; + +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; + +import java.util.Objects; + +/** + * Fluent assertion chain for {@link AgentTestCase}. + * + *Each assertion method evaluates immediately and throws {@link AssertionError} + * on failure, allowing standard JUnit 5 failure reporting.
+ */ +public final class AgentTestCaseAssert { + + private final AgentTestCase testCase; + + AgentTestCaseAssert(AgentTestCase testCase) { + this.testCase = Objects.requireNonNull(testCase, "testCase must not be null"); + } + + /** + * Evaluates the metric against the test case and asserts it passes. + */ + public AgentTestCaseAssert meetsMetric(EvalMetric metric) { + EvalScore score = metric.evaluate(testCase); + if (!score.passed()) { + throw new AssertionError(String.format( + "Metric '%s' failed: %.3f < %.3f (%s)", + metric.name(), score.value(), score.threshold(), score.reason())); + } + return this; + } + + /** + * Asserts that the test case has at least one tool call. + */ + public AgentTestCaseAssert hasToolCalls() { + if (testCase.getToolCalls().isEmpty()) { + throw new AssertionError("Expected tool calls but found none"); + } + return this; + } + + /** + * Asserts that a tool with the given name was called. + */ + public AgentTestCaseAssert calledTool(String toolName) { + boolean found = testCase.getToolCalls().stream() + .anyMatch(tc -> tc.name().equals(toolName)); + if (!found) { + throw new AssertionError("Expected tool '" + toolName + + "' to be called, but it was not. Actual tools: " + + testCase.getToolCalls().stream() + .map(tc -> tc.name()) + .toList()); + } + return this; + } + + /** + * Asserts that a tool with the given name was never called. + */ + public AgentTestCaseAssert neverCalledTool(String toolName) { + boolean found = testCase.getToolCalls().stream() + .anyMatch(tc -> tc.name().equals(toolName)); + if (found) { + throw new AssertionError("Expected tool '" + toolName + + "' to NOT be called, but it was"); + } + return this; + } + + /** + * Asserts that the actual output contains the given substring. + */ + public AgentTestCaseAssert outputContains(String substring) { + String actual = testCase.getActualOutput(); + if (actual == null || !actual.contains(substring)) { + throw new AssertionError("Expected actual output to contain '" + + substring + "', but was: " + actual); + } + return this; + } + + /** + * Asserts that the actual output does not contain the given substring. + */ + public AgentTestCaseAssert outputDoesNotContain(String substring) { + String actual = testCase.getActualOutput(); + if (actual != null && actual.contains(substring)) { + throw new AssertionError("Expected actual output to NOT contain '" + + substring + "'"); + } + return this; + } + + /** + * Asserts that the actual output is not null and not empty. + */ + public AgentTestCaseAssert hasOutput() { + String actual = testCase.getActualOutput(); + if (actual == null || actual.isEmpty()) { + throw new AssertionError("Expected non-empty actual output, but was: " + actual); + } + return this; + } + + /** + * Returns the underlying test case for further inspection. + */ + public AgentTestCase testCase() { + return testCase; + } +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/AgentEvalExtension.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/AgentEvalExtension.java new file mode 100644 index 0000000..f1bdfa1 --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/AgentEvalExtension.java @@ -0,0 +1,174 @@ +package com.agenteval.junit5.extension; + +import com.agenteval.core.config.AgentEvalConfig; +import com.agenteval.core.judge.JudgeModel; +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import com.agenteval.junit5.annotation.Metric; +import com.agenteval.junit5.annotation.Metrics; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** + * JUnit 5 extension that provides {@link AgentTestCase} parameter resolution, + * captures test case instances via invocation interception, and evaluates + * declared {@link Metric} annotations after each test. + * + *Registered automatically via {@code @AgentTest}, {@code @Metric}, + * or {@code @Metrics} meta-annotations. Can also be registered explicitly:
+ * + *{@code
+ * @RegisterExtension
+ * static AgentEvalExtension ext = AgentEvalExtension.withConfig(config);
+ * }
+ */
+public class AgentEvalExtension
+ implements ParameterResolver, InvocationInterceptor, AfterEachCallback {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AgentEvalExtension.class);
+ private static final Namespace NS = Namespace.create(AgentEvalExtension.class);
+ private static final String TEST_CASE_KEY = "agentTestCase";
+
+ private final AgentEvalConfig config;
+
+ /**
+ * Default constructor used by {@code @ExtendWith} (via meta-annotations).
+ */
+ public AgentEvalExtension() {
+ this(null);
+ }
+
+ private AgentEvalExtension(AgentEvalConfig config) {
+ this.config = config;
+ }
+
+ /**
+ * Creates an extension with explicit configuration (for {@code @RegisterExtension}).
+ */
+ public static AgentEvalExtension withConfig(AgentEvalConfig config) {
+ return new AgentEvalExtension(config);
+ }
+
+ // --- ParameterResolver ---
+
+ @Override
+ public boolean supportsParameter(ParameterContext parameterContext,
+ ExtensionContext extensionContext) {
+ return parameterContext.getParameter().getType() == AgentTestCase.class;
+ }
+
+ @Override
+ public Object resolveParameter(ParameterContext parameterContext,
+ ExtensionContext extensionContext) {
+ var store = extensionContext.getStore(NS);
+ AgentTestCase testCase = store.get(TEST_CASE_KEY, AgentTestCase.class);
+ if (testCase == null) {
+ testCase = AgentTestCase.builder().input("").build();
+ store.put(TEST_CASE_KEY, testCase);
+ }
+ return testCase;
+ }
+
+ // --- InvocationInterceptor ---
+
+ @Override
+ public void interceptTestMethod(InvocationTries constructors in order:
+ *A threshold of {@code -1.0} means "use metric default" — the threshold parameter + * is skipped and constructors without a threshold parameter are preferred.
+ */ +public final class MetricFactory { + + private static final Logger LOG = LoggerFactory.getLogger(MetricFactory.class); + private static final double USE_DEFAULT_THRESHOLD = -1.0; + + private MetricFactory() {} + + /** + * Creates a metric instance from its class and configuration. + * + * @param metricClass the metric class + * @param threshold the threshold (-1.0 for metric default) + * @param judgeModel the judge model (may be null for deterministic metrics) + * @return a new metric instance + * @throws MetricInstantiationException if no suitable constructor is found + */ + public static EvalMetric create(Class extends EvalMetric> metricClass, + double threshold, + JudgeModel judgeModel) { + boolean useDefault = Double.compare(threshold, USE_DEFAULT_THRESHOLD) == 0; + + // Try (JudgeModel, double) if we have both + if (judgeModel != null && !useDefault) { + EvalMetric m = tryConstruct(metricClass, new Class>[]{JudgeModel.class, double.class}, + judgeModel, threshold); + if (m != null) return m; + } + + // Try (double) if threshold is explicit + if (!useDefault) { + EvalMetric m = tryConstruct(metricClass, new Class>[]{double.class}, threshold); + if (m != null) return m; + } + + // Try (JudgeModel) if available + if (judgeModel != null) { + EvalMetric m = tryConstruct(metricClass, new Class>[]{JudgeModel.class}, judgeModel); + if (m != null) return m; + } + + // Try no-arg + EvalMetric m = tryConstruct(metricClass, new Class>[]{}); + if (m != null) return m; + + throw new MetricInstantiationException( + "No suitable constructor found for " + metricClass.getName() + + ". Expected one of: (JudgeModel, double), (double), (JudgeModel), ()"); + } + + @SuppressWarnings("unchecked") + private static EvalMetric tryConstruct(Class extends EvalMetric> clazz, + Class>[] paramTypes, + Object... args) { + try { + Constructor extends EvalMetric> ctor = clazz.getDeclaredConstructor(paramTypes); + ctor.setAccessible(true); + return ctor.newInstance(args); + } catch (NoSuchMethodException e) { + LOG.trace("Constructor {} not found on {}", paramTypes, clazz.getSimpleName()); + return null; + } catch (Exception e) { + throw new MetricInstantiationException( + "Failed to instantiate " + clazz.getName() + ": " + e.getMessage(), e); + } + } + + /** + * Exception thrown when metric instantiation fails. + */ + public static class MetricInstantiationException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public MetricInstantiationException(String message) { + super(message); + } + + public MetricInstantiationException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/agenteval-junit5/src/test/java/com/agenteval/junit5/assertion/AgentAssertionsTest.java b/agenteval-junit5/src/test/java/com/agenteval/junit5/assertion/AgentAssertionsTest.java new file mode 100644 index 0000000..ea9db48 --- /dev/null +++ b/agenteval-junit5/src/test/java/com/agenteval/junit5/assertion/AgentAssertionsTest.java @@ -0,0 +1,185 @@ +package com.agenteval.junit5.assertion; + +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import com.agenteval.core.model.ToolCall; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AgentAssertionsTest { + + private static final EvalMetric PASSING_METRIC = new EvalMetric() { + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.9, 0.7, "good"); + } + + @Override + public String name() { return "PassingMetric"; } + }; + + private static final EvalMetric FAILING_METRIC = new EvalMetric() { + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.3, 0.7, "bad"); + } + + @Override + public String name() { return "FailingMetric"; } + }; + + @Test + void meetsMetricShouldPassForPassingMetric() { + var tc = AgentTestCase.builder().input("q").actualOutput("a").build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).meetsMetric(PASSING_METRIC)) + .doesNotThrowAnyException(); + } + + @Test + void meetsMetricShouldFailForFailingMetric() { + var tc = AgentTestCase.builder().input("q").actualOutput("a").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).meetsMetric(FAILING_METRIC)) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("FailingMetric"); + } + + @Test + void hasToolCallsShouldPassWhenToolCallsExist() { + var tc = AgentTestCase.builder() + .input("q") + .toolCalls(List.of(ToolCall.of("search"))) + .build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).hasToolCalls()) + .doesNotThrowAnyException(); + } + + @Test + void hasToolCallsShouldFailWhenEmpty() { + var tc = AgentTestCase.builder().input("q").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).hasToolCalls()) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("Expected tool calls"); + } + + @Test + void calledToolShouldPassWhenToolWasCalled() { + var tc = AgentTestCase.builder() + .input("q") + .toolCalls(List.of(ToolCall.of("search"), ToolCall.of("lookup"))) + .build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).calledTool("search")) + .doesNotThrowAnyException(); + } + + @Test + void calledToolShouldFailWhenToolNotCalled() { + var tc = AgentTestCase.builder() + .input("q") + .toolCalls(List.of(ToolCall.of("search"))) + .build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).calledTool("delete")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("delete"); + } + + @Test + void neverCalledToolShouldPassWhenToolNotCalled() { + var tc = AgentTestCase.builder() + .input("q") + .toolCalls(List.of(ToolCall.of("search"))) + .build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).neverCalledTool("delete")) + .doesNotThrowAnyException(); + } + + @Test + void neverCalledToolShouldFailWhenToolWasCalled() { + var tc = AgentTestCase.builder() + .input("q") + .toolCalls(List.of(ToolCall.of("delete"))) + .build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).neverCalledTool("delete")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("NOT be called"); + } + + @Test + void outputContainsShouldPassWhenContained() { + var tc = AgentTestCase.builder().input("q").actualOutput("refund policy applies").build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).outputContains("refund")) + .doesNotThrowAnyException(); + } + + @Test + void outputContainsShouldFailWhenNotContained() { + var tc = AgentTestCase.builder().input("q").actualOutput("no match").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).outputContains("refund")) + .isInstanceOf(AssertionError.class); + } + + @Test + void outputContainsShouldFailWhenOutputNull() { + var tc = AgentTestCase.builder().input("q").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).outputContains("anything")) + .isInstanceOf(AssertionError.class); + } + + @Test + void outputDoesNotContainShouldPass() { + var tc = AgentTestCase.builder().input("q").actualOutput("hello").build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).outputDoesNotContain("goodbye")) + .doesNotThrowAnyException(); + } + + @Test + void outputDoesNotContainShouldFail() { + var tc = AgentTestCase.builder().input("q").actualOutput("hello world").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).outputDoesNotContain("hello")) + .isInstanceOf(AssertionError.class); + } + + @Test + void hasOutputShouldPassWhenOutputExists() { + var tc = AgentTestCase.builder().input("q").actualOutput("response").build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).hasOutput()) + .doesNotThrowAnyException(); + } + + @Test + void hasOutputShouldFailWhenOutputNull() { + var tc = AgentTestCase.builder().input("q").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).hasOutput()) + .isInstanceOf(AssertionError.class); + } + + @Test + void shouldSupportFluentChaining() { + var tc = AgentTestCase.builder() + .input("q") + .actualOutput("refund processed") + .toolCalls(List.of(ToolCall.of("SearchOrders"))) + .build(); + + assertThatCode(() -> + AgentAssertions.assertThat(tc) + .hasOutput() + .hasToolCalls() + .calledTool("SearchOrders") + .neverCalledTool("DeleteOrder") + .outputContains("refund") + .meetsMetric(PASSING_METRIC) + ).doesNotThrowAnyException(); + } + + @Test + void shouldReturnTestCase() { + var tc = AgentTestCase.builder().input("q").build(); + var result = AgentAssertions.assertThat(tc).testCase(); + org.assertj.core.api.Assertions.assertThat(result).isSameAs(tc); + } +} diff --git a/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/AgentEvalExtensionTest.java b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/AgentEvalExtensionTest.java new file mode 100644 index 0000000..49b54bc --- /dev/null +++ b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/AgentEvalExtensionTest.java @@ -0,0 +1,69 @@ +package com.agenteval.junit5.extension; + +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import com.agenteval.junit5.annotation.AgentTest; +import com.agenteval.junit5.annotation.Metric; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AgentEvalExtensionTest { + + /** + * A simple deterministic metric for testing the extension lifecycle. + */ + public static final class AlwaysPassMetric implements EvalMetric { + AlwaysPassMetric() {} + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(1.0, 0.5, "always passes"); + } + + @Override + public String name() { return "AlwaysPass"; } + } + + public static final class AlwaysFailMetric implements EvalMetric { + AlwaysFailMetric() {} + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.0, 0.5, "always fails"); + } + + @Override + public String name() { return "AlwaysFail"; } + } + + @Nested + class ParameterResolution { + + @AgentTest + void shouldResolveAgentTestCase(AgentTestCase testCase) { + assertThat(testCase).isNotNull(); + testCase.setActualOutput("resolved via extension"); + assertThat(testCase.getActualOutput()).isEqualTo("resolved via extension"); + } + } + + @Nested + class MetricEvaluation { + + @AgentTest + @Metric(value = AlwaysPassMetric.class) + void shouldPassWithPassingMetric(AgentTestCase testCase) { + testCase.setActualOutput("some output"); + } + } + + @Test + void shouldCreateExtensionWithConfig() { + var ext = AgentEvalExtension.withConfig( + com.agenteval.core.config.AgentEvalConfig.defaults()); + assertThat(ext).isNotNull(); + } +} diff --git a/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/DatasetArgumentsProviderTest.java b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/DatasetArgumentsProviderTest.java new file mode 100644 index 0000000..5d7d0d7 --- /dev/null +++ b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/DatasetArgumentsProviderTest.java @@ -0,0 +1,47 @@ +package com.agenteval.junit5.extension; + +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.datasets.DatasetException; +import com.agenteval.junit5.annotation.DatasetSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import java.lang.annotation.Annotation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class DatasetArgumentsProviderTest { + + @Test + void shouldLoadDatasetFromClasspath() { + var provider = new DatasetArgumentsProvider(); + provider.accept(datasetSource("test-dataset.json")); + + var args = provider.provideArguments(mock(ExtensionContext.class)).toList(); + + assertThat(args).hasSize(2); + AgentTestCase first = (AgentTestCase) args.get(0).get()[0]; + assertThat(first.getInput()).isEqualTo("What is the return policy?"); + } + + @Test + void shouldThrowWhenResourceNotFound() { + var provider = new DatasetArgumentsProvider(); + provider.accept(datasetSource("nonexistent.json")); + + assertThatThrownBy(() -> provider.provideArguments(mock(ExtensionContext.class)).toList()) + .isInstanceOf(DatasetException.class) + .hasMessageContaining("not found"); + } + + private DatasetSource datasetSource(String path) { + return new DatasetSource() { + @Override + public String value() { return path; } + + @Override + public Class extends Annotation> annotationType() { return DatasetSource.class; } + }; + } +} diff --git a/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/MetricFactoryTest.java b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/MetricFactoryTest.java new file mode 100644 index 0000000..d9ec649 --- /dev/null +++ b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/MetricFactoryTest.java @@ -0,0 +1,134 @@ +package com.agenteval.junit5.extension; + +import com.agenteval.core.judge.JudgeModel; +import com.agenteval.core.judge.JudgeResponse; +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MetricFactoryTest { + + // --- Test metric implementations with various constructors --- + + public static final class NoArgMetric implements EvalMetric { + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(1.0, 0.5, "no-arg"); + } + + @Override + public String name() { return "NoArg"; } + } + + public static final class ThresholdMetric implements EvalMetric { + private final double threshold; + + ThresholdMetric(double threshold) { + this.threshold = threshold; + } + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.8, threshold, "threshold"); + } + + @Override + public String name() { return "Threshold"; } + } + + public static final class JudgeMetric implements EvalMetric { + private final JudgeModel judge; + + JudgeMetric(JudgeModel judge) { + this.judge = judge; + } + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.9, 0.5, "judge: " + judge.modelId()); + } + + @Override + public String name() { return "Judge"; } + } + + public static final class FullMetric implements EvalMetric { + private final JudgeModel judge; + private final double threshold; + + FullMetric(JudgeModel judge, double threshold) { + this.judge = judge; + this.threshold = threshold; + } + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.7, threshold, "full: " + judge.modelId()); + } + + @Override + public String name() { return "Full"; } + } + + private static final JudgeModel MOCK_JUDGE = new JudgeModel() { + @Override + public JudgeResponse judge(String prompt) { return null; } + + @Override + public String modelId() { return "test-model"; } + }; + + @Test + void shouldCreateNoArgMetric() { + EvalMetric metric = MetricFactory.create(NoArgMetric.class, -1.0, null); + assertThat(metric).isInstanceOf(NoArgMetric.class); + assertThat(metric.name()).isEqualTo("NoArg"); + } + + @Test + void shouldCreateThresholdMetric() { + EvalMetric metric = MetricFactory.create(ThresholdMetric.class, 0.8, null); + assertThat(metric).isInstanceOf(ThresholdMetric.class); + } + + @Test + void shouldCreateJudgeMetric() { + EvalMetric metric = MetricFactory.create(JudgeMetric.class, -1.0, MOCK_JUDGE); + assertThat(metric).isInstanceOf(JudgeMetric.class); + } + + @Test + void shouldCreateFullMetric() { + EvalMetric metric = MetricFactory.create(FullMetric.class, 0.7, MOCK_JUDGE); + assertThat(metric).isInstanceOf(FullMetric.class); + } + + @Test + void shouldFallbackToNoArgWhenDefaultThreshold() { + EvalMetric metric = MetricFactory.create(NoArgMetric.class, -1.0, MOCK_JUDGE); + assertThat(metric).isInstanceOf(NoArgMetric.class); + } + + @Test + void shouldThrowWhenNoSuitableConstructor() { + assertThatThrownBy(() -> MetricFactory.create(NoConstructorMetric.class, 0.5, null)) + .isInstanceOf(MetricFactory.MetricInstantiationException.class) + .hasMessageContaining("No suitable constructor"); + } + + public static final class NoConstructorMetric implements EvalMetric { + NoConstructorMetric(String unsupported) {} + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.pass("never"); + } + + @Override + public String name() { return "NoConstructor"; } + } +} diff --git a/agenteval-junit5/src/test/resources/test-dataset.json b/agenteval-junit5/src/test/resources/test-dataset.json new file mode 100644 index 0000000..28ad44d --- /dev/null +++ b/agenteval-junit5/src/test/resources/test-dataset.json @@ -0,0 +1,13 @@ +{ + "name": "junit5-test-set", + "testCases": [ + { + "input": "What is the return policy?", + "expectedOutput": "You can return items within 30 days." + }, + { + "input": "How do I contact support?", + "expectedOutput": "Email support@example.com or call 1-800-SUPPORT." + } + ] +} diff --git a/agenteval-reporting/pom.xml b/agenteval-reporting/pom.xml new file mode 100644 index 0000000..e987d48 --- /dev/null +++ b/agenteval-reporting/pom.xml @@ -0,0 +1,27 @@ + +Supports ANSI color output for terminal display.
+ */ +public final class ConsoleReporter implements EvalReporter { + + private static final String ANSI_RESET = "\u001B[0m"; + private static final String ANSI_GREEN = "\u001B[32m"; + private static final String ANSI_RED = "\u001B[31m"; + private static final String ANSI_BOLD = "\u001B[1m"; + + private final PrintStream out; + private final boolean ansiColors; + + public ConsoleReporter() { + this(System.out, true); + } + + public ConsoleReporter(PrintStream out, boolean ansiColors) { + this.out = out; + this.ansiColors = ansiColors; + } + + @Override + public void report(EvalResult result) { + int total = result.caseResults().size(); + int failed = result.failedCases().size(); + int passed = total - failed; + + printBold("=== AgentEval Results ==="); + out.printf("Cases: %d | Passed: %d | Failed: %d | Pass Rate: %.1f%%%n", + total, passed, failed, result.passRate() * 100); + out.printf("Average Score: %.3f | Duration: %dms%n", + result.averageScore(), result.durationMs()); + + MapEach (metric x test case) pair becomes a {@code