概述
测试是确保 AI 代理应用质量的关键。本文将介绍单元测试和集成测试的方法,并提供实用的测试代码示例。
为什么测试很重要?
想象一下,如果你的 AI 客服在上线后才发现无法正确回答用户问题,或者工具调用失败,会造成多大的损失?
测试就像给你的应用做"体检",在问题发生之前就发现并修复它们。
测试的好处
-
提前发现问题:在开发阶段就发现 bug
-
保证质量:确保功能按预期工作
-
安全重构:修改代码时不怕破坏现有功能
-
文档作用:测试代码展示了如何使用功能
测试的类型
1. 单元测试
测试单个函数或类的功能,不依赖外部服务。
2. 集成测试
测试多个组件协同工作,包括与外部服务的交互。
3. 端到端测试
测试完整的用户场景,从输入到输出。
单元测试
1. 测试框架选择
在 .NET 中,常用的测试框架有:
-
xUnit:现代、简洁(推荐)
-
NUnit:功能丰富
-
MSTest:微软官方
本文使用 xUnit 作为示例。
2. 创建测试项目
# 创建测试项目
dotnet new xunit -n AgentApp.Tests
# 添加项目引用
cd AgentApp.Tests
dotnet add reference ../AgentApp/AgentApp.csproj
# 添加必要的包
dotnet add package Moq
dotnet add package FluentAssertions
3. 测试工具函数
假设我们有一个数据脱敏工具:
// AgentApp/DataMasker.cs
public class DataMasker
{
public string MaskPhoneNumber(string text)
{
return Regex.Replace(text, @"1[3-9]\d{9}", m =>
{
var phone = m.Value;
return phone.Substring(0, 3) + "****" + phone.Substring(7);
});
}
public string MaskEmail(string text)
{
return Regex.Replace(text, @"[\w\.-]+@[\w\.-]+\.\w+", m =>
{
var email = m.Value;
var parts = email.Split('@');
if (parts[0].Length <= 2)
return "***@" + parts[1];
return parts[0].Substring(0, 2) + "***@" + parts[1];
});
}
}
单元测试:
// AgentApp.Tests/DataMaskerTests.cs
using Xunit;
using FluentAssertions;
public class DataMaskerTests
{
private readonly DataMasker _masker;
public DataMaskerTests()
{
_masker = new DataMasker();
}
[Fact]
public void MaskPhoneNumber_ShouldMaskMiddleDigits()
{
// Arrange
var input = "我的手机号是13812345678";
// Act
var result = _masker.MaskPhoneNumber(input);
// Assert
result.Should().Be("我的手机号是138****5678");
}
[Theory]
[InlineData("13812345678", "138****5678")]
[InlineData("15912345678", "159****5678")]
[InlineData("18812345678", "188****5678")]
public void MaskPhoneNumber_ShouldHandleVariousPhoneNumbers(string phone, string expected)
{
// Act
var result = _masker.MaskPhoneNumber(phone);
// Assert
result.Should().Be(expected);
}
[Fact]
public void MaskEmail_ShouldMaskUsername()
{
// Arrange
var input = "联系我:zhangsan@example.com";
// Act
var result = _masker.MaskEmail(input);
// Assert
result.Should().Be("联系我:zh***@example.com");
}
[Fact]
public void MaskEmail_ShortUsername_ShouldMaskCompletely()
{
// Arrange
var input = "邮箱:ab@test.com";
// Act
var result = _masker.MaskEmail(input);
// Assert
result.Should().Be("邮箱:***@test.com");
}
}
运行测试:
dotnet test
4. 测试输入验证
// AgentApp/InputValidator.cs
public class InputValidator
{
private const int MaxMessageLength = 4000;
public bool ValidateMessageLength(string message, out string error)
{
if (string.IsNullOrWhiteSpace(message))
{
error = "消息不能为空";
return false;
}
if (message.Length > MaxMessageLength)
{
error = $"消息长度不能超过 {MaxMessageLength} 字符";
return false;
}
error = null;
return true;
}
}
单元测试:
// AgentApp.Tests/InputValidatorTests.cs
public class InputValidatorTests
{
private readonly InputValidator _validator;
public InputValidatorTests()
{
_validator = new InputValidator();
}
[Fact]
public void ValidateMessageLength_ValidMessage_ShouldReturnTrue()
{
// Arrange
var message = "这是一条有效的消息";
// Act
var result = _validator.ValidateMessageLength(message, out var error);
// Assert
result.Should().BeTrue();
error.Should().BeNull();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ValidateMessageLength_EmptyMessage_ShouldReturnFalse(string message)
{
// Act
var result = _validator.ValidateMessageLength(message, out var error);
// Assert
result.Should().BeFalse();
error.Should().Be("消息不能为空");
}
[Fact]
public void ValidateMessageLength_TooLongMessage_ShouldReturnFalse()
{
// Arrange
var message = new string('a', 4001);
// Act
var result = _validator.ValidateMessageLength(message, out var error);
// Assert
result.Should().BeFalse();
error.Should().Contain("不能超过");
}
}
5. 使用 Mock 测试依赖
当测试的代码依赖外部服务时,使用 Mock 对象模拟这些依赖。
// AgentApp/AgentService.cs
public interface IChatClient
{
Task<string> SendMessageAsync(string message);
}
public class AgentService
{
private readonly IChatClient _chatClient;
private readonly InputValidator _validator;
public AgentService(IChatClient chatClient, InputValidator validator)
{
_chatClient = chatClient;
_validator = validator;
}
public async Task<string> ProcessMessageAsync(string message)
{
// 验证输入
if (!_validator.ValidateMessageLength(message, out var error))
{
throw new ArgumentException(error);
}
// 调用 AI 服务
return await _chatClient.SendMessageAsync(message);
}
}
使用 Mock 的单元测试:
// AgentApp.Tests/AgentServiceTests.cs
using Moq;
public class AgentServiceTests
{
private readonly Mock<IChatClient> _mockChatClient;
private readonly InputValidator _validator;
private readonly AgentService _service;
public AgentServiceTests()
{
_mockChatClient = new Mock<IChatClient>();
_validator = new InputValidator();
_service = new AgentService(_mockChatClient.Object, _validator);
}
[Fact]
public async Task ProcessMessageAsync_ValidMessage_ShouldCallChatClient()
{
// Arrange
var message = "你好";
var expectedResponse = "你好!我是 AI 助手";
_mockChatClient
.Setup(x => x.SendMessageAsync(message))
.ReturnsAsync(expectedResponse);
// Act
var result = await _service.ProcessMessageAsync(message);
// Assert
result.Should().Be(expectedResponse);
_mockChatClient.Verify(x => x.SendMessageAsync(message), Times.Once);
}
[Fact]
public async Task ProcessMessageAsync_EmptyMessage_ShouldThrowException()
{
// Arrange
var message = "";
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
async () => await _service.ProcessMessageAsync(message)
);
// 验证没有调用 ChatClient
_mockChatClient.Verify(x => x.SendMessageAsync(It.IsAny<string>()), Times.Never);
}
}
集成测试
集成测试验证多个组件协同工作,包括与真实服务的交互。
1. 测试与 AI 服务的集成
// AgentApp.Tests/Integration/AgentIntegrationTests.cs
using Microsoft.Extensions.Configuration;
public class AgentIntegrationTests : IDisposable
{
private readonly ChatCompletionAgent _agent;
private readonly IConfiguration _configuration;
public AgentIntegrationTests()
{
// 加载配置
_configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
// 创建真实的代理
var endpoint = _configuration["AZURE_OPENAI_ENDPOINT"];
var apiKey = _configuration["AZURE_OPENAI_API_KEY"];
if (string.IsNullOrEmpty(endpoint) || string.IsNullOrEmpty(apiKey))
{
throw new InvalidOperationException("请配置 AZURE_OPENAI_ENDPOINT 和 AZURE_OPENAI_API_KEY");
}
var chatClient = new AzureOpenAIClient(
new Uri(endpoint),
new ApiKeyCredential(apiKey)
).GetChatClient("gpt-35-turbo");
_agent = new ChatCompletionAgent(
chatClient: chatClient,
name: "TestAgent",
instructions: "你是一个测试助手,回答要简洁"
);
}
[Fact]
public async Task Agent_ShouldRespondToSimpleQuestion()
{
// Arrange
var thread = new AgentThread();
await thread.AddUserMessageAsync("1+1等于几?");
// Act
var response = await _agent.InvokeAsync(thread);
// Assert
response.Should().NotBeNull();
response.Content.Should().Contain("2");
}
[Fact]
public async Task Agent_ShouldMaintainContext()
{
// Arrange
var thread = new AgentThread();
// 第一轮对话
await thread.AddUserMessageAsync("我叫张三");
var response1 = await _agent.InvokeAsync(thread);
// 第二轮对话
await thread.AddUserMessageAsync("我叫什么名字?");
var response2 = await _agent.InvokeAsync(thread);
// Assert
response2.Content.Should().Contain("张三");
}
public void Dispose()
{
// 清理资源
}
}
2. 测试工具调用
// AgentApp/Tools/CalculatorTool.cs
public class CalculatorTool
{
[Description("计算两个数的和")]
public int Add(
[Description("第一个数")] int a,
[Description("第二个数")] int b)
{
return a + b;
}
[Description("计算两个数的乘积")]
public int Multiply(
[Description("第一个数")] int a,
[Description("第二个数")] int b)
{
return a * b;
}
}
集成测试:
// AgentApp.Tests/Integration/ToolIntegrationTests.cs
public class ToolIntegrationTests
{
private readonly ChatCompletionAgent _agent;
public ToolIntegrationTests()
{
var chatClient = CreateChatClient(); // 创建真实客户端
var calculator = new CalculatorTool();
_agent = new ChatCompletionAgent(
chatClient: chatClient,
name: "CalculatorAgent",
instructions: "你是一个计算助手,使用工具进行计算"
)
{
Tools = [AIFunctionFactory.Create(calculator.Add),
AIFunctionFactory.Create(calculator.Multiply)]
};
}
[Fact]
public async Task Agent_ShouldUseAddTool()
{
// Arrange
var thread = new AgentThread();
await thread.AddUserMessageAsync("计算 15 + 27");
// Act
var response = await _agent.InvokeAsync(thread);
// Assert
response.Content.Should().Contain("42");
}
[Fact]
public async Task Agent_ShouldUseMultiplyTool()
{
// Arrange
var thread = new AgentThread();
await thread.AddUserMessageAsync("计算 6 乘以 7");
// Act
var response = await _agent.InvokeAsync(thread);
// Assert
response.Content.Should().Contain("42");
}
private IChatClient CreateChatClient()
{
// 实际实现
return null;
}
}
3. 测试多轮对话
public class ConversationIntegrationTests
{
[Fact]
public async Task MultiTurnConversation_ShouldMaintainContext()
{
// Arrange
var agent = CreateAgent();
var thread = new AgentThread();
// 第一轮:设置上下文
await thread.AddUserMessageAsync("我想买一台笔记本电脑");
var response1 = await agent.InvokeAsync(thread);
response1.Content.Should().NotBeNullOrEmpty();
// 第二轮:基于上下文提问
await thread.AddUserMessageAsync("预算在 5000 元左右");
var response2 = await agent.InvokeAsync(thread);
response2.Content.Should().Contain("笔记本");
// 第三轮:继续对话
await thread.AddUserMessageAsync("有什么推荐吗?");
var response3 = await agent.InvokeAsync(thread);
response3.Content.Should().NotBeNullOrEmpty();
}
private ChatCompletionAgent CreateAgent()
{
// 实际实现
return null;
}
}
测试最佳实践
1. 测试命名规范
使用清晰的命名让测试易于理解:
// 格式:MethodName_Scenario_ExpectedBehavior
[Fact]
public void MaskPhoneNumber_ValidPhone_ShouldMaskMiddleDigits() { }
[Fact]
public void ValidateInput_EmptyMessage_ShouldReturnFalse() { }
[Fact]
public void ProcessMessage_WithRetry_ShouldSucceedAfterFailure() { }
2. AAA 模式
每个测试分为三个部分:
[Fact]
public void Example_Test()
{
// Arrange(准备):设置测试数据和依赖
var input = "test input";
var expected = "expected output";
// Act(执行):调用被测试的方法
var result = MethodUnderTest(input);
// Assert(断言):验证结果
result.Should().Be(expected);
}
3. 一个测试只验证一件事
// ❌ 不好:一个测试验证多件事
[Fact]
public void ProcessMessage_ShouldValidateAndCallApiAndReturnResult()
{
// 测试太多东西
}
// ✅ 好:分成多个测试
[Fact]
public void ProcessMessage_InvalidInput_ShouldThrowException() { }
[Fact]
public void ProcessMessage_ValidInput_ShouldCallApi() { }
[Fact]
public void ProcessMessage_ApiSuccess_ShouldReturnResult() { }
4. 使用测试数据生成器
public class TestDataBuilder
{
public static string CreateValidMessage(int length = 100)
{
return new string('a', length);
}
public static string CreateInvalidMessage()
{
return new string('a', 5000); // 超过限制
}
public static AgentThread CreateThreadWithHistory(int messageCount)
{
var thread = new AgentThread();
for (int i = 0; i < messageCount; i++)
{
thread.AddUserMessageAsync($"消息 {i}").Wait();
}
return thread;
}
}
// 使用
[Fact]
public void Test_WithGeneratedData()
{
var message = TestDataBuilder.CreateValidMessage(50);
// 使用 message 进行测试
}
5. 测试异步代码
[Fact]
public async Task AsyncMethod_ShouldReturnExpectedResult()
{
// Arrange
var service = new AgentService();
// Act
var result = await service.ProcessMessageAsync("test");
// Assert
result.Should().NotBeNull();
}
[Fact]
public async Task AsyncMethod_ShouldThrowException()
{
// Arrange
var service = new AgentService();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
async () => await service.ProcessMessageAsync("")
);
}
测试覆盖率
1. 安装覆盖率工具
dotnet add package coverlet.collector
2. 运行覆盖率测试
dotnet test --collect:"XPlat Code Coverage"
3. 生成覆盖率报告
# 安装报告生成工具
dotnet tool install -g dotnet-reportgenerator-globaltool
# 生成报告
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html
# 查看报告
start coveragereport/index.html
4. 覆盖率目标
-
核心业务逻辑:80% 以上
-
工具函数:90% 以上
-
UI 代码:50% 以上(可选)
持续集成中的测试
1. GitHub Actions 配置
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Run tests
run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage"
- name: Upload coverage
uses: codecov/codecov-action@v2
测试检查清单
在提交代码之前,使用这个清单检查测试:
-
\] **单元测试** * \[ \] 所有工具函数都有测试 * \[ \] 所有验证逻辑都有测试 * \[ \] 边界条件都有测试 * \[ \] 异常情况都有测试
-
\] AI 服务调用有测试
-
\] 多轮对话有测试
-
\] 测试命名清晰
-
\] 一个测试只验证一件事
-
-
\] **覆盖率** * \[ \] 核心逻辑覆盖率 \> 80% * \[ \] 关键路径都有测试
-
\] 配置了自动测试
-
小结
测试是保证代码质量的重要手段,关键要点:
-
单元测试:测试单个函数,使用 Mock 隔离依赖
-
集成测试:测试组件协作,验证真实场景
-
测试命名:清晰描述测试内容
-
AAA 模式:准备、执行、断言
-
测试覆盖率:核心逻辑要有足够覆盖
-
持续集成:自动运行测试
记住:好的测试不仅能发现 bug,还能作为代码的文档。
