插件化模块单元测试
# 单元测试背景
# 依赖Web API服务
● 接口鉴权。需启动Web API服务进行鉴权后测试,同时插件需集成于Web API才能测试验证,版本较多,有时Web API会出现无法正常启动,费时费力带来额外的成本投入排查,在单元测试中我们直连网关,不再依赖Web API服务,确保单元测试更高效和更充分。
● 接口安全性。为快速测试验证接口我们可能会省去鉴权,所以往往将接口直接声明为匿名接口,但自测验证通过后遗忘去除匿名,极容易引发严重的安全事故。
# 及时发现错误
● 单元测试可以在开发过程中尽早地发现代码中的错误。例如,当开发者编写了一个函数,用于计算两个数的乘积,通过单元测试可以快速检查这个函数是否能正确处理各种输入情况,像正数、负数、零等。如果函数存在逻辑错误,如错误的运算符使用或者变量初始化问题,单元测试能够在代码集成到更大的系统之前就将其暴露出来。
● 这有助于避免错误在后续的开发阶段(如集成测试、系统测试)中被发现,因为那时修复错误的成本会更高。因为在集成测试阶段,可能需要排查多个模块之间的交互问题来定位错误,而在单元测试阶段,只需要关注单个单元的逻辑。
# 促进代码的可维护性
● 为了能够顺利地进行单元测试,代码往往需要被设计得更加模块化。例如,一个复杂的系统如果要进行单元测试,就需要将功能分解为多个独立的单元。这使得代码的结构更加清晰,每个单元都有明确的职责。
● 当后续需要对代码进行修改或者扩展功能时,良好的单元测试可以作为保障。开发者可以先修改代码,然后运行单元测试来验证修改后的代码是否仍然符合预期。比如,一个电商系统的订单处理模块,如果对订单金额计算的逻辑进行了修改,单元测试可以快速检查修改后的逻辑是否正确处理了各种订单情况,如优惠券折扣、满减活动等情况,从而确保代码改动不会引入新的错误。
# 提高代码的可读性
● 开发者在编写单元测试时,需要理解代码单元的功能。这会促使开发者编写更容易被理解的代码。因为如果代码本身难以理解,编写单元测试也会变得非常困难。例如,一个函数如果参数过多、逻辑过于复杂,编写单元测试时就需要考虑很多边界情况和复杂的输入组合。所以,为了便于单元测试,开发者往往会优化代码结构,减少不必要的复杂性,从而提高代码的可读性。
# 减少回归测试工作量
● 在软件开发过程中,每当代码发生变更,都需要进行回归测试来确保新的改动没有破坏已有的功能。单元测试可以承担一部分回归测试的工作。例如,在一个软件项目中,当开发者修复了一个关于用户登录功能的漏洞,通过运行相关的单元测试,就可以快速验证登录功能是否仍然正常工作,而不需要手动测试整个用户登录流程以及与登录相关的其他功能(如用户权限检查等)。
● 大大节省了测试人员时间,使他们可以将更多精力放在新功能的测试和更复杂的集成测试场景上。
# 单元测试工具
使用微软官方在Visual Studio编译中无缝集成的MSTest测试框架(MSTest overview - .NET | Microsoft Learn (opens new window))和第三方Moq工具(GitHub - devlooped/moq: The most popular and friendly mocking framework for .NET (opens new window))
# 单元测试搭建
# 插件解决方案创建单元测试项目

注:单元项目名称规范为:插件名称+UnitTest,例如如下:

# 单元测试项目引用插件项目

# 单元测试项目引用最新包

<ItemGroup>
<PackageReference Include="IoTCenterCore.UnitTest" Version="6.1.0-rc9" />
</ItemGroup>
# 单元测试依赖
# 直连网关服务
请确保待进行单元测试对应版本的网关服务已正常启动,同时确保网关启动模式非单网关启动即无需启动web api服务

# 单元测试用例
# 创建单元测试业务类
# 静态构造函数注入相关服务

# 基础设施和插件服务注入说明
var services = new ServiceCollection();
var serviceProvider = ModuleUnitTest.Plugin
.RegisterBasicServices()
.RegisterModuleServices<EquipListDbContext>(services)
.RegisterModuleSession("admin", "Ganwei@.123")
.Build();
单元测试注册基础设施。RegisterBasicServices注册的是网关所有服务和web api基础服务
单元测试插件注册上下文。若插件自身无上下文即默认使用底层GWDbContext,则将上述EquipListDbContext替换为GWDbContext即可。
单元测试插件注册服务。若插件自身需注入服务,则使用上述var services = new ServiceCollection();来进行注册即可,例如如下:
var services = new ServiceCollection();
services.AddScoped<IExportManager, ExportManager>();
var serviceProvider = ModuleUnitTest.Plugin
.RegisterBasicServices()
.RegisterModuleServices<GWDbContext>(services)
.RegisterModuleSession("admin", "Ganwei@.123")
.Build();
- 单元测试插件注册用户。RegisterModuleSession("admin", "Ganwei@.123")修改所连接数据库的用户账号和密码
# 场景一(请求无参)

# 获取设备树形列表
直接实例化new EquipListServiceImpl,网关和插件服务直接从上述静态构造函数构建的容器中去获取
equipListServiceImpl = new EquipListServiceImpl(
serviceProvider.GetRequiredService<ICurveClientAppService>(),
Mock.Of<IHttpContextAccessor>(),
serviceProvider.GetRequiredService<IotCenterHostService>(),
serviceProvider.GetRequiredService<IEquipBaseClientAppService>(),
serviceProvider.GetRequiredService<IDatabaseRepository>(),
Mock.Of<IExportManager>(),
serviceProvider.GetRequiredService<ILoggingService>(),
serviceProvider.GetRequiredService<EquipListDbContext>(),
serviceProvider.GetRequiredService<PermissionCacheService>(),
Mock.Of<IImportManager>(),
Mock.Of<IHttpClientFactory>(),
MockStringLocalizer().Object,
serviceProvider.GetRequiredService<Session>(),
serviceProvider.GetRequiredService<IServiceScopeFactory>(),
serviceProvider.GetRequiredService<IDataCenterAppService>());
# 设备树形列表测试方法
[TestMethod]
public void GetEquipListByPage()
{
try
{
var result = equipListServiceImpl.GetEquipListByPage(new Models.GetEquipListModel() { });
Assert.IsNotNull(result);
Assert.AreEqual(200, result.Code);
}
catch (Exception ex)
{
Assert.Fail(ex.Message);
}
}

# 场景二(请求简单类型传参)
对于内联测试,MSTest 使用DataRow来指定数据驱动测试使用的值。测试针对每个数据行连续运行。这样就可以使用单个方法轻松测试各种输入。 例如如下示例
[TestMethod]
[DataRow(1, 1, 2)]
[DataRow(2, 2, 4)]
[DataRow(3, 3, 6)]
[DataRow(0, 0, 1)] // The test run with this row fails
public void AddIntegers_FromDataRowTest(int x, int y, int expected)
{
var target = new Maths();
int actual = target.AddIntegers(x, y);
Assert.AreEqual(expected, actual,
"x:<{0}> y:<{1}>",
new object[] {x, y});
}
# 获取指定设备下测点状态列表
/// <summary>
/// 查询指定设备测点状态列表
/// </summary>
/// <param name="equipNo"></param>
[TestMethod]
[DataRow(1)]
public void GetEquipItemStateByPage(int equipNo)
{
try
{
var result = equipListServiceImpl.GetEquipItemStateByPage(new Models.GetEquipItemStateModel() { EquipNo = equipNo });
Assert.IsNotNull(result);
Assert.AreEqual(200, result.Code);
}
catch (Exception ex)
{
Assert.Fail(ex.Message);
}
}
# 场景三(请求复杂对象传参)
对于属性成员测试,MSTest 使用DynamicData来指定数据驱动测试使用的值。测试针对每个数据行连续运行。这样就可以使用单个方法轻松测试各种输入。 比如我们获取设备列表请求参数为对象,我们需要模拟请求复杂对象,此时需使用DynamicData,如下示例
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphores = new();
public static IEnumerable<object[]> MockEquipData()
{
yield return new object[] { new GetEquipListModel { EquipName = "烟感1" } };
yield return new object[] { new GetEquipListModel { EquipName = "门禁1" } };
}
[TestMethod]
[DynamicData(nameof(MockEquipData), DynamicDataSourceType.Method)]
public void GetEquipListByPage(GetEquipListModel model)
{
var semaphore = _semaphores.GetOrAdd(equipListServiceImpl.GetType().FullName, _ => new SemaphoreSlim(1));
semaphore.Wait();
try
{
var result = equipListServiceImpl.GetEquipListByPage(model);
Assert.IsNotNull(result);
Assert.AreEqual(200, result.Code);
}
catch (Exception ex)
{
Assert.Fail(ex.Message);
}
finally
{
semaphore.Release();
}
}

说明:上述测试集合数据定义2个对象,所以单元测试会并行运行2次,注意上下文非线程安全,上述使用信号量确保顺序执行
- DynamicData特性指定从MockEquipData方法中获取测试数据。
- 测试方法GetEquipListByPage接收GetEquipListModel对象作为参数
# 场景四(切换账号角色)
一般情况下我们只需使用同一账号测试业务场景,所以在静态构造函数提供默认全局的统一登录账号密码,但业务上存在根据账号所属角色进行单元测试。此时按照如下步骤执行
# 测试类静态构造函数

# 测试方法重新构造会话
基于上述静态构造函数获取的容器调用RebuildModuleSession方法重新构建会话,返回新的容器中再次获取Session进行构造函数注入
ServiceProvider.RebuildModuleSession("userName", "password");
# 单元测试报告
请根据如下测试报告参考模板简要填写后上传至应用商店,以便后续进行问题追溯
IoTCenter产品插件化模块单元测试报告_v1.0.docx (opens new window)
# 单元测试FAQ
# 网关连接不上?
当我们在单元测试项目中添加单元测试核心包(IoTCenterCore.UnitTest)时,单元测试项目下会生成如下2个文件,2个文件不可更改

当我们构建生成单元测试项目时,会在构建完成后输出目录自动生成这2个文件(每次重新生成解决方案时2个文件会被覆盖)appsettings.json存储的是连接网关端口,默认为4000,请确保网关启动端口为4000,若不正确,请自行修改。

# 什么时候用Mock.Of?
Mock使用来自于第三方开源模拟包(Moq),当实例化构造函数必须注入对应接口,但我们具体待测试接口无需使用该接口,我们就可以默认模拟,完全没必要手动去进行实际构造。例如,我们单元测试获取设备树形列表接口,但该接口中相关如下构造函数标注接口并未使用

# 接口实例化存在【IStringLocalizer】如何模拟构建?
public static Mock<IStringLocalizer<EquipListServiceImpl>> MockStringLocalizer()
{
var localizerMock = new Mock<IStringLocalizer<EquipListServiceImpl>>();
localizerMock.Setup(x => x[It.IsAny<string>()]).Returns(new LocalizedString("equiplist", "mock"));
return localizerMock;
}

# 如何进行异步测试?
对于异步方法,测试方法应返回 Task,并使用 async/await 关键字。
# 如何忽略测试?
如果某些测试暂时不需要运行,可以使用 [Ignore] 属性跳过测试。