鼎鼎知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

03CleanArchitecture下的CleanTesting.md 11KB

用到的组件

本篇涉及的测试形式:

  • Subcutaneous测试:最接近UI层的测试,聚焦在输入和输出,非常适合逻辑和UI分离的场景,这样就避免进行UI测试,容易写也容易维护
  • Integration集成测试:
  • Unit单元测试:

FluentAssertions组件

  • 支持非常自然地规定自动测试的结果
  • 更好的可读性
  • 更好的测试失败说明
  • 避免调试
  • 更有生产力,更简单

Moq组件

  • .NET而来
  • 流行并且友好
  • 支持Mock类和接口
  • 强类型,这样可以避免和系统关键字冲突
  • 简单易用

EF Core InMemory组件

  • 内置数据库引擎
  • 内存数据库
  • 适用于测试场景
  • 轻量级无依赖

Respawn组件

  • 为集成测试准备的数据库智能清洗
  • 避免删除数据或回滚事务
  • 把数据库恢复到一个干净的某个节点
  • 忽略表和Schema,比如忽略_EFMigrationsHistory
  • 支持SQL Server, Postgres,MySQL

界面

列表

t1

删除和更新

t2

解决方案组件依赖

  • .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