首次在WebAPI中写单元测试
xUnit
这次我使用的是xUnit
测试框架,而不是VS自带的MSTest
框架。在添加新建项目时选择xUnit测试项目就行了。
目前只体验到了一个差别,即xUnit可以使用特性向测试方法传参,而不用在测试方法中一个赋值语句一个个去定义参数,这是比较方便的。
单元测试有一个好处,就是一次性可以获得所测试的很多接口的失败信息。如果使用swagger
去测试接口,只能去启动项目,输入密码鉴权,然后一个个发请求。遇到一个错误处理一个接口,比较麻烦。单元测试可以把所有接口的报错信息展示在测试窗口,而且是缓存的,不用启动项目,只需要点击一下测试按钮,就把所有接口测试了。我在迁移接口时,有一个控制器一次性迁移了14个接口,单元测试通过了6个,失败了8个,失败的都列出了错误消息,这就很舒服了。经过了几天的使用,我发现这比到swagger或postman中手动测试接口方便太多了。
这里的失败基本都是迁移数据库结构引起的,我申请修改结构后,就又有几个通过了测试。修改进度如何,看起来很直观。到目前为止几天了,测试仍然没有全部通过,单元测试起到了很好的监控作用。
点击失败的测试,可以看到调用堆栈,跳转到运行失败的那一行代码。这使得修改起来很方便。
单元测试环境准备
我写的是控制器方法中action
的单元测试。但是一般来说,控制器和Service
层会注入许多服务,而action依赖于这些服务。在使用依赖注入时,单元测试要如何处理这种情况?
可以和ASP.NET core
的做法一样。它准备了一个依赖注入容器,那我也准备一个依赖注入容器。WebAPI还构造了一个web主机。但是单元测试是独立运行的,就不需要创建一个web主机了。在单元测试项目种添加了一个TestBase基类
,用于创建容器,注册服务,以供测试方法使用。
//测试环境
public class TestBase
{
//依赖注入容器
public IServiceCollection Services;
//从容器获取服务
public IServiceProvider Provider;
public TestBase()
{
//创建容器
Services = new ServiceCollection();
//....注册服务
Provider = Services.BuildServiceProvider();
}
}
然后向容器注册我们需要的服务,比如常见的MemoryCache
IWebHostEnvironment
ISqlSugarClient
XXXService
。我们就不需要在测试方法中使用new
运算符创建服务类实例,而是可以直接从容器中获得了。
//注册控制器和服务层
Services.AddScoped<HomeController>();
Services.AddScoped<IHomeService, HomeService>();
//注册缓存
Services.AddScoped<IMemoryCache, MemoryCache>(service =>
{
return new MemoryCache(new MemoryCacheOptions());
});
//注册SqlSuger
Services.AddScoped<ISqlSugarClient>(service =>
{
return new SqlSugarClient(new ConnectionConfig()
{
ConnectionString = "Data Source=XXX",
DbType = DbType.Oracle,
IsAutoCloseConnection = true,
InitKeyType = InitKeyType.Attribute,
});
});
//注册环境变量
Services.AddScoped<IWebHostEnvironment, WebHostEnvironment>();
IWebHostEnvironment
为了使用这个接口需要引入包Microsoft.Extensions.DependencyInjection.Abstractions
。这个接口一般是WebApplicationBuilder创建的,在涉及文件读写时经常用到。但是单元测试项目中没有builder,我就自己添加了一个IWebHostEnvironment实现类,并注册到容器中。
public class WebHostEnvironment : IWebHostEnvironment
{
public string WebRootPath { get => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "../../../../WebAPI/wwwroot"); }
public IFileProvider WebRootFileProvider { get; set; }
public string EnvironmentName { get; set; }
public string ApplicationName { get; set; }
public string ContentRootPath { get => AppDomain.CurrentDomain.BaseDirectory; }
public IFileProvider ContentRootFileProvider { get; set; }
}
ICurentUser
这个自定义接口一般是在请求处理管道中存储身份验证后的相关信息。比如添加身份认证中间件,并在身份认证服务中构建这个对象。单元测试中不同的接口可能需要不同的user,比如一个流程中,不同角色调用同一个接口。ICurentUser同样也是注册到容器中的,然后在service层注入。具体的业务方法中根据这个角色的不同执行不同逻辑。
我比较疑惑的是,单元测试又该怎么注入呢?要注意的是,不同测试方法的ICurentUser是不同的。要知道从容器中获取service,容器自动帮我们挑选了合适够构造方法。但是这里由于角色的不同,不能直接从容器获取准备好的角色存根。难道我们要手动构造service传入controller中吗?我是有听说mocq
,但不知道怎么用来模拟多个ICurentUser。
我最终找到了解决方案。既然我们没法在一个容器中提供不同的角色,那我们提供两个拥有不同角色的容器不就行了?为此可以在TestBase
中增加一个provider提供方法
//TestBase.cs
public ServiceProvider CreateProvider(ICurrentUser? currentUser = null)
{
IServiceCollection Services = new ServiceCollection();
Services.AddScoped<HomeController>();
Services.AddScoped<IHomeService, HomeService>();
Services.AddScoped<IMemoryCache, MemoryCache>(service =>
{
return new MemoryCache(new MemoryCacheOptions());
});
Services.AddScoped<ISqlSugarClient>(service =>
{
return new SqlSugarClient(new ConnectionConfig()
{
ConnectionString = "Data Source=xxx",
DbType = DbType.Oracle,
IsAutoCloseConnection = true,
InitKeyType = InitKeyType.Attribute,
});
});
Services.AddScoped<IWebHostEnvironment, WebHostEnvironment>();
//单元测试涉及到ICurrentUser时,自行构造对象并注入容器中
if (currentUser != null)
{
Services.AddScoped<ICurrentUser>(option =>
{
return currentUser;
});
}
var Provider = Services.BuildServiceProvider();
return Provider;
}
//testMethodA
var provider = TestServiceProvider.CreateProvider(new CurrentUser(){role="队长"});
var homecontroller = provider.GetService<HomeController>();
//testMethodB
var provider = TestServiceProvider.CreateProvider(new CurrentUser(){role="队员"});
var homecontroller = provider.GetService<HomeController>();
原来构造函数中就不用重复配置容器了,直接在属性中调用CreateProvider就行了。
private IServiceProvider _Provider;
public IServiceProvider Provider
{
get
{
if (_Provider==null)
{
_Provider= CreateProvider();
}
return _Provider;
}
}
public TestBase()
{
}
如果更多类似的类需要按需注册的话的话,我认为直接传递Action
委托到CreateProvider是一个更好的方法。
为控制器添加单元测试
添加一个HomeControllerUnitTest
类,并继承于前面定义的基类TestBase
。我们应该在构造函数中从容器取出相应的服务以供使用。
public class HomeControllerUnitTest:TestBase
{
HomeController homeController;
public HomeControllerUnitTest()
{
//从容器注入服务
homeController = new HomeController(
Provider.GetRequiredService<IHomeService>(),
Provider.GetRequiredService<IWebHostEnvironment>());
//这种方法不需要关心注入哪些服务,比前面更加方便
homeController=Provider.GetRequiredService<HomeController>();
}
}
接着,向测试类添加单元Action
的测试方法。一般都是三步
- 准备数据 Arrange
- 调用测试方法 Act
- 断言结果 Assert
也就是AAA模式。
[Theory(DisplayName = "测试XXX")]
[InlineData("xxx", "xxx", 1,30)]
public void Test_GetData(string wId, string tId, int page, int rows)
{
var data = homeController.GetData(wId, tId, page, rows).Result;
Assert.True(data.Tag);
}
在实际使用时,我会每添加一个测试,就运行未运行的测试。头一天看一下哪些测试没通过,这里一般是数据库结构不对,然后申请修改数据库。第二天再运行失败的测试验证。不会每次运行全部测试。