# 测试方案设计 > 所属项目: 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(), Substitute.For()); [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(), LspManager: Substitute.For(), TaskManager: Substitute.For(), Services: Substitute.For()); // 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(); taskManager.CreateShellTaskAsync(Arg.Any(), Arg.Any()) .Returns(new LocalShellTask { TaskId = "bg-123", Command = "sleep 10" }); var tool = new BashTool(taskManager, Substitute.For()); 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(); mcpManager.GetToolsAsync().Returns(new List { 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(); 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()); 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()); 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)