本篇涉及的测试形式:
Subcutaneous
测试:最接近UI
层的测试,聚焦在输入和输出,非常适合逻辑和UI分离的场景,这样就避免进行UI测试,容易写也容易维护Integration
集成测试:Unit
单元测试:FluentAssertions
组件Moq
组件.NET
而来Mock
类和接口EF Core InMemory
组件Respawn
组件Schema
,比如忽略_EFMigrationsHistory
SQL Server
, Postgres
,MySQL
.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<IActionResult<TodoVm>> Get()
{
return await Mediator.Send(new GetTodosQuery());
}
[HttpGet("{id}")]
public async Task<FileResult> Get(int id)
{
var vm = await Mediator.Send(new ExportTodosQuery {ListId=id});
return File(vm.Content, vm.ContentType, vm.FileName);
}
[HttpPost]
public async Task<IActionResult<int>> Create(CreateTodoListCommand command)
{
return await Mediator.Send(command);
}
}
Application
层GetTodosQuery.cs
public class GetTodosQuery : IRequest<TodosVm>
{
}
public class GetTodosQueryHandler : IRequestHandler<GetTodosQuery, TodosVm>
{
private readonly IApplicationDbContext _context;
private readonly IMapper _mapper;
public GetTodosQueryHandler(IApplicationDbContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public async Task<TodosVm> Handler(GetTodosQuery request, CancellationToken cancellationToken)
{
return new TodosVm{
PriorityLevels = Enum.GetValues(typeof(PriorityLevel))
.Cast<PriorityLevel>()
.Select(p => new PriorityLevelDto{Value=(int)p, Name=p.ToString()})
.ToList,
Lists = await _context.TodosLists
.ProjectTo<TodoListDto>(_mapper.ConfigurationProvider)
.OrdreBy(t => t.Title)
.ToListAsync(cacnellationToken);
};
}
}
在MediatR
的请求管道中做验证。
public class RequestValidationBehavior<TRequest, TResponse> : IPiplelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly IENumerable<IValidator<TRequest>> _validators;
public RequestValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> 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<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
private readonly ILogger<TRequest> _logger;
public UnhandledExceptionBehaviour(ILogger<TRequest> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(TRequest request, CancellationTOken canellationToken, RequestHandlerDelegate<TResponse> 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<ValidaitonException>();
}
[Test]
public async Task ShouldRequiredUniqueTitle()
{
await SendAsync(new CreateTodoListCommand{
Title = ""
});
var command = new CreateTodoListCommand
{
Title = ""
};
FluenActions.Invoking(() => SendAsync(comamnd))
.Should().Throw<ValidationException>();
}
[Test]
public async Task ShouldCreateTodoList()
{
var useId = await RunAsDefaultUserAsync();
var command = new CreateTodoListCommand
{
Title = ""
};
var listeId = await SendAsync(command);
var list = await FindAsync<TodoList>(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<NotFoundException>();
}
[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<ValidationException>().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<TodoList>(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<IWebHostEnvironment>(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<ICurrentUserService>(s => s.UserId == _currentUserId));
_scopeFactory = services.BuilderServiceProvider().GetService<IServiceScopeFactory>();
_checkpoint = new Checkpoint{
TablesToIgnore = new [] {"__EFMigrationsHistory"}
};
}
public static async Task<string> RunAsDefaultUserAsync()
{
return await RundAsUserAsync("","");
_currentUserId = null;
}
public static async Task<string> RunAsUserAsync(string userName, string password)
{
using var scope = _scopeFactory.CreateScope();
var userManager = scope.ServiceProvider.GetService<UserManager<ApplicaitonUser>>();
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>(TEntity entity) where TEntity : class
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetService<ApplicationDbContext>();
context.Add(entity);
await context.SaveChangesAsync();
}
public static async Task<TResponse> SendAsync<TResponse>(IRequest<TResponse> request)
{
using var scope = _scopeFacotry.CreateScope();
var mediator = scope.ServiceProvider.GetService<IMediator>();
return await mediator.Send(request);
}
public static async Task<TEntity> FindAsync<TEntity>(int id)
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetService<ApplicationContext>();
return await context.FindAsync<TEntity>(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