領(lǐng)域驅(qū)動設(shè)計之單元測試最佳實(shí)踐(二)
介紹完了DDD案例,我們終于可以進(jìn)入主題了,本方案的測試代碼基于Xunit編寫,斷言組件采用了FluentAssertions,類似的組件還有Shouldly。另外本案例使用了Code Contracts for .NET,如果不安裝此插件,可能有個別測試不能正確Pass。
為了實(shí)現(xiàn)目標(biāo)中的第二點(diǎn):"盡量不Mock,包括數(shù)據(jù)庫讀取部分”,我嘗試過3種方案:
1、測試代碼連接真實(shí)數(shù)據(jù)庫,只需要將測試數(shù)據(jù)庫配置到測試項(xiàng)目中的web.config中,即可達(dá)到這一目標(biāo)。但是該方案畢竟存在很多缺點(diǎn),如:需要將測試庫和正式庫的更改保持同步,單元測試不利于集成在CI中,不利于團(tuán)隊協(xié)作等。
2、使用SQL Lite,但是由于SQL lite本身不支持一些Linq表達(dá)式如:Skip,另外還有一些功能也無法跟Sql server保持一致,最終放棄該方案。
3、使用測試組件Effort,可以很好的配合Entity framework使用,由于Effort內(nèi)部使用了關(guān)系型內(nèi)存數(shù)據(jù)庫nmemory,所以非常適合運(yùn)行單元測試。
當(dāng)然我還是非常期待微軟能夠編寫基于EF的單元測試組件。
我在《我眼中的領(lǐng)域驅(qū)動設(shè)計》一文中提到:不要使用數(shù)據(jù)庫獨(dú)有的技術(shù),如存儲過程和觸發(fā)器等。一方面這些邏輯都應(yīng)該是Domain邏輯,另一方面一旦使用了這些技術(shù)也就意味著我們無法為這些邏輯編寫測試。
一、使用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());
????
}
}
二、為測試編寫場景
為了復(fù)用測試數(shù)據(jù),我們需要編寫場景(Scenario),下面的文件組織結(jié)構(gòu)描述了這一意圖:

以用戶注冊為例,設(shè)計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);
????
}
}
場景總是提供了正確的數(shù)據(jù),執(zhí)行這樣的場景總是能夠得到正確的結(jié)果:
[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);
}
測試的方法名很重要,我們在讀完這個方法名之后就知道該測試是在干嘛。
為了得到失敗的結(jié)果,我們需要重寫Scenario中的數(shù)據(jù),比如下面的測試:
[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"
);
}
三、基于之前的場景編寫新的場景,從而達(dá)到復(fù)用數(shù)據(jù)的目的
例如我們需要編寫“用戶登錄”的測試,首先需要編寫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);
????
}
}
在這個場景的構(gòu)造函數(shù)中我們又執(zhí)行了RegisterScenario,從而達(dá)到重復(fù)利用數(shù)據(jù)的目的。
為“用戶登錄”編寫測試:
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();
????
}
}
?
我們總是需要為新的業(yè)務(wù)邏輯編寫新的場景,而新的場景總是基于之前編寫好的場景,整個系統(tǒng)的任何功能都可以用真實(shí)的測試代碼來覆蓋。
由于我們在測試基類中為每個測試都開啟了單獨(dú)的scope,每一個測試結(jié)束都會dispose數(shù)據(jù)庫。所以每一個測試無論運(yùn)行多少遍都是相同的效果。缺點(diǎn)是這些測試不能并行運(yùn)行,XUnit默認(rèn)以不同的測試類為單位并行運(yùn)行,我們通過在測試類上添加相同的[Collection("IntegrationTests")]標(biāo)簽,從而禁用XUnit的并行運(yùn)行能力。
采用該方案覆蓋完畢單元測試的系統(tǒng),開發(fā)者每次提交代碼并保證所有單元測是都是“passed”,開發(fā)者每一次代碼提交都會信心滿滿。
高質(zhì)量的單元測試不但能夠確保系統(tǒng)的平穩(wěn)運(yùn)行,更是一種有效的文檔,當(dāng)你讀完每一個場景的測試用例,你基本就能夠?qū)υ摌I(yè)務(wù)非常熟悉了。
接近真實(shí)的單元測試還可以省去你Debug的時間,只要你編寫的測試通過,基本就可以確保后臺代碼的可靠性。另外你可以在任何時候從這些測試代碼中Debug進(jìn)去,相比從前端界面Debug代碼能夠節(jié)省不少時間,一勞永逸。