using System.Text.Json;
using Dpz.Core.Infrastructure;
using Dpz.Core.Service.Network.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
using Xunit.Abstractions;
namespace Dpz.Core.Service.Network.Test;
public class OpenAiServiceTest(ITestOutputHelper testOutputHelper)
: NetworkServiceTestBasic(testOutputHelper)
{
private readonly ITestOutputHelper _testOutputHelper = testOutputHelper;
[Fact]
public void OpenAiServiceCanBeResolved()
{
var service = GetService<IOpenAiService>();
Assert.NotNull(service);
}
[Fact]
public async Task SendMessageAsyncReturnsDeltasAndFinalContent()
{
var service = GetService<IOpenAiService>();
var deltas = new List<string>();
var result = await service.SendMessageAsync(
[new UserChatMessage("请用中文简短回答:2+2等于几?")],
(delta, _) =>
{
deltas.Add(delta);
return Task.CompletedTask;
}
);
Assert.True(result.Success, result.Message);
Assert.NotNull(result.Data);
Assert.NotEmpty(deltas);
Assert.False(string.IsNullOrWhiteSpace(result.Data!.Content));
_testOutputHelper.WriteLine($"OpenAI SDK stream response: {result.Data.Content}");
}
[Fact]
public async Task SendMessageAsyncWithEmptyMessagesReturnsFail()
{
var service = GetService<IOpenAiService>();
var result = await service.SendMessageAsync([]);
Assert.False(result.Success);
Assert.Contains("empty", result.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendMessageAsyncWithJsonSchemaCanDeserializeResponse()
{
var service = GetService<IOpenAiService>();
var result = await service.SendMessageAsync(
[
new UserChatMessage("请只返回 JSON:2+2 的结果。"),
new SystemChatMessage("格式:{answer: 0}"),
],
options: null
);
Assert.True(result.Success, result.Message);
Assert.NotNull(result.Data);
if (
result.Data.TryDeserialize<StructuredAnswer>(
out var value,
out var msg,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
)
)
{
Assert.NotNull(value);
Assert.True(string.IsNullOrWhiteSpace(msg));
}
var response = result.Data!.Deserialize<StructuredAnswer>(
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
);
Assert.NotNull(response);
Assert.Equal(4, response.Answer);
return;
// 支持约束的模型很少
// void Options(OpenAiChatRequestOptions x)
// {
// x.ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat(
// jsonSchemaFormatName: "math_answer",
// jsonSchema: BinaryData.FromBytes(
// """
// {
// "type": "object",
// "properties": {
// "answer": {
// "type": "integer"
// }
// },
// "required": ["answer"],
// "additionalProperties": false
// }
// """u8.ToArray()
// ),
// jsonSchemaIsStrict: true
// );
// }
}
[Fact]
public async Task GetModelsAsyncReturnsAvailableModelsWhenProviderSupportsEndpoint()
{
var service = GetService<IOpenAiService>();
var result = await service.GetModelsAsync();
if (!result.Success)
{
Assert.Contains("404", result.Message);
_testOutputHelper.WriteLine(
"当前兼容服务商未暴露 OpenAI /models 端点,模型列表能力返回 404。"
);
return;
}
Assert.NotNull(result.Data);
Assert.NotEmpty(result.Data!);
foreach (var item in result.Data)
{
_testOutputHelper.WriteLine(
$"Id:{item.Id},OwnedBy:{item.OwnedBy},OwnedBy:{item.OwnedBy}"
);
}
}
[Theory]
[InlineData("OpenAI:Endpoint", "OpenAI:Endpoint configuration is missing")]
[InlineData("OpenAI:ApiKey", "OpenAI:ApiKey configuration is missing")]
[InlineData("OpenAI:Model", "OpenAI:Model configuration is missing")]
public void MissingRequiredConfigurationThrows(string missingKey, string expectedMessage)
{
var configuration = BuildConfiguration((missingKey, string.Empty));
var exception = Assert.Throws<InvalidConfigurationException>(() =>
CreateService(configuration)
);
Assert.Contains(expectedMessage, exception.Message);
}
[Fact]
public void DeserializeWithCodeFenceWrappedContentSucceeds()
{
var result = new OpenAiChatStreamResult
{
Model = "test-model",
Content = "```json\n{ \"answer\": 42 }\n```",
};
var value = result.Deserialize<StructuredAnswer>(
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
);
Assert.NotNull(value);
Assert.Equal(42, value.Answer);
}
[Fact]
public void TryDeserializeWithCodeFenceWrappedContentSucceeds()
{
var result = new OpenAiChatStreamResult
{
Model = "test-model",
Content = "```\n{\"answer\": 7}\n```",
};
var success = result.TryDeserialize<StructuredAnswer>(
out var value,
out var errorMessage,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
);
Assert.True(success);
Assert.NotNull(value);
Assert.Equal(7, value.Answer);
Assert.Null(errorMessage);
}
private static IConfiguration BuildConfiguration(params (string Key, string? Value)[] overrides)
{
var values = new Dictionary<string, string?>
{
["OpenAI:Endpoint"] = "https://api.example.com",
["OpenAI:ApiKey"] = "test-key",
["OpenAI:Model"] = "gpt-5-mini",
["OpenAI:AuthenticationScheme"] = "Bearer",
};
foreach (var (key, value) in overrides)
{
values[key] = value;
}
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
private static OpenAiService CreateService(IConfiguration configuration)
{
var services = new ServiceCollection();
services.AddSingleton(configuration);
services.AddLogging();
var provider = services.BuildServiceProvider();
return new OpenAiService(
configuration,
provider.GetRequiredService<ILogger<OpenAiService>>()
);
}
private sealed class StructuredAnswer
{
public int Answer { get; set; }
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
这是一个基于 xUnit 的测试类文件,主要用于测试 Dpz.Core.Service.Network 中的 OpenAiService 类,验证其对 OpenAI 兼容接口的调用能力、配置校验、响应序列化等功能。
代码整体结构
- 命名空间:
Dpz.Core.Service.Network.Test - 类名:
OpenAiServiceTest - 继承自
NetworkServiceTestBasic(基础测试类,提供GetService<T>()等测试辅助方法) - 构造函数接收
ITestOutputHelper,用于输出测试日志 - 包含一个私有字段
_testOutputHelper保存输出助手
主要引用的库:
Microsoft.Extensions.Configuration/DependencyInjection:配置与依赖注入OpenAI.Chat:使用官方 OpenAI SDK 的消息类型xUnit及Xunit.Abstractions:测试框架与日志输出System.Text.Json:JSON 处理- 自用命名空间:
Dpz.Core.Infrastructure(可能包含InvalidConfigurationException)、Dpz.Core.Service.Network.Models
测试方法说明
1. OpenAiServiceCanBeResolved
- 测试类型解析:从依赖注入容器中获取
IOpenAiService实例。 - 断言不为
null,确保服务已正确注册。
2. SendMessageAsyncReturnsDeltasAndFinalContent
- 调用
IOpenAiService.SendMessageAsync,传入一个用户消息:"请用中文简短回答:2+2等于几?"。 - 在流式回调参数中,将每次收到的增量片段
delta添加到一个列表deltas。 - 断言:
- 调用结果
Success为true(否则输出Message) - 返回结果
Data不为null - 增量列表
deltas不为空(说明有流式输出) - 最终内容
Content非空
- 调用结果
- 输出最终完整内容到测试控制台。
3. SendMessageAsyncWithEmptyMessagesReturnsFail
- 传入空消息列表
[]调用SendMessageAsync。 - 断言结果为失败(
Success为false),并且Message中包含 “empty” 字样(不区分大小写),验证了空消息参数的保护逻辑。
4. SendMessageAsyncWithJsonSchemaCanDeserializeResponse
- 用多个消息(系统消息指定返回格式,用户消息要求只返回 JSON)调用
SendMessageAsync。 - 不传
options,使用默认策略。 - 断言调用成功,
Data不为空。 - 尝试用
StructuredAnswer类型反序列化Data对应的 JSON 内容:- 首先
TryDeserialize,判断是否成功,若成功则断言value不为空、msg为空字符串。 - 然后直接
Deserialize,并断言返回对象的Answer == 4。
- 首先
- 代码中保留了一段注释掉的代码,展示了 OpenAI 官方 SDK 配置
ResponseFormat为 JsonSchema 的用法,但注释说明“支持约束的模型很少”,因此没被启用,而是依赖提示词让模型返回符合格式的 JSON。 - 测试既验证了服务能向模型请求并返回内容,也验证了自定义的 JSON 反序列化扩展方法可以正确处理可能包含 Markdown 代码块包裹的 JSON。
5. GetModelsAsyncReturnsAvailableModelsWhenProviderSupportsEndpoint
- 调用
GetModelsAsync()获取模型列表。 - 如果结果失败(如 404),则断言错误信息中包含 “404”,说明当前兼容服务商不支持
/models端点,测试视为通过(并通过测试输出记录说明)。 - 如果成功,则断言列表不为空,并输出每个模型的
Id和OwnedBy属性(注意输出了两次OwnedBy,可能是笔误)。
6. MissingRequiredConfigurationThrows
- 这是一个
[Theory]测试,使用[InlineData]提供三组缺失配置项及其预期错误消息:- 缺少
OpenAI:Endpoint→ 错误消息包含 “OpenAI:Endpoint configuration is missing” - 缺少
OpenAI:ApiKey→ 类似 - 缺少
OpenAI:Model→ 类似
- 缺少
- 测试通过
BuildConfiguration构造配置(将指定键设为空字符串表示缺失),然后调用CreateService创建服务。 - 断言抛出了
InvalidConfigurationException,且异常消息包含预期内容,验证初始化时对必要配置的校验。
7. DeserializeWithCodeFenceWrappedContentSucceeds
- 构造一个
OpenAiChatStreamResult实例,设置Content为包含 Markdown 代码块包裹的 JSON:"```json\n{ \"answer\": 42 }\n```"。 - 调用其扩展方法
Deserialize<StructuredAnswer>(...),断言能成功反序列化为StructuredAnswer且Answer为 42,验证扩展方法能够自动剥离代码围栏。
8. TryDeserializeWithCodeFenceWrappedContentSucceeds
- 类似上一个测试,但使用的是
TryDeserialize变体,并且Content是未带json标记的代码围栏("```\n{\"answer\": 7}\n```")。 - 断言
TryDeserialize返回success=true,value不为空,errorMessage为null,并验证Answer == 7。
辅助方法
BuildConfiguration(params (string Key, string? Value)[] overrides)- 构造一个包含默认值的字典(Endpoint、ApiKey、Model、AuthenticationScheme)
- 将传入的覆盖项合并进去(可用来模拟“配置缺失”)
- 使用
ConfigurationBuilder.AddInMemoryCollection返回IConfiguration
CreateService(IConfiguration configuration)- 建立一个临时依赖注入容器,注册配置与日志服务
- 从中取出
ILogger<OpenAiService>并构造OpenAiService实例(直接new,便于测试构造函数中的配置校验逻辑)
私有类
StructuredAnswer- 用于 JSON 反序列化的强类型对象,包含整型属性
Answer
- 用于 JSON 反序列化的强类型对象,包含整型属性
整体设计意图
- 测试覆盖了
OpenAiService的主要公共方法:SendMessageAsync、GetModelsAsync。 - 验证了流式回调机制、空消息处理、结构化输出反序列化。
- 验证了配置缺失时构造函数能抛出明确异常。
- 测试了辅助扩展方法对带代码块 JSON 内容的反序列化能力。
- 通过
ITestOutputHelper输出调试信息,便于集成测试时人工查看。 - 因为使用了真实 OpenAI 兼容服务(或本地 Mock),测试需要网络或模拟端点,因此部分测试按实际返回结果对成功/失败进行了兼容处理(如
GetModelsAsync的 404 容错)。
可以说,这是一个较全面的单元/集成测试文件,用于确保 OpenAiService 能正确、稳健地与 OpenAI 兼容 API 交互,并验证周边辅助工具函数的正确性。
AI 正在分析代码…
评论加载中...