三層架構之解耦和單元測試
業務域的簡單案例---構造器賦值
傳統nUnit測試示例
壞味道?---重構的提出
MSpec的引入--- AAA語法
Rhino Mock --- 我演我
AutoMock --- 懶的最高境界
得心應手武器庫:
nUnit
MSpec
Rhino Mock
AutoMocking
本文所涉及使用的工具, 見前文: 我的.Net武器庫 ------ 新.Net架構必備工具列表
三層架構之解耦和單元測試
依賴注入DI很大程度的幫助測試單元化。這對層與層之間的依賴關系,幾乎是真理。
如對數據讀寫的依賴關系,用IRepository替換之后,所有用到IRepository的類,如Serivce這一層的ExamService,在測試時,只需要傳入一個Mock的IRepository類,就不需要使用真實的數據庫對它測試了.
我們的另外一層Controller也用到Service這一層,同樣我為Service這一層的實現也提出一個接口IExamService,在Controller的構造器中傳入IExamService的Mock類。因此,很容易的讓測試關注於Controller本身的行為和功能。甚至可以在ExamService類實現之前,我們就可以測試和實現Controller類。這是依賴注入的優勢。
這一整套分層,解耦和測試我們已經實現了,並形成一個規范的過程和成形的框架。現在已經簡單到按部就班,就能輕松完成,甚至后期都可以考慮自動生成這部分代碼。但這部分現在不是本文的重點。
業務域的簡單案例---構造器賦值
當我們的注意力轉移到業務域時,情景有了悄悄的改變。業務域中,類與類之間有更多更復雜的依賴關系。相比之下,三層之間反而簡單。
這里,把我正在做的考試(Exam)類做一個簡單的背景介紹。考試,對於身經百戰的我們應該不陌生了,讓我們好好分析,看看熟悉身影的陌生之面。另外,我這里考試更多是拿社會化考試作分析目標。
一個考試有三個很重要的要素:考試代碼(考試定義);考區(北京考區,湖南考區);考試日期。這三個要素,唯一標識一個考試,也就是說,同一個考區,同一個考試定義在同日期,我就認為是同一個考試。很簡單的邏輯,為了體現這個邏輯,我把這三個要素,放在考試類的構造器中。為什么?任何一個要素的缺失,考試對象的存在都沒有任何含義,所以一開始構造的時候,就要傳入。從另一個角度,考區+考試定義+日期是考試的業務ID,是唯一標識,必須貫穿於業務對象的始終。
看代碼:
public class Exam { public Exam(District district, ExamDef exam_def, Date date) { District = district; ExamDef = exam_def; Date = date; } }
通過構造器,從外部傳入三個對象后,把它們賦給考試的三不屬性,而這三個屬性是只讀, Private是為了給nHibernate和構造器使用的。為什么?如前所說他們是業務動,在創建之后,再修改沒有任何含義。
看代碼:
public class Exam { public Exam(District district, ExamDef exam_def, Date date) { District = district; ExamDef = exam_def; Date = date; } public virtual ExamDef ExamDef { get; private set; } public virtual District District { get; private set; } public virtual Date Date { get;private set; } }
傳統nUnit測試示例
好了,背景已經足夠了。讓我們來針對這部分功能進行測試。喂,等等,我們……現在有功能嗎?有!我測試的描述就是,
當從構造器鏈構造考試類時,三個屬性應該要賦相應的值。
是的,足夠簡單使我們一目了然,也足夠復雜,我們需要用測試來保障它的功能。 1. 保證它被運行---覆蓋測試;2. 保證它是按我的設計進行的---行為測試。
看代碼:
[TestFixture] public class when_create_an_exam { [Test] public void it_should_assign_parameters_to_properties() { //Arrange var stub_exam_def = new ExamDef("98"); var stub_district = new District("01"); var stub_date = new Date(2011, 1, 1); //Action var subject = new Exam(stub_district, stub_exam_def, stub_date); //Assert Assert.AreEqual(stub_district,subject.District); Assert.AreEqual(stub_exam_def,subject.ExamDef); Assert.AreEqual(stub_date,subject.Date); } }
引入三個中間變量和另外三個類的定義我就不在這羅嗦了。我的命名方式也曾為人病詬,也不在這辯解。只看實質內容:分別創建三個類的實例,用於測試,至於這三個類的具體內容,我其實並不關心。所以用個詞Stub來表示我的不關心。DDD的核心理念之一:名符其實。最后,我的斷言只判斷屬性的值是否與構造器傳入值相符。OK,完成!
壞味道?---重構的提出
過一段時,間。我們再回頭看看這段測試,會有些小小的不舒服。特別,我們還有更多的類有類似的構造器賦值功能,還有更多更復雜的功能等着我們去測試,我們在做商業軟件,不是嗎?隨着類似的測試更得越多。這些小小的不舒服會越積越大。
這面的測試有什么問題?
1. 測試有三部分:建立測試環境;調用被測功能,(測試的本體);斷言。上面的代碼,我甚至都已經刻意用注釋分離出了這么三塊,但仍不是語法級別的分離。
2. 對第三方的類依賴較為嚴重,這是本文的重點---單元測試單元化。對Exam類來說ExamDef, District都是插足的第三者。
3. 測試代碼太多,被測的實際上只有三行,雖然這不是原則性的問題,但是本着更好,更快,更強的精神,這個問題也是值得解決的。
好了,你提出的問題已經太多了,我沒辦法一下子解決。3個還多?是的,我們的口號是“只要一個好”。
MSpec的引入--- AAA語法
言歸正傳,讓我們本着選代和重構的原則來把這些問題一個一個解決。是的,測試也需要重構,測試代碼還有bug呢?一點不奇怪。你沒碰到過?噢,因為你根本不寫測試代碼。
關於測試的三段式,我曾經看過有人確實在nUnit的框架下一步一步重構,形成良好了測試框架。這里我就不這么麻煩了,直接上工具MSpec!測試的三段式,有個說法,叫AAA語法,分別是Arrange,Action,Assert。3A級語法,多酷!
而MSpec用了自己的名詞,分別是Establish, Because, It。看看下面改造之后的測試代碼就清楚什么意思了。
看代碼:
public class When_create_an_exam_by { private Establish context = () => { stub_exam_def = new ExamDef("98"); stub_district = new District("01"); stub_date = new Date(2011, 1, 1); }; private Because of = () => subject = new Exam(stub_district, stub_exam_def, stub_date); private It should_assign_to_properties = () => { subject.District.ShouldEqual(stub_district); subject.ExamDef.ShouldEqual(stub_exam_def); subject.Date.ShouldEqual(stub_date); }; private static ExamDef stub_exam_def; private static District stub_district; private static Date stub_date; private static Exam subject; }
再看一看測試運行的結果,就明了代碼即文檔的含義了。
看截圖:
從nUnit升級到MSpec,給人一種耳目一新的感覺。開始也許會有些不習慣。但是,一旦習慣之后再也不想回頭了。
Rhino Mock --- 我演我
好了,看看第二個問題。一開始,我們依乎不覺得這是個大問題,不就是直接創建一個依賴美嗎,創建就完了唄,一行代碼而已。仍然,需要提醒注意,我們是在做商業軟件。一旦展開了,一個類不可能只是一、兩個類,特別是間接關聯的,會更多,拔出蘿卜帶出泥。就拿這個考試類來說,在我們的實際項目中,它還有考試科目列表屬性,還通過報考類與考生有間接聯系。而報考類又與訂單類,事務類有交互有關系。考慮所有這些級聯關系,難道我為了測試這個構造賦值功能把所有的類全部創建出來?
再進一步思考,我們會給出一個自然的解決方案,把考區類,考試定義類抽象出兩個接口來,構造器傳入接口定義,而不是類本身。這其實是對層與層之間依賴注入的一個模仿。但是,相信我,這個方向是另一個夢魘的入口。業務域和多層之間完全是不同的環境。不想太深入討論,可能獨立一篇文章都打不住。
幸好,我們有另一個工具Rhino Mock,能幫助我們解決類的模擬的問題。改造之后的測試代碼如下。唯一的影響是,你需要為被模擬的類,加入一個至少是protected的無參數構造器。這其實不是個大問題,如果你同時在項目中使用nHibernate的話,也會有類似的要求。
看代碼:
public class When_create_an_exam { private Establish context = () => { stub_exam_def = MockRepository.GenerateMock<ExamDef>(); stub_district = MockRepository.GenerateMock<District>(); stub_date = MockRepository.GenerateMock<Date>(); }; //...此處省略的沒有修改的代碼 }
可以看到,這一次的重構,把考試代碼、考區代碼等,其實你根本不關心的信息已經省略掉了。
AutoMocking --- 懶的最高境界
到這還不夠,最后一個問題是填飽我們肚子的最有一塊燒餅。
隆重介紹AutoMocking,自動模擬。當你的測試類從AutoMock的Specification類繼承時,它會自動為你創建一個被測試對象subject,並且根據被測試對象構建器的參數定義,全自動的創建模擬對象。而引用這些模擬對象的方式,
很簡單Dependency<ExamDef>,就是依賴注入的依賴這個詞。已經不需要太多的解釋---名如其實。
再看代碼:
public class When_create_an_exam:Specification<Exam> { private It should_assign_to_properties = () => { subject.District.ShouldEqual(DependencyOf<District>()); subject.ExamDef.ShouldEqual(DependencyOf<ExamDef>()); subject.Date.ShouldEqual(DependencyOf<Date>()); }; }
三行實現代碼,對應三行測試代碼。簡潔的不能再簡潔了。