领域驱动设计之单元测试最佳实践(二)
介绍完了DDD案例,我们终于可以进入主题了,本方案的测试代码基于Xunit编写,断言组件采用了FluentAssertions,类似的组件还有Shouldly。另外本案例使用了Code Contracts for .NET,如果不安装此插件,可能有个别测试不能正确Pass。
为了实现目标中的第二点:"尽量不Mock,包括数据库读取部分”,我尝试过3种方案:
1、测试代码连接真实数据库,只需要将测试数据库配置到测试项目中的web.config中,即可达到这一目标。但是该方案毕竟存在很多缺点,如:需要将测试库和正式库的更改保持同步,单元测试不利于集成在CI中,不利于团队协作等。
2、使用SQL Lite,但是由于SQL lite本身不支持一些Linq表达式如:Skip,另外还有一些功能也无法跟Sql server保持一致,最终放弃该方案。
3、使用测试组件Effort,可以很好的配合Entity framework使用,由于Effort内部使用了关系型内存数据库nmemory,所以非常适合运行单元测试。
当然我还是非常期待微软能够编写基于EF的单元测试组件。
我在《我眼中的领域驱动设计》一文中提到:不要使用数据库独有的技术,如存储过程和触发器等。一方面这些逻辑都应该是Domain逻辑,另一方面一旦使用了这些技术也就意味着我们无法为这些逻辑编写测试。
一、使用Effort
为了能够在Castle中使用基于Effort的DbContext,需要在Castle中注册Effort:
public
class
FakeDbContextInstaller:IWindsorInstaller
{
public
const
string
DbConnectionKey =
"FakeDbConnection"
;
public
const
string
FakeBookLibraryDbContextKey =
"FakeBookLibraryDbContext"
;
public
void
Install(IWindsorContainer container, IConfigurationStore store)
{
container.Register(
Component.For<DbConnection>().UsingFactoryMethod(DbConnectionFactory.CreateTransient)
.Named(DbConnectionKey)
.LifestylePerWebRequest()
);
container.Register(Component.For<BookLibraryDbContext>()
.DependsOn(Dependency.OnComponent(
typeof
(DbConnection), DbConnectionKey))
.Named(FakeBookLibraryDbContextKey)
.LifestylePerWebRequest()
.IsDefault());
}
}
二、为测试编写场景
为了复用测试数据,我们需要编写场景(Scenario),下面的文件组织结构描述了这一意图:

以用户注册为例,设计RegisterUserScenario:
public
class
RegisterUserScenario : ScenarioBase
{
public
UserModel GivingModel {
get
;
set
; }
public
Guid Id {
get
;
private
set
; }
public
RegisterUserScenario(IWindsorContainer container):
base
(container)
{
GivingModel =
new
UserModel()
{
Name =
"Lilei"
,
Password =
"Password1"
,
Email =
"lilei@google.com"
,
};
}
public
override
void
Execute()
{
var
userService = Container.Resolve<IUserService>();
Id = userService.Register(GivingModel);
}
}
场景总是提供了正确的数据,执行这样的场景总是能够得到正确的结果:
[Fact]
public
void
When_RegisterUserWithValidData_Should_CreateUser()
{
//Arrange
var
scenario=
new
RegisterUserScenario(Container);
//Act
scenario.Execute();
//Assert
var
user = UserService.GetUser(scenario.Id);
user.Name.Should().Be(scenario.GivingModel.Name);
user.Email.Should().Be(scenario.GivingModel.Email);
}
测试的方法名很重要,我们在读完这个方法名之后就知道该测试是在干嘛。
为了得到失败的结果,我们需要重写Scenario中的数据,比如下面的测试:
[Fact]
public
void
When_RegisterUserWithEmptyName_Should_ThrowException()
{
//Arrange
var
scenario=
new
RegisterUserScenario(Container)
{
GivingModel =
new
UserModel()
{
Name =
string
.Empty,
Email =
"lilei@google.com"
,
Password =
"Password1"
}
};
//Act
scenario.Invoking(s => s.Execute()).ShouldThrow<Exception>(
"invalid username"
);
}
三、基于之前的场景编写新的场景,从而达到复用数据的目的
例如我们需要编写“用户登录”的测试,首先需要编写LoginScenario
public
class
LoginScenario:ScenarioBase
{
public
string
Email {
get
;
set
; }
public
string
Password {
get
;
set
; }
public
bool
Login {
get
;
private
set
; }
public
Guid Id {
get
;
private
set
; }
public
LoginScenario(IWindsorContainer container) :
base
(container)
{
var
registerScenario=
new
RegisterUserScenario(container);
registerScenario.Execute();
Id = registerScenario.Id;
Email = registerScenario.GivingModel.Email;
Password = registerScenario.GivingModel.Password;
}
public
override
void
Execute()
{
var
userService = Container.Resolve<IUserService>();
Login=userService.Login(Email, Password);
}
}
在这个场景的构造函数中我们又执行了RegisterScenario,从而达到重复利用数据的目的。
为“用户登录”编写测试:
public
class
UserLoginTests:TestBase
{
[Fact]
public
void
When_LoginWithInexistentEmail_Should_ThrowException()
{
//Arrange
var
loginScenario=
new
LoginScenario(Container)
{
Email =
"other@google.com"
,
};
//Act
loginScenario.Invoking(s => s.Execute()).ShouldThrow<ApplicationServiceException>(
"no such user"
);
}
[Fact]
public
void
When_LoginWithWrongPassword_Should_ReturnFalse()
{
//Arrange
var
loginScenario=
new
LoginScenario(Container)
{
Password =
"wrongPassword"
};
//Act
loginScenario.Execute();
//Assert
loginScenario.Login.Should().BeFalse();
}
[Fact]
public
void
When_LoginWithCorrectPassword_Should_ReturnTrue()
{
//Arrange
var
loginScenario =
new
LoginScenario(Container);
//Act
loginScenario.Execute();
//Assert
loginScenario.Login.Should().BeTrue();
}
}
我们总是需要为新的业务逻辑编写新的场景,而新的场景总是基于之前编写好的场景,整个系统的任何功能都可以用真实的测试代码来覆盖。
由于我们在测试基类中为每个测试都开启了单独的scope,每一个测试结束都会dispose数据库。所以每一个测试无论运行多少遍都是相同的效果。缺点是这些测试不能并行运行,XUnit默认以不同的测试类为单位并行运行,我们通过在测试类上添加相同的[Collection("IntegrationTests")]标签,从而禁用XUnit的并行运行能力。
采用该方案覆盖完毕单元测试的系统,开发者每次提交代码并保证所有单元测是都是“passed”,开发者每一次代码提交都会信心满满。
高质量的单元测试不但能够确保系统的平稳运行,更是一种有效的文档,当你读完每一个场景的测试用例,你基本就能够对该业务非常熟悉了。
接近真实的单元测试还可以省去你Debug的时间,只要你编写的测试通过,基本就可以确保后台代码的可靠性。另外你可以在任何时候从这些测试代码中Debug进去,相比从前端界面Debug代码能够节省不少时间,一劳永逸。