領域驅動設計之單元測試最佳實踐(一)
介紹完了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 = "[email protected]", }; } 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 = "[email protected]", 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 = "[email protected]", }; //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代碼能夠節省不少時間,一勞永逸。
更多具體細節請查看源碼:https://git.oschina.net/richieyangs/BookLibrary.git