学习ASP.NET Core(10)-全局日志与xUnit系统测试
上一篇我们介绍了数据塑形,HATEOAS和内容协商,并在制器方法中完成了对应功能的添加;本章我们将介绍日志和测试相关的概念,并添加对应的功能
一、全局日志
在第一章介绍项目结构时,有提到.NET Core启动时默认加载了日志服务,且在appsetting.json文件配置了一些日志的设置,根据设置的日志等级的不同可以进行不同级别的信息的显示,但它无法做到输出固定格式的log信息至本地磁盘或是数据库,所以需要我们自己手动实现,而我们可以借助日志框架实现。
ps:在第7章节中我们记录的是数据处理层方法调用的日志信息,这里记录的则是ASP.NET Core WebAPI层级的日志信息,两者有所差异
1、引入日志框架
.NET程序中常用的日志框架有log4net,serilog 和Nlog,这里我们使用Serilog来实现相关功能,在BlogSystem.Core层使用NuGet安装Serilog.AspNetCore,同时还需要搜索Serilog.Skins安装希望支持的功能,这里我们希望添加对文件和控制台的输出,所以选择安装的是Serilog.Skins.File和Serilog.Skins.Console
需要注意的是Serilog是不受appsetting.json的日志设置影响的,且它可以根据命名空间重写记录级别。还有一点需要注意的是需要手动对Serilog对象进行资源的释放,否则在系统运行期间,无法打开日志文件。
2、系统添加
在BlogSystem.Core项目中添加一个Logs文件夹,并在Program类中进行Serilog对象的添加和使用,如下:
3、全局添加
1、这个时候其实系统已经使用Serilog替换了系统自带的log对象,如下图,Serilog会根据相关信息进行高亮显示:
2、这个时候问题就来了,我们怎么才能进行全局的添加呢,总不能一个方法一个方法的添加吧?还记得之前我们介绍AOP时提到的过滤器Filter吗?ASP.NET Core中一共有五类过滤器,分别是:
- 授权过滤器Authorization Filter:优先级最高,用于确定用户是否获得授权。如果请求未被授权,则授权过滤器会使管道短路;
- 资源过滤器Resource Filter:授权后运行,会在Authorization之后,Model Binding之前执行,可以实现类似缓存的功能;
- 方法过滤器Action Filter:在控制器的Action方法执行之前和之后被调用,可以更改传递给操作的参数或更改从操作返回的结果;
- 异常过滤器Exception Filter:当Action方法执行过程中出现了未处理的异常,将会进入这个过滤器进行统一处理;
- 结果过滤器Result Filter:执行操作结果之前和之后运行,仅在action方法成功执行后才运行;
过滤器的具体执行顺序如下:
3、这里我们可以借助异常过滤器实现全局日志功能的添加;在在BlogSystem.Core项目添加一个Filters文件夹,添加一个名为ExceptionFilter的类,继承IExceptionFilter接口,这里是参考老张的哲学的简化版本,实现如下:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
using Serilog;
using System;
namespace BlogSystem.Core.Filters
{
public class ExceptionsFilter : IExceptionFilter
{
private readonly ILogger<ExceptionsFilter> _logger;
public ExceptionsFilter(ILogger<ExceptionsFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
try
{
//错误信息
var msg = context.Exception.Message;
//错误堆栈信息
var stackTraceMsg = context.Exception.StackTrace;
//返回信息
context.Result = new InternalServerErrorObjectResult(new { msg, stackTraceMsg });
//记录错误日志
_logger.LogError(WriteLog(context.Exception));
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
finally
{
//记得释放,否则运行时无法打开日志文件
Log.CloseAndFlush();
}
}
//返回500错误
public class InternalServerErrorObjectResult : ObjectResult
{
public InternalServerErrorObjectResult(object value) : base(value)
{
StatusCode = StatusCodes.Status500InternalServerError;
}
}
//自定义格式内容
public string WriteLog(Exception ex)
{
return $"【异常信息】:{ex.Message} \r\n 【异常类型】:{ex.GetType().Name} \r\n【堆栈调用】:{ex.StackTrace}";
}
}
}
4、在Startup类的ConfigureServices方法中进行异常处理过滤器的注册,如下:
5、我们在控制器方法中抛出一个异常,分别查看效果如下,如果觉得信息太多,可调整日志记录级别:
二、系统测试
这里我们从测试的类别出发,了解下测试相关的内容,并添加相关的测试(介绍内容大部分来自微软官方文档,为了更易理解,从个人习惯的角度进行了修改,如有形容不当之处,可在评论区指出)
1、测试说明及分类
1、自动测试是确保软件应用程序按照作者期望执行操作的一种绝佳方式。软件应用有多种类型的测试,包括集成测试、Web测试、负载测试和其他测试。单元测试用于测试个人软件的组件或方法,并不包括如数据库、文件系统和网络资源类的基础结构测试。
当然我们可以使用编写测试的最佳方法,如测试驱动开发(TDD)所指的先编写单元测试,再编写该单元测试要检查的代码,就好比先编写书籍的大纲,再编写书籍。其主要目的是为了帮助开发人员编写更简单,更具可读性的高效代码。两者区别如下(来自Edison Zhou)
2、以深度(测试的细致程度)和广度(测试的覆盖程度)区分, 测试分类如下(此处内容来自solenovex):
Unit Test 单元测试:它可以测试一个类或者一个类的某个功能,但其覆盖程度较低;
Integration Test 集成测试:它的细致程度没有单元测试高,但是有较好的覆盖程度,它可以测试功能的组合,以及像数据库或文件系统这样的外部资源;
Subcutaneous Test 皮下测试 :其作用区域为UI层的下一层,有较好的覆盖程度,但是深度欠佳;
UI测试:直接从UI层进行测试,覆盖程度很高,但是深度欠佳
3、在编写单元测试时,尽量不要引入基础结构依赖项,这些依赖项会降低测试速度,使测试更加脆弱,我们应当将其保留供集成测试使用。可以通过遵循显示依赖项原则和使用依赖项注入避免应用程序中的这些依赖项,还可以将单元测试保留在单独的项目中与集成测试相分离,以确保单元测试项目没有引用或依赖于基础结构包。
总结下常用的单元测试和集成测试,单元测试会与外部资源隔离,以保证结果的一致性;而集成测试会依赖外部资源,且覆盖面更广。
2、测试的目的及特征
1、为什么需要测试?我们从以单元测试为例从4个方面进行说明:
- 时间人力成本:进行功能测试时,通常涉及打开应用程序,执行一系列需要遵循的步骤来验证预期的行为,这意味着测试人员需要了解这些步骤或联系熟悉该步骤的人来获取结果。对于细微的更改或者是较大的更改,都需要重复上述过程,而单元测试只需要按一下按钮即可运行,无需测试人员了解整个系统,测试结果也取决于测试运行程序而非测试人员。
- 防止错误回归:程序更改后有时会出现旧功能异常的问题,所以测试时不仅要测试新功能还要确保旧功能的正常运行。而单元测试可以确保在更改一行代码后重新运行整套测试,确保新代码不会破坏现有的功能。
- 可执行性:在给定某个输入的情况下,特定方法的作用或行为可能不会很明显。比如,输入或传递空白字符串、null后,该方法会有怎样的行为?而当我们使用一套命名正确的单元测试,并清楚的解释给定的输入和预期输出,那么它将可以验证其有效性。
- 减少代码耦合:当代码紧密耦合时,会难以进行单元测试,所以以创建单元测试为目的时,会在一定程度上要求我们注意代码的解耦
2、优质的测试需要符合哪些特征,同样以单元测试为例:
- 快速:成熟的项目会进行数千次的单元测试,所以应当花费非常少的时间来运行单元测试,一般来说在几毫秒
- 独立:单元测试应当是独立的,可以单独运行,不依赖文件系统或数据库等外部因素
- 可重复:单元测试的结果应当保持一致,即运行期间不进行更改,返回的结果应该相同
- 自检查:测试应当在没有人工交互的情况下,自动检测是否通过
- 及时:编写单元测试不应该花费过多的时间,如果花费时间较长,应当考虑另外一种更易测试的设计
在具体的执行时,我们应当遵循一些最佳实践规则,具体请参考微软官方文档单元测试最佳做法
3、xUnit框架介绍
常用的单元测试框架有MSTest、xUnit和NUnit,这里我们以xUnit为例进行相关的说明
3.1、测试操作
首先我们要明确如何编写测试代码,一般来说,测试分为三个主要操作:
- Arrange:意为安排或准备,这里可以根据需求进行对象的创建或相关的设置;
- Act:意为操作,这里可以执行获取生产代码返回的结果或者是设置属性;
- Assert:意为断言,这里可以用来判断某些项是否按预期进行,即测试通过还是失败
3.2、Assert类型
Assert时通常会对不同类型的返回值进行判断,而在xUnit中是支持多种返回值类型的,常用的类型如下:
boolean:针对方法返回值为bool的结果,可以判断结果是true或false
string:针对方法返回值为string的结果,可以判断结果是否相等,是否以某字符串开头或结尾,是否包含某些字符,并支持正则表达式
数值型:针对方法返回值为数值的结果,可以判断数值是否相等,数值是否在某个区间内,数值是否为null或非null
Collection:针对方法返回值为集合的结果,可以针对集合内所有元素或至少一个元素判断其是否包含某某字符,两个集合是否相等
ObjectType:针对方法返回值为某种类型的情况,可以判断是否为预期的类型,一个类是否继承于另一个类,两个类是否为同一实例
Raised event:针对事件是否执行的情况,可以判断方法内部是否执行了预期的事件
3.3、常用特性
在xUnit中还有一些常用的特性,可作用于方法或类,如下:
[Fact]:用来标注该方法为测试方法
[Trait(“Name”,”Value”)]:用来对测试方法进行分组,支持标注多个不同的组名
[Fact(Skip=”忽略说明…”)]:用来修饰需要忽略测试的方法
3.4 、性能相关
在测试时我们应当注意性能上的问题,针对一个对象供多个方法使用的情况,我们可以使用共享上下文
- 针对一个对象供同一类中的多个方法使用时,可以将该对象提取出来,使用IClassFixture对象将其注入到构造函数中
- 针对一个对象供多个测试类使用的情况,可以使用ICollectionFixture对象和[CollectionDefinition(“…”)]定义该对象
需要注意在使用IClassFixture和ICollectionFixture对象时应当避免多个测试方法之间相互影响的情况
3.5、数据驱动测试
在进行测试方法时,通常我们会指定输入值和输出值,如希望多测试几种情况,我们可以定义多个测试方法,但这显然不是一个最佳的实现;在合理的情况下,我们可以将参数和数据分离,如何实现?
- 方法一:使用[Theory]替换[Fact],将输入输出参数提取为方法参数,并使用多个[InlineData(“输入参数”,”输出参数)]来标注方法
- 方法二:使用[Theory]替换[Fact],针对测试方法新增一个测试数据类,该类包含一个静态属性IEumerable<object[]>,将数据封装为一个list后赋值给该属性,并使用[MemberData(nameof(数据类的属性),MemberType=typeof(数据类))]标注测试方法即可;
- 方法三:使用外部数据如数据库数据/Excel数据/txt数据等,其实现原理与方法二相同,只是多了一个数据获取封装为list的步骤;
- 方法四:自定义一个Attribute,继承自DataAttribute,实现其对应的方法,使用yield返回object类型的数组;使用时只需要在测试方法上方添加[Theory]和[自定义Attribute]即可
4、测试项目添加
4.1、添加测试项目
首先我们右键项目解决方案选择添加一个项目,输入选择xUnit后进行添加,项目命名为BlogSystem.Core.Test,如下:
项目添加完成后我们需要添加对测试项目的引用,在解决方案中右击依赖项选择添加BlogSystem.Core;这里我们预期对Controller进行测试,但后续有可能会添加其他项目的测试,所以我们建立一个Controller_Test文件夹保证项目结构相对清晰。
4.2、添加测试方法
在BlogSystem.Core.Test项目的Controller_Test文件夹下新建一个命名为UserController_Should的方法;在微软的《单元测试的最佳做法》文档中有提到,测试命名应该包括三个部分:①被测试方法的名称②测试的方案③方案预期行为;实际使用时也可以对照测试的方法进行命名,这里我们先不考虑最佳命名原则,仅对照测试方法进行命名,如下:
using Xunit;
namespace BlogSystem.Core.Test.Controller_Test
{
public class UserController_Should
{
[Fact]
public void Register_Test()
{
}
}
}
4.3、方案选择
1、在进行测试时,我们可以根据实际情况使用以下方案来进行测试:
- 方案一:直接new一个Controller对象,调用其Action方法直接进行测试;适用于Controller没有其他依赖项的情况;
- 方案二:当有多个依赖项时,可以借助工具来模拟实例化时的依赖项,如Moq就是一个很好的工具;当然这需要一定的学习成本;
- 方案三:模拟Http请求的方式来调用API进行测试;NuGet中的Microsoft.AspNetCore.TestHost就支持这类情况;
- 方案四:自定义方法实例化所有依赖项;将测试过程种需要用到的对象放到容器中并加载,其实现较为复杂;
这里我们以测试UserController为例,其构造函数包含了接口服务实例和HttpContext对象实例,Action方法内部又有数据库连接操作,从严格意义上来讲测试这类方法已经脱离了单元测试的范畴,属于集成测试,但这类测试一定程度上可以节省我们大量的重复劳动。这里我们选择方案三进行相关的测试。
2、如何使用TestHost对象?先来看看它的工作流程,首先它会创建一个IHostBuilder对象,并用它创建一个TestServer对象,TestServer对象可以创建HttpClient对象,该对象支持发送及响应请求,如下图所示(来自solenovex):
在尝试使用该对象的过程中我们会发现一个问题,创建IHostBuilder对象时需要指明类似Startup的配置项,因为这里是测试环境,所以实际上会与BlogSystem.Core中的配置类StartUp存在一定的差异,因而这里我们需要为测试新建立一个Startup配置类。
4.4、方法实现
1、我们在测试项目中添加名为TestServerFixture 的类和名为TestStartup的类,TestServerFixture 用来创建HttpClient对象并做一些准备工作,TestStartup类为配置类。然后使用Nuget安装Microsoft.AspNetCore.TestHost;TestServerFixture 和TestStartup实现如下:
using Autofac.Extensions.DependencyInjection;
using BlogSystem.Core.Helpers;
using BlogSystem.Model;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Hosting;
using System;
using System.Net.Http;
namespace BlogSystem.Core.Test
{
public static class TestServerFixture
{
public static IHostBuilder GetTestHost()
{
return Host.CreateDefaultBuilder()
.UseServiceProviderFactory(new AutofacServiceProviderFactory())//使用autofac作为DI容器
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseTestServer()//建立TestServer——测试的关键
.UseEnvironment("Development")
.UseStartup<TestStartup>();
});
}
//生成带token的httpclient
public static HttpClient GetTestClientWithToken(this IHost host)
{
var client = host.GetTestClient();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {GenerateJwtToken()}");//把token加到Header中
return client;
}
//生成JwtToken
public static string GenerateJwtToken()
{
TokenModelJwt tokenModel = new TokenModelJwt { UserId = userData.Id, Level = userData.Level.ToString() };
var token = JwtHelper.JwtEncrypt(tokenModel);
return token;
}
//测试用户的数据
private static readonly User userData = new User
{
Account = "jordan",
Id = new Guid("9CF2DAB5-B9DC-4910-98D8-CBB9D54E3D7B"),
Level = Level.普通用户
};
}
}
using Autofac;
using Autofac.Extras.DynamicProxy;
using BlogSystem.Common.Helpers;
using BlogSystem.Common.Helpers.SortHelper;
using BlogSystem.Core.AOP;
using BlogSystem.Core.Filters;
using BlogSystem.Core.Helpers;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
namespace BlogSystem.Core.Test
{
public class TestStartup
{
private readonly IConfiguration _configuration;
public TestStartup(IConfiguration configuration)
{
_configuration = GetConfig(null);
//传递Configuration对象
JwtHelper.GetConfiguration(_configuration);
}
public void ConfigureServices(IServiceCollection services)
{
//控制器服务注册
services.AddControllers(setup =>
{
setup.ReturnHttpNotAcceptable = true;//开启不存在请求格式则返回406状态码的选项
var jsonOutputFormatter = setup.OutputFormatters.OfType<SystemTextJsonOutputFormatter>()?.FirstOrDefault();//不为空则继续执行
jsonOutputFormatter?.SupportedMediaTypes.Add("application/vnd.company.hateoas+json");
setup.Filters.Add(typeof(ExceptionsFilter));//添加异常过滤器
}).AddXmlDataContractSerializerFormatters()//开启输出输入支持XML格式
//jwt授权服务注册
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(x =>
{
x.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true, //验证密钥
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_configuration["JwtTokenManagement:secret"])),
ValidateIssuer = true, //验证发行人
ValidIssuer = _configuration["JwtTokenManagement:issuer"],
ValidateAudience = true, //验证订阅人
ValidAudience = _configuration["JwtTokenManagement:audience"],
RequireExpirationTime = true, //验证过期时间
ValidateLifetime = true, //验证生命周期
ClockSkew = TimeSpan.Zero, //缓冲过期时间,即使配置了过期时间,也要考虑过期时间+缓冲时间
};
});
//注册HttpContext存取器服务
services.AddHttpContextAccessor();
//自定义判断属性隐射关系
services.AddTransient<IPropertyMappingService, PropertyMappingService>();
services.AddTransient<IPropertyCheckService, PropertyCheckService>();
}
//configureContainer访问AutoFac容器生成器
public void ConfigureContainer(ContainerBuilder builder)
{
//获取程序集并注册,采用每次请求都创建一个新的对象的模式
var assemblyBll = Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "BlogSystem.BLL.dll"));
var assemblyDal = Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "BlogSystem.DAL.dll"));
builder.RegisterAssemblyTypes(assemblyDal).AsImplementedInterfaces().InstancePerDependency();
//注册拦截器
builder.RegisterType<LogAop>();
//对目标类型启用动态代理,并注入自定义拦截器拦截BLL
builder.RegisterAssemblyTypes(assemblyBll).AsImplementedInterfaces().InstancePerDependency()
.EnableInterfaceInterceptors().InterceptedBy(typeof(LogAop));
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler(builder =>
{
builder.Run(async context =>
{
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Unexpected Error!");
});
});
}
app.UseRouting();
//添加认证中间件
app.UseAuthentication();
//添加授权中间件
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
private IConfiguration GetConfig(string environmentName)
{
var path = Microsoft.DotNet.PlatformAbstractions.ApplicationEnvironment.ApplicationBasePath;
IConfigurationBuilder builder = new ConfigurationBuilder().SetBasePath(path)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
if (!string.IsNullOrWhiteSpace(environmentName))
{
builder = builder.AddJsonFile($"appsettings.{environmentName}.json", optional: true);
}
builder = builder.AddEnvironmentVariables();
return builder.Build();
}
}
}
2、这里对UserController中的注册、登录、获取用户信息方法进行测试,实际上这里的断言并不严谨,会产生什么后果?请继续往下看
using BlogSystem.Model.ViewModels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Hosting;
using Newtonsoft.Json;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Xunit;
namespace BlogSystem.Core.Test.Controller_Test
{
public class UserController_Should
{
const string _mediaType = "application/json";
readonly Encoding _encoding = Encoding.UTF8;
/// <summary>
/// 用户注册
/// </summary>
[Fact]
public async Task Register_Test()
{
// 1、Arrange
var data = new RegisterViewModel { Account = "test", Password = "123456", RequirePassword = "123456" };
StringContent content = new StringContent(JsonConvert.SerializeObject(data), _encoding, _mediaType);
using var host = await TestServerFixture.GetTestHost().StartAsync();//启动TestServer
// 2、Act
var response = await host.GetTestClient().PostAsync($"http://localhost:5000/api/user/register", content);
var result = await response.Content.ReadAsStringAsync();
// 3、Assert
Assert.DoesNotContain("用户已存在", result);
}
/// <summary>
/// 用户登录
/// </summary>
[Fact]
public async Task Login_Test()
{
var data = new LoginViewModel { Account = "jordan", Password = "123456" };
StringContent content = new StringContent(JsonConvert.SerializeObject(data), _encoding, _mediaType);
var host = await TestServerFixture.GetTestHost().StartAsync();//启动TestServer
var response = await host.GetTestClientWithToken().PostAsync($"http://localhost:5000/api/user/Login", content);
var result = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("账号或密码错误!", result);
}
/// <summary>
/// 获取用户信息
/// </summary>
[Fact]
public async Task UserInfo_Test()
{
string id = "jordan";
using var host = await TestServerFixture.GetTestHost().StartAsync();//启动TestServer
var client = host.GetTestClient();
var response = await client.GetAsync($"http://localhost:5000/api/user/{id}");
var result = response.StatusCode;
Assert.True(Equals(HttpStatusCode.OK, result)|| Equals(HttpStatusCode.NotFound, result));
}
}
}
4.5、异常及解决
1、添加完上述的测试方法后,我们使用打开Visual Studio自带的测试资源管理器,点击运行所有测试,发现提示错误无法加载BLL?在原先的BlogSystem.Core的StartUp类中我们是加载BLL和DAL项目的dll来达到解耦的目的,所以做了一个将dll输出到Core项目bin文件夹的动作,但是在测试项目的TestStarup类中,我们是无法加载到BLL和DAL的。我尝试将BLL和DAL同时输出到两个路径下,但未找到对应的方法,所以这里我采用了最简单的解决方法,测试项目添加了对DAL和BLL的引用。再次运行,如下图,似乎成功了??
2、我们在测试方法内部打上断点,右击测试方法,选择调试测试,结果发现response参数为空,只应Assert不严谨导致看上去没有问题;在各种查找后,我终于找到了解决办法,在TestStarup类的ConfigureServices方法内部service.AddControllers方法最后加上这么一句话即可解决 .AddApplicationPart(Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "BlogSystem.Core.dll")))
3、再次运行测试方法,成功!但是又发现了另外一个问题,这里我们只是测试,但是数据库中却出现了我们测试添加的test账号,如何解决?我们可以使用Microsoft.EntityFrameworkCore.InMemory库 ,它支持使用内存数据库进行测试,这里暂未添加,有兴趣的朋友可以自行研究。
本章完~
本人知识点有限,若文中有错误的地方请及时指正,方便大家更好的学习和交流。
本文部分内容参考了网络上的视频内容和文章,仅为学习和交流,视频地址如下:
老张的哲学,系列教程一目录:.netcore+vue 前后端分离
我想吃晚饭,ASP.NET Core搭建多层网站架构【12-xUnit单元测试之集成测试】
solenovex,使用 xUnit.NET 对 .NET Core 项目进行单元测试
solenovex,ASP.NET Core Web API 集成测试
微软官方文档,.NET Core 和 .NET Standard 中的单元测试
Edison Zhou,.NET单元测试的艺术