# 用到的组件 本篇涉及的测试形式: - `Subcutaneous`测试:最接近`UI`层的测试,聚焦在输入和输出,非常适合逻辑和UI分离的场景,这样就避免进行UI测试,容易写也容易维护 - `Integration`集成测试: - `Unit`单元测试: ## `FluentAssertions`组件 - 支持非常自然地规定自动测试的结果 - 更好的可读性 - 更好的测试失败说明 - 避免调试 - 更有生产力,更简单 ## `Moq`组件 - 为`.NET`而来 - 流行并且友好 - 支持`Mock`类和接口 - 强类型,这样可以避免和系统关键字冲突 - 简单易用 ## `EF Core InMemory`组件 - 内置数据库引擎 - 内存数据库 - 适用于测试场景 - 轻量级无依赖 ## `Respawn`组件 - 为集成测试准备的数据库智能清洗 - 避免删除数据或回滚事务 - 把数据库恢复到一个干净的某个节点 - 忽略表和`Schema`,比如忽略`_EFMigrationsHistory` - 支持`SQL Server`, `Postgres`,`MySQL` # 界面 ## 列表 ![t1](F:\SourceCodes\DDWiki\专题\后端\测试\t1.png) ## 删除和更新 ![t2](F:\SourceCodes\DDWiki\专题\后端\测试\t2.png) # 解决方案组件依赖 - `.NET Core Template Package` - `ASP.NET Core 3.1` - `Entity Framework Core 3.1` - `ASP.NET Core Identity 3.1` - `Angular 9` # 文件结构 - `Application`层 - `Domain`层 - `Infrastructure`层 - `WebUI`层 - 集成测试层 # `WebUI`层 `TodoListsController` ``` [Authorize] public class TodoListsController : ApiController { [HttpGet] public async Task> Get() { return await Mediator.Send(new GetTodosQuery()); } [HttpGet("{id}")] public async Task Get(int id) { var vm = await Mediator.Send(new ExportTodosQuery {ListId=id}); return File(vm.Content, vm.ContentType, vm.FileName); } [HttpPost] public async Task> Create(CreateTodoListCommand command) { return await Mediator.Send(command); } } ``` # `Application`层 `GetTodosQuery.cs` ``` public class GetTodosQuery : IRequest { } public class GetTodosQueryHandler : IRequestHandler { private readonly IApplicationDbContext _context; private readonly IMapper _mapper; public GetTodosQueryHandler(IApplicationDbContext context, IMapper mapper) { _context = context; _mapper = mapper; } public async Task Handler(GetTodosQuery request, CancellationToken cancellationToken) { return new TodosVm{ PriorityLevels = Enum.GetValues(typeof(PriorityLevel)) .Cast() .Select(p => new PriorityLevelDto{Value=(int)p, Name=p.ToString()}) .ToList, Lists = await _context.TodosLists .ProjectTo(_mapper.ConfigurationProvider) .OrdreBy(t => t.Title) .ToListAsync(cacnellationToken); }; } } ``` 在`MediatR`的请求管道中做验证。 ``` public class RequestValidationBehavior : IPiplelineBehavior where TRequest : IRequest { private readonly IENumerable> _validators; public RequestValidationBehavior(IEnumerable> validators) { _validators = validators; } public Task Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next) { if(_validators.Any()) { var context = new ValidationContext(request); var failures = _validators .Select(v => v.Validate(context)) .SelectMany(result => result.Errors) .Whre(f => f != null) .ToList(); if(failures.Count != 0) { throw new ValidationException(failures); } } return next(); } } ``` 在`MediatR`的请求管道中处理异常。 ``` public class UnhandledExceptionBehavior : IPipelineBehavior { private readonly ILogger _logger; public UnhandledExceptionBehaviour(ILogger logger) { _logger = logger; } public async Task Handle(TRequest request, CancellationTOken canellationToken, RequestHandlerDelegate next) { try { return await next(); } catch(Exception ex) { var requestName = typeof(TRequest).Name; _logger.LogError(); throw; } } } ``` # 集成测试层 引用`WebUI`层 ``` dotnet add package FluentAssertions dotnet add package Moq dotnet add package Respawn ``` `GetTodosTests.cs` ``` using static Testing; public class GetTodosTests : TestBase //每次运行测试让数据库回到干净的状态 { [Test] public async Task ShouldReturnAllListsAndAssociatedItems() { //Arrange await AddAsync(new TodoList{ Title = "", Items = { new TodoItem {Title="",Done=true}, new TodoItem {Title="",Done=true}, new TodoItem {Title="",Done=true}, new TodoItem {Title=""}, new TodoItem {Title=""}, new TodoItem {Title=""} } }); var query = new GetTodosQuery(); //Act TodosVm result = await SendAsync(query); //Assert result.Should().NotBeNull(); result.List.Should().HaveCount(1); result.Lists.First().Items.Should().HaveCount(); } } ``` `CreateTodoListTests.cs` ``` using static Testing; public class CreateTodListTests : TestBase//每次运行测试让数据库回到干净的状态 { [Test] public void ShouldRequredMinimumFileds() { var command = new CrateTodoListCommand(); FluenActions.Invoking(()=> SendAsync(command)) .Should().Throw(); } [Test] public async Task ShouldRequiredUniqueTitle() { await SendAsync(new CreateTodoListCommand{ Title = "" }); var command = new CreateTodoListCommand { Title = "" }; FluenActions.Invoking(() => SendAsync(comamnd)) .Should().Throw(); } [Test] public async Task ShouldCreateTodoList() { var useId = await RunAsDefaultUserAsync(); var command = new CreateTodoListCommand { Title = "" }; var listeId = await SendAsync(command); var list = await FindAsync(listId); list.Should().NotBuNull(); list.Title.Should().Be(command.Title); list.Created.Should().BeCloseTo(DateTime.Now, 10000); list.CreatedBy.Should().Be(userId); } } ``` `UpdateDotListTests.cs` ``` using static Testing; public class UpdateTodoListTests : TestBase { [Test] public void ShouldRequireValidTodoListId() { var command = new UpdateTodoListCommand { Id = 99, Title = "New Title" }; FluenActions.Invoking(() => SendAsync(command)).Should().Throw(); } [Test] public async Task ShouldRequireUniqueTitle() { var listId = await SendAsync(new CreateTodoListCommand{Title=""}); await SendAsync(new CreateTodoListCommand={Title=""}); var command = new UpdateTodoListCommand { Id = listId, Title = "" }; FluentActions.Invoking(()=> SendAsync(command)).Should().Throw().Where(ex => ex.Errors.ContainsKey("Title")) .And.Erros["Title"].Should().Contain("The specified title is already exists."); } [Test] public async Task ShouldUpdateTodoList() { var userId = await RunAsDefaultUserAsync(); var listId = await SendAsync(new CreateTodoListCommand{ Title = "" }); var command = new UpdateTodoListCommand { Id = listId, Title= "" }; await SendAsync(command); var list =await FindAsync(listId); list.Title.Should().Be(command.Title); list.LastModifiedBy.Should().NotBeNull(); list.LastModifiedBy.Should().Be(userId); list.LastModified.Should().NotBeNull(); list.LstModified.Should().BeCloaseTo(DateTime.Now, 1000); } } ``` `Testing.cs` ``` [SetUpFixture] public class Testing { private static IConfiguration _configuration;//配置 private static IServiceScopeFactory _scopeFactory;//域 private static Checkpoint _checkpoint;//Respawn private static string _currentUserId; [OneTimeSetUp] public void RunBeforeAnyTests() { var buidler = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory) .AddJsonFile("appsettings.json", true, true) .AddEnvironmentVariables(); _configuration = builder.Build(); var services = new ServiceCollection();//看作是容器 var startup = new Startup(_configuration);//Startup.cs需要IConfiguration //ServiceCollection需要宿主 services.AddSignleton(Mock.Of(w => w.ApplcationName == "CleanTesting.WebUI" && w.EnvirnmentName == "Development")); startup.ConfigureServices(services); //Replace service registration for ICurrentUserService //Remove existing registration var currentUserServiceDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ICurrentUserService)); service.Remove(currentUserServiceDescriptor); //Register testing version services.AddTransient(provider => Mock.Of(s => s.UserId == _currentUserId)); _scopeFactory = services.BuilderServiceProvider().GetService(); _checkpoint = new Checkpoint{ TablesToIgnore = new [] {"__EFMigrationsHistory"} }; } public static async Task RunAsDefaultUserAsync() { return await RundAsUserAsync("",""); _currentUserId = null; } public static async Task RunAsUserAsync(string userName, string password) { using var scope = _scopeFactory.CreateScope(); var userManager = scope.ServiceProvider.GetService>(); var user = new ApplicationUser{UserName=userName, Email = userName}; var result = await userManager.CreateAsync(user, password); _currentUserId = user.Id; return _currentUserId; } public static async Task ResetState() { await _checkpoint.Reset(_configuration.GetConnectionString("DefaultConnection")); } public static async Task AddAsync(TEntity entity) where TEntity : class { using var scope = _scopeFactory.CreateScope(); var context = scope.ServiceProvider.GetService(); context.Add(entity); await context.SaveChangesAsync(); } public static async Task SendAsync(IRequest request) { using var scope = _scopeFacotry.CreateScope(); var mediator = scope.ServiceProvider.GetService(); return await mediator.Send(request); } public static async Task FindAsync(int id) { using var scope = _scopeFactory.CreateScope(); var context = scope.ServiceProvider.GetService(); return await context.FindAsync(id); } } ``` `appSetting.json` ``` { "UseInMemoryDatabase":false, "ConnectionStrings":{ "DefaultConnection" : "Server=(localdb)\\mssqllocaldb;Database=CleanTestingDb;Truested_Connection=True;MultipleActiveRestultSet" }, "IdentityServer":{ "Clients":{ "CleanTesting.WebUI": { "Profile": "IdentityServerSPA" } }, "Key":{ "Type" : "Development" } } } ``` `TestBase.cs` ``` using static Testing; public calss TestBase { [SetUp] public async Task SetUp() { await ResetState(); } } ``` # 资源 - `Clean Testing Sample Code`: `https://github.com/jasontaylordev/cleantesting` - `Rules to Better Unit Tests`: `https://rules.ssw.com.au/rules-to-better-unit-tests`(翻墙) - `Fixie Demo`: `https://github.com/fixie/fixie.demo`