mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-03-22 10:34:30 +08:00
- 添加完整的错误处理最佳实践指南,涵盖Result<T>和Option<T>使用方式 - 补充移动端性能优化策略,包括纹理压缩、对象池、内存管理和电池优化 - 更新单元测试教程中的相关文档链接 - 完善错误处理层次结构和测试示例代码 - 增加移动端UI优化和平台适配最佳实践
25 KiB
25 KiB
title, description
| title | description |
|---|---|
| 单元测试实践 | 学习如何为 GFramework 项目编写单元测试 |
单元测试实践
学习目标
完成本教程后,你将能够:
- 创建和配置测试项目
- 为架构组件(Model、System、Controller)编写单元测试
- 测试事件系统的发送和订阅功能
- 测试命令和查询的执行
- 使用 Moq 模拟依赖项
- 编写集成测试验证完整流程
- 理解测试最佳实践
前置条件
步骤 1:创建测试项目
首先,创建一个测试项目并添加必要的依赖。
1.1 创建测试项目
# 创建测试项目
dotnet new nunit -n MyGame.Tests
# 添加项目引用
cd MyGame.Tests
dotnet add reference ../MyGame/MyGame.csproj
1.2 配置项目文件
编辑 MyGame.Tests.csproj,添加必要的包引用:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<!-- 测试框架 -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="NUnit" Version="4.5.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
<!-- Mock 框架 -->
<PackageReference Include="Moq" Version="4.20.72" />
</ItemGroup>
<ItemGroup>
<!-- 项目引用 -->
<ProjectReference Include="..\MyGame\MyGame.csproj" />
</ItemGroup>
</Project>
1.3 创建 GlobalUsings.cs
创建 GlobalUsings.cs 文件,添加常用命名空间:
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading.Tasks;
global using NUnit.Framework;
global using Moq;
global using GFramework.Core.Abstractions.architecture;
global using GFramework.Core.Abstractions.model;
global using GFramework.Core.Abstractions.system;
步骤 2:测试架构组件
2.1 测试 Model
Model 是数据层组件,我们需要测试其初始化和数据访问功能。
using GFramework.Core.Abstractions.enums;
using GFramework.Core.model;
namespace MyGame.Tests.model;
/// <summary>
/// 测试模型类
/// </summary>
public sealed class TestModel : AbstractModel, ITestModel
{
public const int DefaultXp = 5;
public bool Initialized { get; private set; }
public int GetCurrentXp { get; } = DefaultXp;
public void Initialize()
{
Initialized = true;
}
public override void OnArchitecturePhase(ArchitecturePhase phase)
{
}
protected override void OnInit()
{
}
}
/// <summary>
/// Model 测试类
/// </summary>
[TestFixture]
public class TestModelTests
{
private TestModel _model = null!;
[SetUp]
public void SetUp()
{
_model = new TestModel();
}
[Test]
public void Model_Should_Have_Default_Xp()
{
// Assert
Assert.That(_model.GetCurrentXp, Is.EqualTo(TestModel.DefaultXp));
}
[Test]
public void Model_Should_Initialize_Correctly()
{
// Act
_model.Initialize();
// Assert
Assert.That(_model.Initialized, Is.True);
}
[Test]
public void Model_Should_Not_Be_Initialized_By_Default()
{
// Assert
Assert.That(_model.Initialized, Is.False);
}
}
2.2 测试 System
System 是业务逻辑层组件,测试其初始化和销毁功能。
using GFramework.Core.Abstractions.enums;
using GFramework.Core.Abstractions.system;
namespace MyGame.Tests.system;
/// <summary>
/// 测试系统类
/// </summary>
public sealed class TestSystem : ISystem
{
private IArchitectureContext _context = null!;
public bool Initialized { get; private set; }
public bool DestroyCalled { get; private set; }
public void SetContext(IArchitectureContext context)
{
_context = context;
}
public IArchitectureContext GetContext()
{
return _context;
}
public void Initialize()
{
Initialized = true;
}
public void Destroy()
{
DestroyCalled = true;
}
public void OnArchitecturePhase(ArchitecturePhase phase)
{
}
}
/// <summary>
/// System 测试类
/// </summary>
[TestFixture]
public class TestSystemTests
{
private TestSystem _system = null!;
private Mock<IArchitectureContext> _mockContext = null!;
[SetUp]
public void SetUp()
{
_system = new TestSystem();
_mockContext = new Mock<IArchitectureContext>();
_system.SetContext(_mockContext.Object);
}
[Test]
public void System_Should_Initialize_Correctly()
{
// Act
_system.Initialize();
// Assert
Assert.That(_system.Initialized, Is.True);
}
[Test]
public void System_Should_Destroy_Correctly()
{
// Act
_system.Destroy();
// Assert
Assert.That(_system.DestroyCalled, Is.True);
}
[Test]
public void System_Should_Store_Context()
{
// Act
var context = _system.GetContext();
// Assert
Assert.That(context, Is.EqualTo(_mockContext.Object));
}
}
步骤 3:测试事件系统
事件系统是框架的核心功能之一,需要测试事件的注册、发送和取消注册。
using GFramework.Core.events;
namespace MyGame.Tests.events;
/// <summary>
/// 测试事件类
/// </summary>
public class TestEvent
{
public int ReceivedValue { get; init; }
}
/// <summary>
/// 事件总线测试类
/// </summary>
[TestFixture]
public class EventBusTests
{
private EventBus _eventBus = null!;
[SetUp]
public void SetUp()
{
_eventBus = new EventBus();
}
[Test]
public void Register_Should_Add_Handler()
{
// Arrange
var called = false;
// Act
_eventBus.Register<TestEvent>(@event => { called = true; });
_eventBus.Send<TestEvent>();
// Assert
Assert.That(called, Is.True);
}
[Test]
public void UnRegister_Should_Remove_Handler()
{
// Arrange
var count = 0;
Action<TestEvent> handler = @event => { count++; };
// Act
_eventBus.Register(handler);
_eventBus.Send<TestEvent>();
Assert.That(count, Is.EqualTo(1));
_eventBus.UnRegister(handler);
_eventBus.Send<TestEvent>();
// Assert
Assert.That(count, Is.EqualTo(1));
}
[Test]
public void SendEvent_Should_Invoke_All_Handlers()
{
// Arrange
var count1 = 0;
var count2 = 0;
// Act
_eventBus.Register<TestEvent>(@event => { count1++; });
_eventBus.Register<TestEvent>(@event => { count2++; });
_eventBus.Send<TestEvent>();
// Assert
Assert.That(count1, Is.EqualTo(1));
Assert.That(count2, Is.EqualTo(1));
}
[Test]
public void SendEvent_Should_Pass_Event_Data()
{
// Arrange
var receivedValue = 0;
const int expectedValue = 100;
// Act
_eventBus.Register<TestEvent>(e => { receivedValue = e.ReceivedValue; });
_eventBus.Send(new TestEvent { ReceivedValue = expectedValue });
// Assert
Assert.That(receivedValue, Is.EqualTo(expectedValue));
}
}
步骤 4:测试命令和查询
命令和查询是 CQRS 模式的核心,需要测试其执行逻辑。
4.1 测试 Command
using GFramework.Core.Abstractions.cqrs.command;
using GFramework.Core.command;
namespace MyGame.Tests.command;
/// <summary>
/// 测试命令输入
/// </summary>
public sealed class TestCommandInput : ICommandInput
{
public int Value { get; init; }
}
/// <summary>
/// 测试命令
/// </summary>
public sealed class TestCommand : AbstractCommand<TestCommandInput>
{
public TestCommand(TestCommandInput input) : base(input)
{
}
public bool Executed { get; private set; }
public int ExecutedValue { get; private set; }
protected override void OnExecute(TestCommandInput input)
{
Executed = true;
ExecutedValue = input.Value;
}
}
/// <summary>
/// 带返回值的测试命令
/// </summary>
public sealed class TestCommandWithResult : AbstractCommand<TestCommandInput, int>
{
public TestCommandWithResult(TestCommandInput input) : base(input)
{
}
public bool Executed { get; private set; }
protected override int OnExecute(TestCommandInput input)
{
Executed = true;
return input.Value * 2;
}
}
/// <summary>
/// 命令执行器测试类
/// </summary>
[TestFixture]
public class CommandExecutorTests
{
private CommandExecutor _commandExecutor = null!;
[SetUp]
public void SetUp()
{
_commandExecutor = new CommandExecutor();
}
[Test]
public void Send_Should_Execute_Command()
{
// Arrange
var input = new TestCommandInput { Value = 42 };
var command = new TestCommand(input);
// Act
_commandExecutor.Send(command);
// Assert
Assert.That(command.Executed, Is.True);
Assert.That(command.ExecutedValue, Is.EqualTo(42));
}
[Test]
public void Send_WithNullCommand_Should_ThrowArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => _commandExecutor.Send(null!));
}
[Test]
public void Send_WithResult_Should_Return_Value()
{
// Arrange
var input = new TestCommandInput { Value = 100 };
var command = new TestCommandWithResult(input);
// Act
var result = _commandExecutor.Send(command);
// Assert
Assert.That(command.Executed, Is.True);
Assert.That(result, Is.EqualTo(200));
}
}
4.2 测试 Query
using GFramework.Core.Abstractions.cqrs.query;
using GFramework.Core.query;
namespace MyGame.Tests.query;
/// <summary>
/// 测试查询输入
/// </summary>
public sealed class TestQueryInput : IQueryInput
{
public int Value { get; init; }
}
/// <summary>
/// 整数查询
/// </summary>
public sealed class TestQuery : AbstractQuery<TestQueryInput, int>
{
public TestQuery(TestQueryInput input) : base(input)
{
}
protected override int OnDo(TestQueryInput input)
{
return input.Value * 2;
}
}
/// <summary>
/// 字符串查询
/// </summary>
public sealed class TestStringQuery : AbstractQuery<TestQueryInput, string>
{
public TestStringQuery(TestQueryInput input) : base(input)
{
}
protected override string OnDo(TestQueryInput input)
{
return $"Result: {input.Value * 2}";
}
}
/// <summary>
/// 查询执行器测试类
/// </summary>
[TestFixture]
public class QueryExecutorTests
{
private QueryExecutor _queryExecutor = null!;
[SetUp]
public void SetUp()
{
_queryExecutor = new QueryExecutor();
}
[Test]
public void Send_Should_Return_Query_Result()
{
// Arrange
var input = new TestQueryInput { Value = 10 };
var query = new TestQuery(input);
// Act
var result = _queryExecutor.Send(query);
// Assert
Assert.That(result, Is.EqualTo(20));
}
[Test]
public void Send_WithNullQuery_Should_ThrowArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => _queryExecutor.Send<int>(null!));
}
[Test]
public void Send_WithStringResult_Should_Return_String()
{
// Arrange
var input = new TestQueryInput { Value = 5 };
var query = new TestStringQuery(input);
// Act
var result = _queryExecutor.Send(query);
// Assert
Assert.That(result, Is.EqualTo("Result: 10"));
}
}
步骤 5:使用 Mock 和 Stub
使用 Moq 框架模拟依赖项,实现单元测试的隔离。
5.1 Mock 接口依赖
using GFramework.Core.Abstractions.model;
using GFramework.Core.system;
namespace MyGame.Tests.mock;
/// <summary>
/// 玩家数据接口
/// </summary>
public interface IPlayerModel : IModel
{
int GetScore();
void AddScore(int points);
}
/// <summary>
/// 游戏系统(依赖 IPlayerModel)
/// </summary>
public class GameSystem : AbstractSystem
{
private IPlayerModel _playerModel = null!;
protected override void OnInit()
{
_playerModel = this.GetModel<IPlayerModel>();
}
public int CalculateBonus()
{
var score = _playerModel.GetScore();
return score * 2;
}
}
/// <summary>
/// Mock 测试示例
/// </summary>
[TestFixture]
public class MockExampleTests
{
private Mock<IPlayerModel> _mockPlayerModel = null!;
private Mock<IArchitectureContext> _mockContext = null!;
private GameSystem _gameSystem = null!;
[SetUp]
public void SetUp()
{
// 创建 Mock 对象
_mockPlayerModel = new Mock<IPlayerModel>();
_mockContext = new Mock<IArchitectureContext>();
// 设置 Mock 行为
_mockContext
.Setup(ctx => ctx.GetModel<IPlayerModel>())
.Returns(_mockPlayerModel.Object);
// 创建被测试对象
_gameSystem = new GameSystem();
_gameSystem.SetContext(_mockContext.Object);
_gameSystem.Initialize();
}
[Test]
public void CalculateBonus_Should_Return_Double_Score()
{
// Arrange
_mockPlayerModel.Setup(m => m.GetScore()).Returns(100);
// Act
var bonus = _gameSystem.CalculateBonus();
// Assert
Assert.That(bonus, Is.EqualTo(200));
}
[Test]
public void CalculateBonus_Should_Call_GetScore()
{
// Arrange
_mockPlayerModel.Setup(m => m.GetScore()).Returns(50);
// Act
_gameSystem.CalculateBonus();
// Assert
_mockPlayerModel.Verify(m => m.GetScore(), Times.Once);
}
}
5.2 验证方法调用
namespace MyGame.Tests.mock;
[TestFixture]
public class VerificationTests
{
[Test]
public void Should_Verify_Method_Called_With_Specific_Arguments()
{
// Arrange
var mock = new Mock<IPlayerModel>();
// Act
mock.Object.AddScore(10);
// Assert
mock.Verify(m => m.AddScore(10), Times.Once);
}
[Test]
public void Should_Verify_Method_Never_Called()
{
// Arrange
var mock = new Mock<IPlayerModel>();
// Assert
mock.Verify(m => m.AddScore(It.IsAny<int>()), Times.Never);
}
[Test]
public void Should_Verify_Property_Access()
{
// Arrange
var mock = new Mock<IPlayerModel>();
mock.Setup(m => m.GetScore()).Returns(100);
// Act
var score = mock.Object.GetScore();
// Assert
mock.VerifyGet(m => m.GetScore(), Times.Once);
}
}
步骤 6:集成测试
集成测试验证多个组件协同工作的完整流程。
6.1 创建测试架构
using GFramework.Core.architecture;
using GFramework.Core.Abstractions.enums;
namespace MyGame.Tests.integration;
/// <summary>
/// 测试架构基类
/// </summary>
public abstract class TestArchitectureBase : Architecture<TestArchitectureBase>
{
public bool InitCalled { get; private set; }
public ArchitecturePhase CurrentPhase { get; private set; }
public List<ArchitecturePhase> PhaseHistory { get; } = new();
protected override void OnInitialize()
{
InitCalled = true;
}
public override void OnArchitecturePhase(ArchitecturePhase phase)
{
CurrentPhase = phase;
PhaseHistory.Add(phase);
base.OnArchitecturePhase(phase);
}
}
/// <summary>
/// 同步测试架构
/// </summary>
public sealed class SyncTestArchitecture : TestArchitectureBase
{
protected override void OnInitialize()
{
RegisterModel(new TestModel());
RegisterSystem(new TestSystem());
base.OnInitialize();
}
}
/// <summary>
/// 架构集成测试
/// </summary>
[TestFixture]
[NonParallelizable]
public class ArchitectureIntegrationTests
{
private SyncTestArchitecture? _architecture;
[SetUp]
public void SetUp()
{
_architecture = new SyncTestArchitecture();
}
[TearDown]
public async Task TearDown()
{
if (_architecture != null)
{
await _architecture.DestroyAsync();
_architecture = null;
}
}
[Test]
public void Architecture_Should_Initialize_All_Components_Correctly()
{
// Act
_architecture!.Initialize();
// Assert
Assert.That(_architecture.InitCalled, Is.True);
Assert.That(_architecture.CurrentPhase, Is.EqualTo(ArchitecturePhase.Ready));
var context = _architecture.Context;
var model = context.GetModel<TestModel>();
Assert.That(model, Is.Not.Null);
Assert.That(model!.Initialized, Is.True);
var system = context.GetSystem<TestSystem>();
Assert.That(system, Is.Not.Null);
Assert.That(system!.Initialized, Is.True);
}
[Test]
public void Architecture_Should_Enter_Phases_In_Correct_Order()
{
// Act
_architecture!.Initialize();
// Assert
var phases = _architecture.PhaseHistory;
CollectionAssert.AreEqual(
new[]
{
ArchitecturePhase.BeforeUtilityInit,
ArchitecturePhase.AfterUtilityInit,
ArchitecturePhase.BeforeModelInit,
ArchitecturePhase.AfterModelInit,
ArchitecturePhase.BeforeSystemInit,
ArchitecturePhase.AfterSystemInit,
ArchitecturePhase.Ready
},
phases
);
}
[Test]
public async Task Architecture_Destroy_Should_Destroy_All_Systems()
{
// Arrange
_architecture!.Initialize();
// Act
await _architecture.DestroyAsync();
// Assert
var system = _architecture.Context.GetSystem<TestSystem>();
Assert.That(system!.DestroyCalled, Is.True);
Assert.That(_architecture.CurrentPhase, Is.EqualTo(ArchitecturePhase.Destroyed));
}
[Test]
public void Event_Should_Be_Received()
{
// Arrange
_architecture!.Initialize();
var context = _architecture.Context;
var receivedValue = 0;
const int targetValue = 100;
// Act
context.RegisterEvent<TestEvent>(e => { receivedValue = e.ReceivedValue; });
context.SendEvent(new TestEvent { ReceivedValue = targetValue });
// Assert
Assert.That(receivedValue, Is.EqualTo(targetValue));
}
}
6.2 测试 BindableProperty
using GFramework.Core.property;
namespace MyGame.Tests.property;
[TestFixture]
public class BindablePropertyTests
{
[Test]
public void Value_Get_Should_Return_Default_Value()
{
// Arrange
var property = new BindableProperty<int>(5);
// Assert
Assert.That(property.Value, Is.EqualTo(5));
}
[Test]
public void Value_Set_Should_Trigger_Event()
{
// Arrange
var property = new BindableProperty<int>();
var receivedValue = 0;
// Act
property.Register(value => { receivedValue = value; });
property.Value = 42;
// Assert
Assert.That(receivedValue, Is.EqualTo(42));
}
[Test]
public void Value_Set_To_Same_Value_Should_Not_Trigger_Event()
{
// Arrange
var property = new BindableProperty<int>(5);
var count = 0;
// Act
property.Register(_ => { count++; });
property.Value = 5;
// Assert
Assert.That(count, Is.EqualTo(0));
}
[Test]
public void UnRegister_Should_Remove_Handler()
{
// Arrange
var property = new BindableProperty<int>();
var count = 0;
Action<int> handler = _ => { count++; };
// Act
property.Register(handler);
property.Value = 1;
Assert.That(count, Is.EqualTo(1));
property.UnRegister(handler);
property.Value = 2;
// Assert
Assert.That(count, Is.EqualTo(1));
}
[Test]
public void RegisterWithInitValue_Should_Call_Handler_Immediately()
{
// Arrange
var property = new BindableProperty<int>(5);
var receivedValue = 0;
// Act
property.RegisterWithInitValue(value => { receivedValue = value; });
// Assert
Assert.That(receivedValue, Is.EqualTo(5));
}
}
完整代码示例
以下是一个完整的测试项目结构示例:
MyGame.Tests/
├── GlobalUsings.cs
├── MyGame.Tests.csproj
├── model/
│ └── TestModelTests.cs
├── system/
│ └── TestSystemTests.cs
├── events/
│ └── EventBusTests.cs
├── command/
│ └── CommandExecutorTests.cs
├── query/
│ └── QueryExecutorTests.cs
├── mock/
│ ├── MockExampleTests.cs
│ └── VerificationTests.cs
├── property/
│ └── BindablePropertyTests.cs
└── integration/
└── ArchitectureIntegrationTests.cs
运行测试
使用命令行
# 运行所有测试
dotnet test
# 运行特定测试类
dotnet test --filter "FullyQualifiedName~EventBusTests"
# 运行特定测试方法
dotnet test --filter "FullyQualifiedName~EventBusTests.Register_Should_Add_Handler"
# 生成测试覆盖率报告
dotnet test --collect:"XPlat Code Coverage"
使用 IDE
在 Visual Studio 或 Rider 中:
- 打开测试资源管理器
- 选择要运行的测试
- 点击"运行"或"调试"按钮
测试输出示例
正在启动测试执行,请稍候...
总共 1 个测试文件与指定模式匹配。
通过! - 失败: 0, 通过: 15, 跳过: 0, 总计: 15, 持续时间: 234 ms
测试运行成功。
测试总数: 15
通过: 15
总时间: 1.2345 秒
测试最佳实践
1. 遵循 AAA 模式
[Test]
public void Example_Test()
{
// Arrange - 准备测试数据和依赖
var input = new TestInput { Value = 10 };
// Act - 执行被测试的操作
var result = PerformOperation(input);
// Assert - 验证结果
Assert.That(result, Is.EqualTo(20));
}
2. 测试命名规范
使用清晰的命名约定:
// 格式: MethodName_Scenario_ExpectedBehavior
[Test]
public void Send_WithNullCommand_Should_ThrowArgumentNullException()
{
// ...
}
3. 一个测试一个断言
// 好的做法
[Test]
public void Model_Should_Initialize()
{
_model.Initialize();
Assert.That(_model.Initialized, Is.True);
}
// 避免
[Test]
public void Model_Should_Work()
{
_model.Initialize();
Assert.That(_model.Initialized, Is.True);
Assert.That(_model.GetXp(), Is.EqualTo(5));
Assert.That(_model.Name, Is.Not.Null);
}
4. 使用 SetUp 和 TearDown
[TestFixture]
public class MyTests
{
private MyClass _instance = null!;
[SetUp]
public void SetUp()
{
// 每个测试前执行
_instance = new MyClass();
}
[TearDown]
public void TearDown()
{
// 每个测试后执行
_instance?.Dispose();
}
}
5. 测试边界条件
[Test]
[TestCase(0)]
[TestCase(-1)]
[TestCase(int.MinValue)]
public void AddScore_WithInvalidValue_Should_ThrowException(int invalidScore)
{
Assert.Throws<ArgumentException>(() => _model.AddScore(invalidScore));
}
6. 使用测试数据生成器
[Test]
[TestCase(1, 2)]
[TestCase(5, 10)]
[TestCase(100, 200)]
public void CalculateBonus_Should_Return_Double(int input, int expected)
{
var result = Calculator.CalculateBonus(input);
Assert.That(result, Is.EqualTo(expected));
}
下一步
完成本教程后,你可以:
-
提高测试覆盖率
- 使用代码覆盖率工具(如 Coverlet)
- 目标:达到 80% 以上的代码覆盖率
-
学习 TDD(测试驱动开发)
- 先写测试,再写实现
- 红-绿-重构循环
-
集成 CI/CD
- 在 GitHub Actions 中自动运行测试
- 配置测试失败时阻止合并
-
性能测试
- 使用 BenchmarkDotNet 进行性能测试
- 测试关键路径的性能
-
探索高级测试技术
- 参数化测试
- 数据驱动测试
- 快照测试