12 KiB
12 KiB
测试方案设计
所属项目: free-code .NET 10 重写 文档类型: 测试设计 来源章节: DESIGN-NET10-PART3.md § 22 上级文档: 测试与构建总览
概述
原始 TypeScript 项目没有系统化的测试覆盖。.NET 重写从零建立分层测试体系,目标是核心路径 80%+ 覆盖率。测试框架组合为 xUnit 2 + NSubstitute 5 + FluentAssertions 7,三个独立测试项目分别承载单元、集成和 E2E 三个层次。
22.1 测试项目结构
tests/
├── FreeCode.Tests.Unit/ # 120+ 单元测试
│ ├── Engine/
│ │ ├── QueryEngineTests.cs
│ │ └── SystemPromptBuilderTests.cs
│ ├── Tools/
│ │ ├── BashToolTests.cs
│ │ ├── FileEditToolTests.cs
│ │ ├── FileReadToolTests.cs
│ │ ├── FileWriteToolTests.cs
│ │ ├── GlobToolTests.cs
│ │ ├── GrepToolTests.cs
│ │ ├── AgentToolTests.cs
│ │ └── ToolRegistryTests.cs
│ ├── Commands/
│ │ └── CommandRegistryTests.cs
│ ├── ApiProviders/
│ │ ├── AnthropicProviderTests.cs
│ │ ├── CodexProviderTests.cs
│ │ └── ProviderRouterTests.cs
│ ├── Mcp/
│ │ ├── McpClientTests.cs
│ │ ├── McpClientManagerTests.cs
│ │ └── TransportTests.cs
│ ├── Lsp/
│ │ └── LspClientManagerTests.cs
│ ├── Services/
│ │ ├── AuthServiceTests.cs
│ │ ├── RateLimitServiceTests.cs
│ │ ├── SessionMemoryServiceTests.cs
│ │ ├── CompanionServiceTests.cs
│ │ └── NotificationServiceTests.cs
│ └── State/
│ └── AppStateStoreTests.cs
├── FreeCode.Tests.Integration/ # 60 集成测试
│ ├── QueryPipelineTests.cs
│ ├── McpIntegrationTests.cs
│ ├── LspIntegrationTests.cs
│ ├── BridgeIntegrationTests.cs
│ ├── TaskManagerTests.cs
│ └── PluginLoadingTests.cs
└── FreeCode.Tests.E2E/ # 20 端到端测试
├── CliTests.cs
├── OneShotModeTests.cs
└── InteractiveReplTests.cs
22.2 单元测试示例
BashToolTests.cs
测试覆盖: 命令执行、超时处理、后台任务、只读分类。
原始来源: ../../src/tools/BashTool.tsx
// === BashToolTests.cs ===
public class BashToolTests
{
private readonly BashTool _tool = new(
Substitute.For<IBackgroundTaskManager>(),
Substitute.For<IFeatureFlagService>());
[Fact]
public async Task ExecuteAsync_SimpleCommand_ReturnsOutput()
{
// Arrange
var input = new BashToolInput { Command = "echo hello" };
var context = new ToolExecutionContext(
WorkingDirectory: Path.GetTempPath(),
PermissionMode: PermissionMode.Default,
AdditionalWorkingDirectories: [],
PermissionEngine: Substitute.For<IPermissionEngine>(),
LspManager: Substitute.For<ILspClientManager>(),
TaskManager: Substitute.For<IBackgroundTaskManager>(),
Services: Substitute.For<IServiceProvider>());
// Act
var result = await _tool.ExecuteAsync(input, context);
// Assert
result.IsError.Should().BeFalse();
result.Data.Stdout.Trim().Should().Be("hello");
result.Data.ExitCode.Should().Be(0);
}
[Fact]
public async Task ExecuteAsync_Timeout_KillsProcess()
{
var input = new BashToolInput { Command = "sleep 10", Timeout = 500 };
var context = CreateContext();
var result = await _tool.ExecuteAsync(input, context);
result.Data.Interrupted.Should().BeTrue();
result.Data.ExitCode.Should().Be(-1);
}
[Fact]
public async Task ExecuteAsync_Background_ReturnsTaskId()
{
var taskManager = Substitute.For<IBackgroundTaskManager>();
taskManager.CreateShellTaskAsync(Arg.Any<string>(), Arg.Any<ProcessStartInfo>())
.Returns(new LocalShellTask { TaskId = "bg-123", Command = "sleep 10" });
var tool = new BashTool(taskManager, Substitute.For<IFeatureFlagService>());
var input = new BashToolInput { Command = "sleep 10", RunInBackground = true };
var result = await tool.ExecuteAsync(input, CreateContext());
result.Data.BackgroundTaskId.Should().Be("bg-123");
}
[Theory]
[InlineData("ls", true)]
[InlineData("rm -rf /", false)]
[InlineData("cat file.txt", true)]
[InlineData("echo test > file", false)]
public void IsReadOnly_ClassifiesCorrectly(string command, bool expected)
{
var input = new BashToolInput { Command = command };
_tool.IsReadOnly(input).Should().Be(expected);
}
}
ToolRegistryTests.cs
测试覆盖: 基础工具注册、MCP 工具去重策略。
原始来源: ../../src/tools.ts
// === ToolRegistryTests.cs ===
public class ToolRegistryTests
{
[Fact]
public async Task GetToolsAsync_ReturnsBaseTools()
{
var registry = CreateRegistry();
var tools = await registry.GetToolsAsync();
tools.Should().Contain(t => t.Name == "Read");
tools.Should().Contain(t => t.Name == "Edit");
tools.Should().Contain(t => t.Name == "Bash");
tools.Should().Contain(t => t.Name == "Agent");
}
[Fact]
public async Task AssembleToolPool_DeduplicatesMcpTools()
{
// 内置 "Bash" 工具 + MCP 也提供 "Bash" → 只保留内置
var registry = CreateRegistry();
var mcpManager = Substitute.For<IMcpClientManager>();
mcpManager.GetToolsAsync().Returns(new List<ITool>
{
CreateTool("Bash"), // 与内置重名,应被丢弃
CreateTool("SlackSearch"), // 新工具,应被添加
});
// 验证: Bash 只有一个 (内置), SlackSearch 被添加
}
}
AnthropicProviderTests.cs
测试覆盖: SSE 流式事件解析。
原始来源: ../../src/services/api/claude.ts
// === AnthropicProviderTests.cs ===
public class AnthropicProviderTests
{
[Fact]
public async Task StreamAsync_ParsesSSECorrectly()
{
var sseResponse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\n"
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"Hello\"}}\n\n"
+ "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
var handler = new MockHttpHandler(sseResponse);
var provider = new AnthropicProvider(new HttpClient(handler));
var messages = new List<SDKMessage>();
await foreach (var msg in provider.StreamAsync(new ApiRequest()))
messages.Add(msg);
messages.Should().ContainSingle(m => m is SDKMessage.StreamingDelta sd && sd.Text == "Hello");
}
}
CompanionServiceTests.cs
测试覆盖: 骰子结果确定性、不同用户产生不同结果。 原始来源: 无直接对应(新增 Companion 功能)
// === CompanionServiceTests.cs ===
public class CompanionServiceTests
{
[Fact]
public void RollBones_IsDeterministic()
{
// 相同 userId → 相同骨骼
var service = new CompanionService(Substitute.For<IGlobalConfig>());
var c1 = service.RollBones("user-123");
var c2 = service.RollBones("user-123");
c1.Should().BeEquivalentTo(c2);
}
[Fact]
public void RollBones_DifferentUsers_DifferentResults()
{
var service = new CompanionService(Substitute.For<IGlobalConfig>());
var c1 = service.RollBones("user-A");
var c2 = service.RollBones("user-B");
// 极大概率不同 (18 种族, 6 眼形, 5 稀有度)
(c1.Species != c2.Species || c1.Eye != c2.Eye).Should().BeTrue();
}
}
22.3 集成测试示例
McpIntegrationTests.cs
测试覆盖: stdio MCP 服务器连接建立。
原始来源: ../../src/services/mcp/
// === McpIntegrationTests.cs ===
public class McpIntegrationTests : IAsyncLifetime
{
private McpClientManager _manager = null!;
private string _tempDir = null!;
public async Task InitializeAsync()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"mcp-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
// 创建一个测试用 MCP stdio 服务器脚本
var serverScript = Path.Combine(_tempDir, "test-server.sh");
await File.WriteAllTextAsync(serverScript, @"#!/bin/bash
echo '{""jsonrpc"":""2.0"",""id"":1,""result"":{""protocolVersion"":""2025-03-26"",""capabilities"":{""tools"":{}}}}'
");
Process.Start("chmod", $"+x {serverScript}")?.WaitForExit();
}
[Fact]
public async Task ConnectServersAsync_ConnectsToStdioServer()
{
// 配置一个 stdio MCP 服务器
var config = new StdioServerConfig { Command = "echo", Scope = ConfigScope.Local };
// ... 验证连接成功
}
}
22.4 E2E 测试示例
CliTests.cs
测试覆盖: 版本标志输出、一次性模式响应。
原始来源: ../../src/entrypoints/cli.tsx
// === CliTests.cs ===
public class CliTests
{
[Fact]
public async Task VersionFlag_PrintsVersion()
{
var result = await RunCliAsync("--version");
result.ExitCode.Should().Be(0);
result.Output.Should().StartWith("free-code ");
}
[Fact]
public async Task OneShotMode_ReturnsResponse()
{
// 需要 mock API server
var result = await RunCliAsync("-p", "what is 2+2?");
result.ExitCode.Should().Be(0);
}
private static async Task<(int ExitCode, string Output)> RunCliAsync(params string[] args)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"run --project src/FreeCode {string.Join(" ", args)}",
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
}
};
process.Start();
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode, output);
}
}
22.5 测试金字塔
| 层次 | 项目 | 数量 | 执行时间目标 |
|---|---|---|---|
| 单元测试 | FreeCode.Tests.Unit |
120+ | < 30 秒 |
| 集成测试 | FreeCode.Tests.Integration |
60 | < 3 分钟 |
| E2E 测试 | FreeCode.Tests.E2E |
20 | < 10 分钟 |
| 合计 | 200 |
单元测试覆盖所有工具、引擎、命令、API 提供商、MCP/LSP 客户端、服务层和状态管理。集成测试验证跨组件交互(查询管道、MCP 连接、LSP 通信、Bridge 握手、任务管理器、插件加载)。E2E 测试启动完整进程,验证 CLI 行为对外部用户可见的部分。
22.6 测试框架版本
| 包 | 版本 | 用途 |
|---|---|---|
xunit |
2.x | 测试运行器和断言基础 |
NSubstitute |
5.x | 接口 mock / stub / spy |
FluentAssertions |
7.x | 可读的断言链 |
Microsoft.NET.Test.Sdk |
最新 | dotnet test 集成 |
coverlet.collector |
最新 | 代码覆盖率收集 |