主题
39 · 测试(Testing)
和 Spring 一样,Embabel 让用户应用的测试变得容易,为单元测试与集成测试都提供了完善支持。
构建生成式 AI 应用与构建其它软件并无不同。 测试是交付高质量软件的关键,且必须从一开始就纳入考虑。
单元测试(Unit Testing)
Embabel 的单元测试让你在不涉及真实 LLM 调用的前提下测试单个 Agent 动作。
Embabel 的设计意味着 Agent 通常就是 POJO,可以用 fake 或 mock 对象实例化; 动作就是普通方法,可以直接用测试夹具(fixture)调用。 除了你的领域对象,你还要传入一个 OperationContext 的测试夹具,从而能够拦截并验证 LLM 调用。
框架提供 FakePromptRunner 与 FakeOperationContext,用于 mock LLM 交互, 同时允许你校验提示词、超参数与业务逻辑。当然你也可以直接用 mock 对象 —— Java 侧默认用 Mockito,Kotlin 侧用 mockk。
测试提示词与超参数
下面是 Java Agent Template 与 Kotlin Agent Template 仓库中的单元测试,使用 Embabel 的 fake 对象:
java
class WriteAndReviewAgentTest {
@Test
void testWriteAndReviewAgent() {
var context = FakeOperationContext.create();
var promptRunner = (FakePromptRunner) context.promptRunner();
context.expectResponse(new Story("One upon a time Sir Galahad . . "));
var agent = new WriteAndReviewAgent(200, 400);
agent.craftStory(new UserInput("Tell me a story about a brave knight", Instant.now()), context);
String prompt = promptRunner.getLlmInvocations().getFirst().getPrompt();
assertTrue(prompt.contains("knight"), "Expected prompt to contain 'knight'");
var temp = promptRunner.getLlmInvocations().getFirst().getInteraction().getLlm().getTemperature();
assertEquals(0.9, temp, 0.01,
"Expected temperature to be 0.9: Higher for more creative output");
}
@Test
void testReview() {
var agent = new WriteAndReviewAgent(200, 400);
var userInput = new UserInput("Tell me a story about a brave knight", Instant.now());
var story = new Story("Once upon a time, Sir Galahad...");
var context = FakeOperationContext.create();
context.expectResponse("A thrilling tale of bravery and adventure!");
agent.reviewStory(userInput, story, context);
var promptRunner = (FakePromptRunner) context.promptRunner();
String prompt = promptRunner.getLlmInvocations().getFirst().getPrompt();
assertTrue(prompt.contains("knight"), "Expected review prompt to contain 'knight'");
assertTrue(prompt.contains("review"), "Expected review prompt to contain 'review'");
}
}测试流式 API:withId() 与 creating()
FakePromptRunner 完整支持生产代码里使用的流式 API 模式, 因此可以对"用 withId() 做交互追踪、用 creating() 做带样例的结构化对象创建"的 Agent 做充分单测。
测试 withId() 的交互追踪:
withId() 方法为更好的日志追踪设置交互 ID。测试中你可以验证交互 ID 被正确设置:
java
@Test
void shouldSetInteractionIdCorrectly() {
var context = FakeOperationContext.create();
var expectedIntent = new UserIntent("command", "Change channel names");
context.expectResponse(expectedIntent);
var result = context.ai()
.withId("classify-intent") // 设置交互 ID 以便追踪
.creating(UserIntent.class)
.fromPrompt("Classify the user's intent");
assertEquals(expectedIntent, result);
// 验证交互 ID 被正确设置
var interaction = context.getLlmInvocations().getFirst().getInteraction();
assertEquals("classify-intent", interaction.getId().getValue());
}测试 creating() 搭配 withExample():
creating() API 允许你提供强类型样例来提升 LLM 输出质量。测试中可以验证样例确实被带上:
java
@Test
void shouldIncludeExamplesInPrompt() {
var context = FakeOperationContext.create();
var expectedPlan = new ChannelEditPlan(1, "Lead Vox");
context.expectResponse(expectedPlan);
var result = context.ai()
.withLlm(llmSelectionService.selectOptimalLlm())
.withId("analyze-edit-request")
.creating(ChannelEditPlan.class)
.withExample("Rename channel 1", new ChannelEditPlan(1, "Bass"))
.withExample("Rename channel 2", new ChannelEditPlan(2, "Drums"))
.fromPrompt("Analyze the edit request");
assertEquals(expectedPlan, result);
// 验证样例被作为 prompt contributor 加入
var promptContributors = context.getLlmInvocations().getFirst()
.getInteraction().getPromptContributors();
assertTrue(promptContributors.size() >= 2, "Examples should be added as prompt contributors");
}用 CreationExample 做可复用样例:
为了代码更干净、更可复用,你可以用 CreationExample 数据类定义样例,在测试之间共享,或作为集合传入:
java
@Test
void shouldUseCreationExampleDataClass() {
var context = FakeOperationContext.create();
var expectedPlan = new ChannelEditPlan(1, "Lead Vox");
context.expectResponse(expectedPlan);
// 用 CreationExample 创建一个可复用样例
var example = new CreationExample<>(
"Rename channel example",
new ChannelEditPlan(2, "Rhythm")
);
var result = context.ai()
.withDefaultLlm()
.creating(ChannelEditPlan.class)
.withExample(example) // 直接传入 CreationExample
.fromPrompt("Analyze the edit request");
assertEquals(expectedPlan, result);
}用 withExamples() 一次加多个样例:
样例很多时,用 withExamples() 以列表或可变参数传入。 这在样例从文件或数据库加载时尤其有用:
java
@Test
void shouldAddMultipleExamplesFromList() {
var context = FakeOperationContext.create();
var expectedPlan = new ChannelEditPlan(1, "Lead Vox");
context.expectResponse(expectedPlan);
// 创建样例列表(可以是从配置里加载的)
var examples = List.of(
new CreationExample<>("Rename to Bass", new ChannelEditPlan(1, "Bass")),
new CreationExample<>("Rename to Drums", new ChannelEditPlan(2, "Drums")),
new CreationExample<>("Rename to Keys", new ChannelEditPlan(3, "Keys")),
new CreationExample<>("Rename to Vocals", new ChannelEditPlan(4, "Vocals"))
);
var result = context.ai()
.withDefaultLlm()
.creating(ChannelEditPlan.class)
.withExamples(examples) // 一次传入所有样例
.fromPrompt("Analyze the request");
assertEquals(expectedPlan, result);
// 验证所有样例都被加入
var promptContributors = context.getLlmInvocations().getFirst()
.getInteraction().getPromptContributors();
assertTrue(promptContributors.size() >= 4);
}也可以用可变参数语法内联写样例列表:
java
var result = context.ai()
.withDefaultLlm()
.creating(ChannelEditPlan.class)
.withExamples(
new CreationExample<>("Example 1", new ChannelEditPlan(1, "Bass")),
new CreationExample<>("Example 2", new ChannelEditPlan(2, "Drums")),
new CreationExample<>("Example 3", new ChannelEditPlan(3, "Keys"))
)
.fromPrompt("Analyze the request");完整的流式 API 链式示例:
下面是一个完整例子,展示如何测试一个用上全部流式 API 特性的动作:
java
@Test
void shouldTestCompleteFluentApiChain() {
var context = FakeOperationContext.create();
var expectedOutput = new ComplexOutput("analysis complete", 42);
context.expectResponse(expectedOutput);
// 生产代码里完整流式 API 链的写法
var result = context.ai()
.withLlm(LlmOptions.withModel("gpt-4"))
.withId("complex-analysis")
.withSystemPrompt("You are an expert analyst")
.creating(ComplexOutput.class)
.withExample("Simple case", new ComplexOutput("basic", 1))
.withExample("Complex case", new ComplexOutput("advanced", 100))
.fromPrompt("Analyze the input data");
assertEquals(expectedOutput, result);
// 全面校验
var invocation = context.getLlmInvocations().getFirst();
assertEquals("gpt-4", invocation.getInteraction().getLlm().getModel());
assertEquals("complex-analysis", invocation.getInteraction().getId().getValue());
assertTrue(invocation.getInteraction().getPromptContributors().size() >= 3); // system + 2 个样例
}演示出来的关键测试模式
测试提示词内容:
- 用
context.getLlmInvocations().getFirst().getPrompt()取到实际发给 LLM 的提示词; - 用
assertTrue(prompt.contains(...))校验关键领域数据确实被写进了提示词。
测试工具(Tools):
- 通过
getInteraction().getTools()访问工具,校验withToolObject()/withTool()加进来的工具; - 通过
getInteraction().getToolGroups()访问工具组需求,校验withToolGroup(ToolGroupRequirement)加进来的具名工具组需求。
getTools()返回的是真实的Tool实例(来自withToolObject()与withTool()), 而getToolGroups()返回的是ToolGroupRequirement对象 (如通过withToolGroup()加的具名需求"web_search")。多数测试应当用getTools()。
测试带 Spring 依赖的组件:
- 用标准 mock 框架 mock 诸如
HoroscopeService这类 Spring 注入的服务; - 把 mock 后的依赖传给 Agent 构造器,实现隔离的单元测试。
测试多次 LLM 交互
java
@Test
void shouldHandleMultipleLlmInteractions() {
// Arrange
var input = new UserInput("Write about space exploration");
var story = new Story("The astronaut gazed at Earth...");
ReviewedStory review = new ReviewedStory("Compelling narrative with vivid imagery.");
// 按顺序设置期望响应
context.expectResponse(story);
context.expectResponse(review);
// Act
var writtenStory = agent.writeStory(input, context);
ReviewedStory reviewedStory = agent.reviewStory(writtenStory, context);
// Assert
assertEquals(story, writtenStory);
assertEquals(review, reviewedStory);
// 验证两次 LLM 调用都发生了
List<LlmInvocation> invocations = context.getLlmInvocations();
assertEquals(2, invocations.size());
// 验证第一次调用(写作者)
var writerCall = invocations.get(0);
assertEquals(0.8, writerCall.getInteraction().getLlm().getTemperature(), 0.01);
// 验证第二次调用(评审者)
var reviewerCall = invocations.get(1);
assertEquals(0.2, reviewerCall.getInteraction().getLlm().getTemperature(), 0.01);
}你也可以直接用 Mockito 或 mockk。考虑这个组件 —— 它直接注入 Ai:
java
@Component
public record InjectedComponent(Ai ai) {
public record Joke(String leadup, String punchline) {
}
public String tellJokeAbout(String topic) {
return ai
.withDefaultLlm()
.generateText("Tell me a joke about " + topic);
}
}用 Mockito(Java)或 mockk(Kotlin)写单元测试,校验提示词与超参数:
java
class InjectedComponentTest {
@Test
void testTellJokeAbout() {
var mockAi = Mockito.mock(Ai.class);
var mockPromptRunner = Mockito.mock(PromptRunner.class);
var prompt = "Tell me a joke about frogs";
// 是的,这个笑话是一个 LLM 想出来的。
var terribleJoke = """
Why don't frogs ever pay for drinks?
Because they always have a tadpole in their wallet!
""";
when(mockAi.withDefaultLlm()).thenReturn(mockPromptRunner);
when(mockPromptRunner.generateText(prompt)).thenReturn(terribleJoke);
var injectedComponent = new InjectedComponent(mockAi);
var joke = injectedComponent.tellJokeAbout("frogs");
assertEquals(terribleJoke, joke);
Mockito.verify(mockAi).withDefaultLlm();
Mockito.verify(mockPromptRunner).generateText(prompt);
}
}集成测试(Integration Testing)
集成测试演练完整的 Agent 工作流,使用真实或 mock 的外部服务, 但为了可预测性与速度,仍然避免真实的 LLM 调用。这可以确保:
- Agent 被 Agent 平台正确拾取;
- Agent 内部数据流正确;
- 失败场景被优雅处理;
- Agent 之间、Agent 与外部系统之间正确交互;
- 整体工作流行为符合预期;
- LLM 提示词与超参数配置正确。
Embabel 的集成测试构建在 Spring 优秀的集成测试支持之上, 因此你想用真实数据库也可以。Spring 与 Testcontainers 的集成尤其有用。
使用 EmbabelMockitoIntegrationTest
Embabel 提供 EmbabelMockitoIntegrationTest 作为基类,用便捷的辅助方法简化集成测试:
java
/**
* Use framework superclass to test the complete workflow of writing and reviewing a story.
* This will run under Spring Boot against an AgentPlatform instance
* that has loaded all our agents.
*/
class StoryWriterIntegrationTest extends EmbabelMockitoIntegrationTest {
@Test
void shouldExecuteCompleteWorkflow() {
var input = new UserInput("Write about artificial intelligence");
var story = new Story("AI will transform our world...");
var reviewedStory = new ReviewedStory(story, "Excellent exploration of AI themes.", Personas.REVIEWER);
whenCreateObject(contains("Craft a short story"), Story.class)
.thenReturn(story);
// 第二次调用用的是 generateText
whenGenerateText(contains("You will be given a short story to review"))
.thenReturn(reviewedStory.review());
var invocation = AgentInvocation.create(agentPlatform, ReviewedStory.class);
var reviewedStoryResult = invocation.invoke(input);
assertNotNull(reviewedStoryResult);
assertTrue(reviewedStoryResult.getContent().contains(story.text()),
"Expected story content to be present: " + reviewedStoryResult.getContent());
assertEquals(reviewedStory, reviewedStoryResult,
"Expected review to match: " + reviewedStoryResult);
verifyCreateObjectMatching(prompt -> prompt.contains("Craft a short story"), Story.class,
llm -> llm.getLlm().getTemperature() == 0.9 && llm.getToolGroups().isEmpty());
verifyGenerateTextMatching(prompt -> prompt.contains("You will be given a short story to review"));
verifyNoMoreInteractions();
}
}关键集成测试特性
基类带来的好处:
EmbabelMockitoIntegrationTest自动处理 Spring Boot 环境搭建与 LLM mock;- 预先配置好
agentPlatform与llmOperations; - 内置常用测试模式的辅助方法。
便捷的 stub 方法:
whenCreateObject(prompt, outputClass)—— mock 对象创建调用;whenGenerateText(prompt)—— mock 文本生成调用;- 同时支持精确匹配与
contains()匹配; - 在测试 setup 中调用
supportsStreaming(true)即可支持流式调用。
进阶校验:
verifyCreateObjectMatching()—— 用自定义 matcher 校验提示词;verifyGenerateTextMatching()—— 校验文本生成调用;verifyNoMoreInteractions()—— 确保没有意外的 LLM 调用。
LLM 配置测试:
- 校验 temperature:
llm.getLlm().getTemperature() == 0.9; - 检查工具组:
llm.getToolGroups().isEmpty(); - 校验 persona 与其它 LLM 选项。
带真实 LLM 的集成测试
Embabel 还提供一批用真实 LLM 提供方演练完整工作流的集成测试,以验证端到端功能。 这些测试位于各 LLM 提供方的 autoconfiguration 模块中, 验证与提供方 API 的集成是否正确工作,包括护栏、thinking 响应、结构化输出等特性。
LLM 集成测试示例:
| 测试名 | 说明 | LLM 提供方 |
|---|---|---|
LLMOpenAiGuardRailsIntegrationIT | 测试 OpenAI 与护栏的集成,含基于 OpenAI SDK 与 Spring AI 两种方式的 moderation API 集成,并验证结构化输出时护栏是否被调用 | OpenAI (gpt-4.1-mini) |
LLMAnthropicGuardRailsIntegrationIT | 测试 Anthropic 与护栏的集成,验证结构化对象响应时 AssistantMessageGuardRail 被正确调用 | Anthropic (claude-sonnet-4-5) |
LLMGeminiGuardRailsIntegrationIT | 测试 Gemini 与护栏的集成,验证 Google Gemini 模型在结构化输出下的护栏功能 | Gemini (gemini-2.5-flash) |
测试结构
这些集成测试遵循一致的结构,既测试真实 LLM 交互,又保持对测试环境的控制:
1. 测试位置
集成测试放在 autoconfiguration 模块内,而不是单独的测试模块中。 这样的位置让测试能直接访问它所测试的 autoconfiguration 类, 并验证 Spring Boot 是否正确初始化了所有必需的 bean。
embabel-agent-autoconfigure/
models/
embabel-agent-openai-autoconfigure/
src/test/java/com/embabel/agent/config/models/openai/
LLMOpenAiGuardRailsIntegrationIT.java
embabel-agent-anthropic-autoconfigure/
src/test/java/com/embabel/agent/config/models/anthropic/
LLMAnthropicGuardRailsIntegrationIT.java
embabel-agent-gemini-autoconfigure/
src/test/java/com/embabel/agent/config/models/gemini/
LLMGeminiGuardRailsIntegrationIT.java2. 环境属性
每个测试通过 @SpringBootTest 注解的 properties 属性配置 Spring 环境。 这些属性为测试环境配置 LLM 模型、超时、重试与日志级别。
java
@SpringBootTest(
properties = {
"embabel.models.cheapest=gpt-4.1-mini",
"embabel.models.best=gpt-4.1-mini",
"embabel.models.default-llm=gpt-4.1-mini", // ①
"embabel.agent.platform.llm-operations.prompts.defaultTimeout=240s",
"embabel.agent.platform.llm-operations.data-binding.fixedBackoffMillis=6000",
// Thinking 基础设施日志
"logging.level.com.embabel.agent.spi.support.springai.ChatClientLlmOperations=TRACE",
"logging.level.com.embabel.common.core.thinking=DEBUG"
}
)①
default-llm属性配置调用ai.withDefaultLlm()时默认使用哪个 LLM 模型。
3. 激活的 profile
@ActiveProfiles 注解激活为测试环境启用特定功能的 Spring profile。 "thinking" profile 启用扩展思考能力与 LLM 操作的额外日志。
java
@ActiveProfiles("thinking") // ①① 激活
"thinking"profile,启用增强追踪与 thinking 块提取。
4. 配置属性扫描
@ConfigurationPropertiesScan 注解让 Spring Boot 能够发现并绑定配置属性类。 这对于集成测试从 embabel.agent 与 embabel.example 包加载所有配置属性至关重要。
java
@ConfigurationPropertiesScan(
basePackages = {
"com.embabel.agent",
"com.embabel.example"
}
)5. 组件扫描
@ComponentScan 注解配置组件扫描以发现 Spring bean。 集成测试通常会排除某些可能干扰测试执行的组件(例如异常处理器)。
java
@ComponentScan(
basePackages = {
"com.embabel.agent",
"com.embabel.example"
}
)6. 导入 autoconfiguration
@Import 注解显式导入正在被测试的 autoconfiguration 类。 这确保特定 LLM 提供方的 autoconfiguration 被加载,并创建所有必需的 bean。
java
@Import({AgentOpenAiAutoConfiguration.class, GuardRailConfiguration.class}) // ①① 同时导入 OpenAI autoconfiguration 与测试专用的护栏配置。
7. 自动装配依赖
集成测试自动装配测试所需的 bean。 虽然测试可能为了校验而装配多个组件,但执行 LLM 操作只需要 Ai 接口。
java
@Autowired
private Ai ai; // ①
@Autowired
private Autonomy autonomy; // ②
@Autowired
private List<LlmService<?>> llms; // ②①
Ai接口是主要入口,也是发起 LLM 调用唯一必需的依赖。 ② 为校验与测试目的而装配的额外组件。
示例测试:OpenAI 护栏集成
下面的例子展示 LLMOpenAiGuardRailsIntegrationIT.java 中的一个完整集成测试:
java
@Test
void testGuardRailInvokedForStructuredCreateObject() {
logger.info("Starting guardrail structured createObject test"); // ①
List<String> guardRailCalled = Collections.synchronizedList(new ArrayList<>()); // ②
AssistantMessageGuardRail trackingGuard = new AssistantMessageGuardRail() { // ③
@Override
public @NotNull String getName() {
return "StructuredOutputTrackingGuardRail";
}
@Override
public @NotNull String getDescription() {
return "Tracks guardrail invocation for structured output";
}
@Override
public @NotNull ValidationResult validate(@NotNull String input, @NotNull Blackboard blackboard) {
guardRailCalled.add(input);
logger.info("AssistantMessageGuardRail invoked for structured output: {}", input);
return new ValidationResult(true, Collections.emptyList());
}
@Override
public @NotNull ValidationResult validate(@NotNull ThinkingResponse<?> response, @NotNull Blackboard blackboard) {
return new ValidationResult(true, Collections.emptyList());
}
};
PromptRunner runner = ai.withDefaultLlm() // ④
.withGuardRails(trackingGuard);
String prompt = """
What is the hottest month in Florida and provide its temperature.
The name should be the month name, temperature should be in Fahrenheit.
""";
MonthItem result = runner.createObject(prompt, MonthItem.class); // ⑤
assertNotNull(result, "Result should not be null"); // ⑥
assertNotNull(result.getName(), "Month name should not be null");
assertFalse(guardRailCalled.isEmpty(),
"AssistantMessageGuardRail should have been called for structured output");
logger.info("GuardRail was invoked {} time(s) for structured createObject", guardRailCalled.size());
}① 追踪测试执行的日志语句 ② 线程安全的列表,用于追踪护栏被调用的情况 ③ 自定义护栏实现,记录它何时被调用 ④ 用默认 LLM(在测试属性中配置为
gpt-4.1-mini)配置 prompt runner ⑤ 执行 LLM 调用以创建结构化对象 ⑥ 校验结果与护栏调用
该测试用 withDefaultLlm(),它引用的是 @SpringBootTest 属性中配置的模型 (embabel.models.default-llm=gpt-4.1-mini)。 这种做法让测试更灵活 —— 换个模型不需要改测试代码。
测试注解式 Agent(Annotated Agents)
测试用 @Agent 与 @Action 注解构建的 Agent 时,你需要验证:
- Agent 元数据由注解正确构造;
- 动作按正确行为执行(重试策略、前置条件等);
- 规划器恰当地选择并执行动作;
- 领域特定逻辑符合预期。
注解式 Agent 的测试结构
测试一个注解式 Agent 的关键步骤:
- 创建你的注解式 Agent 类实例;
- 用
AgentMetadataReader从注解中提取 Agent 元数据; - 用假的或真实的
AgentPlatform创建AgentProcess; - 提供输入数据并运行该进程;
- 校验输出与任何副作用。
示例:测试动作重试策略
下面这个简洁例子来自 RetryActionAnnotationJavaTest,展示如何测试带重试行为的 Agent:
java
@Test
void retryMethodFailsOnlyOnceSucceedsSecond() {
// 1. 创建 Agent 实例
var instance = new JavaAgentWithTwoRetryActions(); // ①
// 2. 从注解中提取 Agent 元数据
var reader = new AgentMetadataReader();
var agent = reader.createAgentMetadata(instance); // ②
// 3. 用测试平台创建 agent process
var ap = IntegrationTestUtils.dummyAgentPlatform();
var agentProcess = ap.createAgentProcess(
agent,
ProcessOptions.DEFAULT.withPlannerType(PlannerType.UTILITY), // ③
Map.of("input", new JavaRetryTestInput("test")) // ④
);
// 4. 运行并校验
assertDoesNotThrow(() ->
agentProcess.run().resultOfType(JavaRetryTestOutput.class)); // ⑤
assertEquals(2, instance.retryInvocations.get(),
"Retryable method should have been invoked twice"); // ⑥
}① 用所需依赖实例化注解式 Agent ② 用
AgentMetadataReader解析注解并创建 Agent 元数据 ③ 为测试配置规划器类型(UTILITY、GOAP等) ④ 以 Map 提供输入数据 —— key 与动作参数名一致 ⑤ 运行进程并按期望类型提取结果 ⑥ 用实例字段(例如调用计数器)校验行为
被测的注解式 Agent:
java
@Agent(description = "Java agent with retry", planner = PlannerType.GOAP)
class JavaAgentWithTwoRetryActions {
final AtomicInteger retryInvocations = new AtomicInteger(0); // ①
@AchievesGoal(description = "Process the input")
@Action(actionRetryPolicyExpression = "${retry-twice}") // ②
public JavaRetryTestOutput firstAction(JavaRetryTestInput input) {
retryInvocations.incrementAndGet();
if (retryInvocations.get() == 1)
throw new RuntimeException("Failed!"); // ③
return new JavaRetryTestOutput("Success!"); // ④
}
}① 用实例字段追踪调用次数以校验行为 ② 通过注解配置动作行为(重试策略、前置条件等) ③ 第一次调用故意失败,以测试重试行为 ④ 第二次调用成功
关键测试模式:
- Dummy AgentPlatform:用
IntegrationTestUtils.dummyAgentPlatform()做不启 Spring 上下文的轻量测试; - 实例状态:直接访问实例字段来校验内部行为(调用次数、状态变化);
- 输入 Map:以
Map提供动作参数,key 与参数名一致; - 结果提取:用
agentProcess.run().resultOfType(ExpectedType.class)拿到强类型结果; - 异常测试:用
assertThrows()校验失败场景与重试耗尽。