free-code-dotnet/docs/测试与构建/测试与构建-测试方案设计.md
应文浩wenhao.ying@xiaobao100.com e25ac591a7 init easy-code
2026-04-06 07:24:24 +08:00

370 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 测试方案设计
> 所属项目: free-code .NET 10 重写
> 文档类型: 测试设计
> 来源章节: DESIGN-NET10-PART3.md § 22
> 上级文档: [测试与构建总览](测试与构建.md)
---
## 概述
原始 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`
```csharp
// === 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`
```csharp
// === 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`
```csharp
// === 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 功能)
```csharp
// === 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/`
```csharp
// === 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`
```csharp
// === 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` | 最新 | 代码覆盖率收集 |
---
## 参考资料
- [测试与构建总览](测试与构建.md)
- [原始代码映射 — 测试与构建](reference/原始代码映射-测试与构建.md)
- [核心模块设计 — 工具系统](../核心模块设计/核心模块设计-工具系统.md)
- [基础设施设计](../基础设施设计/基础设施设计.md)