走向ASP.NET架構設計——第二章:設計/ 測試/代碼
再次申明一下:本系列不是講述TDD的,只是用TDD來建立設計的思想。即便是用DDD,有時候還是結合TDD一起使用的。
開發方式比較
我們用下面的一段分析來引出今天的內容:
想想我們平時是如何在寫代碼:拿來需求,分析功能,編寫功能代碼。這樣的方式,沒有問題,大家也一直沿用很多年了。為了后面描述方便,我們稱這種方式為傳統流程。
TDD的怎么做的:
拿來需求,分析功能,寫功能測試代碼,編寫功能代碼。其實兩個過程差不多的,真的差不多的。
首先來分析下兩種開發流程。個人認為:因為TDD多了一個角色轉換的過程:在我們傳統流程中,我們一直以一個開發人員的思維在想問題,分析,然后就開始實現。在TDD中,在分析功能之后,我們就要站在客戶的角度(當然很多時候還是我們自己在模擬客戶)就要檢測這個功能是不是真正需要的,然后在這個前提下,再開始編碼。
下面我們再來看一組分析圖:
因為從拿到需求和理解需求,到最后的實現,這個過程肯定是有偏差的。就如上圖。
在TDD中,在功能測試那一個環節,就把這種偏差控制了起來。即使最后有偏差,但是小了一些。
為什么要將兩種開發的方式比較?
首先,從總體上來看,傳統的流程就是先做出基本有用的東西,而且TDD先是搭個架子,然后在做東西。
在TDD中,我們是直奔功能:針對需求出測試,然后針對測試出功能。一針見血。可能這些功能暫時還不能完全用,因為缺少東西,如數據庫,在測試中我們可能是模擬的。例如,在實現一個功能的時候,如果這個功能需要操作數據庫或者要通過網絡訪問,那么我們在用傳統的方法寫的時候,想要看看功能最后實現的效果,往往是debug,或者做出可視化的東西出來,注意力很快就被分散了,如果發現需求理解不對,之前的就重新來過,代價可能而知。而采用TDD的方法,可以先寫測試模擬,如用mock, stub等,這樣關注點主要在業務上,這種方式就好比水波效應:從中心向周圍擴散。
什么是設計
一個軟件系統,最重要的就是核心業務功能,系統設計的時候,肯定先是分析功能,并且確認分析的功能是符合需求的,然后再為實現功能尋找解決方案。在有了解決方案的前提下,再考慮上技術的選擇,復雜性,可擴展行,可維護性,可行性等,最后就”設計”就產生了,確定實現方案之后,最后實現。”設計”確確實實是一個腦力活。
那么我們就來看看,如何做出一個比較好的設計。做設計,考慮的太多,太少都不行。多則可能“過度”,少則可能不全。
我們下面就用TDD來幫助我們建立一些設計的思想。
在此之前,有一點我想提出:TDD不是測試,而是設計。如果之前一直以為TDD就是寫測試,那么就說明對TDD的理解還在“形”上。
設計初探
我們之前說過:TDD不是測試,更多的是設計的思路。那么為什么在寫代碼之前寫測試可以有個比較好的設計?我們就來體驗一下。
我們知道,在面向對象的設計中,有很多的設計原則,例如S.O.L.I.D,在系統中充分的使用這些原則,會導致一個良性的開發過程。所以一個比較的好的設計,應該是盡量的向這些設計原則上面靠攏的。
看一個例子:
例如在用戶訂單管理系統中有一個需求:客戶在下訂單的時候首先要去看看自己的賬戶是否有充足的余額,然后支付,并且把自己所有支付的訂單保存起來。(當然這個例子非常的簡單,我們這里只是通過簡單的例子展示思考的過程)
需求現在已經知道了,實現的技術難度也不大,隨便想一下,架子基本就出來了:
傳統的設計方法:
大家看看上面的Customer類,很多時候,我們都是這樣的寫的(其實就是Active Record的實現方式,后面我們會講述企業架構設計會談到)。
下面基本就是業務方法ProcessOrder的定義和實現:
{
//1.獲取 Customer的賬戶的余額
//2.計算Order中所有Proudct的總的價格
//3. 比較 余額和 總價格
//4.保存Order信息
}
代碼的架子搭起來了,實現的思路也有了。為了確保業務的理解正確,我們可能需要跟客戶或者項目組的人交流,然后再編碼實現。在編碼的的實現中,該去讀數據庫的就去讀,該插入的數據的就去插入,該怎樣就怎樣。這樣代碼寫完之后,一般是調試debug(剛剛開始,為了這個功能寫個UI,不怎么劃算),看看代碼是不是按照我們的意愿在運行。大家應該對這種實現方式沒有什么意見吧。
好,現在在處理訂單的過程中,有加入了一些要求:如果在Order中,有產品的單價超過了1000的,要通知用戶一下。
代碼變為:
{
//1.獲取 Customer的賬戶的余額
//2.計算Order中所有Proudct的總的價格
//3. 如果有Porudct的單價超過1000,通知用戶
//4.比較 余額和 總價格
//5. 保存Order信息
}
然后再調試,查詢數據,插入數據,deubg等等,把之前的步驟重復一下。不知道大家現在是什么感覺。
在上面的例子中,在第一次的代碼實現中,為了判斷ProcessOrder的正確性,我們加入了數據庫的一些操作代碼。
第二次的時候只是在業務流程處理中加了一些小的改動,但是我們在調試成本卻還是調試流程,調試數據訪問代碼。也就是說,我們第二次的時候,數據的操作方法沒有變化,變化的只是流程的處理,但是為了判斷這個ProcessOrder方法的正確性,我們還是走完了整個debug過程。
如果再次在訂單處理流程加入新的需求,那么這個方法很快膨脹起來(可能我們會把整個方法分出一些小的子方法),而且調試的成本會越來越高,而且常常重復的調試已經功能完好的代碼,如數據訪問代碼,而且調試一次的所花的時間也越來越多。
或許有人認為這不是個問題。因為我舉的例子很簡單,如果在一個業務更加復雜的項目中很多的功能都這樣,最后的項目最后會怎樣?
下面我們就用TDD的設計思想來實現一下,然后大家自己比較:
首先,需求分析還是和之前的一樣。下一步就要確認需求的理解(還是和之前的一樣)。最后開始針對需求寫測試代碼。
其實這里就有兩個問題:
1. 系統中哪些部分要寫測試代碼?
我看過一些用TDD開發的項目:幾乎是每個方法都有對應的測試代碼,而且寫的測試代碼在最后運行的時候,測試結果居然是通過debug來看的,簡直和實現功能代碼然后再調試沒有區別。
其實測試是有個覆蓋率的問題,覆蓋率就是:系統中有測試代碼的功能代碼在所有功能中的百分比。例如系統有100個功能,有30個功能寫了測試代碼,那么覆蓋率就是30%。
當然100%的覆蓋率當然好,但是也不是現實,而且也沒有必要。一般來說要對系統的核心的業務流程寫測試代碼,然后再對你認為可能會出現問題的地方寫一些測試代碼,用來測試如果引入變化后,這部分功能是好的。覆蓋率一般是70—80%比較合理,不過得看情況了。
2.怎么為這個需求寫測試代碼?
測試代碼都會寫,但是寫出好的測試代碼就不是那么容易的。首先,寫測試代碼的時候,就得站在用戶的角度,看看功能是否正確,不管內部邏輯如何實現的---只看結果,不看過程的,本著這個思想來設計測試代碼。打個不恰當的比喻:測試代碼就像是一個望子成龍,望女成鳳的家長,家長把聰明的小孩送到學校培訓,不管怎么樣培訓,可能學校是請名師來教課,還是通過比賽學習,還是用別的方式,家長不會怎么管,最后,如果小孩成才了,那么就說明你學校有本事,不然,學校就不行。
我們開始寫測試代碼,我們開始只關注業務流程方面。(假設沒有上面的那個類圖了,我們重新設計,因為之間的那個類圖用用來講述傳統的設計方式的,忘記上面的那個類圖吧)
我們的測試代碼可能會這樣寫:
{
Customer customer = new Customer();
Order order=new Order ();
//.....
// 在Order中加入一些Product
//...
customer.ProcessOrder(order);
}
這樣編譯肯定會報錯的:因為我們系統中還沒有這些類。然后我們就加上相應的代碼的,是的編譯通過。我們設計一個最直接的Customer類,盡量不寫多余的代碼:
另外的一個問題來了:
上面的測試代碼似乎沒有反應什么結果,到底怎么測試?在開始寫測試的時候,會遇到這些問題。現在就要考慮我們之前的那個“家長送孩子上學”的例子了。這里,如果系統訂單處理成功,那么就告訴說:OK,成功了,否則就說失敗。測試代碼現在改為下面的:
{
Customer customer = new Customer();
Order order=new Order ();
//.....
// 在Order中加入一些Product
//...
bool isSuucess=customer.ProcessOrder(order);
Assert.IsEqual(isSuucess, true);
}
OK,基本的測試代碼就這樣了。(當然有不足的地方,我們后面跟著思考的過程慢慢的完善)
下面我們就要使得測試的代碼通過。我們的專注先是業務流程,而不管什么數據是怎么獲取的,從哪里獲取的等,避免分散注意力。
下面我們實現ProcessOrder方法:
流程基本如下:
{
//1.獲取 Customer的賬戶的余額
//2.計算Order中所有Proudct的總的價格
//3. 比較 余額和 總價格
//4.保存Order信息
}
實現的偽碼:
{
//1.獲取Customer的賬戶的余額
decimal despoit=從一個地方獲取余額信息,不管從哪里獲取,拿來就行了。
//2.計算Order中所有Proudct的總的價格
//3.比較 余額和 總價格
//4.保存Order信息
xxx.Save(order); 保存order,不管是怎么保存的,保存就行了
}
大家看到上面的代碼后,可能有點奇怪。因為ProcessOrder是一個業務流程,它應該只是關注自己的流程如何處理,如果要數據,找個地方拿,要保存數據,找個東西保存就行了,不管怎么查詢和怎么保存。回顧前面的“學校如何教小孩子的方法”。
現在有一點要注意:我們現在關注點是業務流程的正確性,數據從哪里來,其實不重要。
我們現在只是想業務流程跑通,反正測試用的數據都是我們自己設計的,即便數據如果從數據庫中來的,而且數據拿來之后,還是得放在內存中的,何必現在就開始寫那么多的數據訪問代碼呢,不如直接用內存中的數據,讓流程先跑通,然后在慢慢替換數據訪問代碼。
好,既然決定數據從內存中拿,說白了就是hard code幾個數據,如果把取數據的方法還是放在Customer中,就像之前的傳統設計那樣。其實是有問題的:此時我們把數據訪問的代碼還是放在里面,流程通了,然后我們把hard code的代碼替換為真正的數據庫操作代碼,流程也通了。如果像之前:ProcessOrder中,加入了一個新的處理過程,我們加完代碼,運行測試,如果測試運行失敗了,那么此時是業務流程失敗了,還是數據訪問代碼失敗?還要debug進行去嗎?如果還得debug,測試的代碼的作用何在?還不如一開始就不要測試,直接debug。因為此時導致測試代碼不通過的原因有兩個了。
所以這里有一個很重要的原則:一個測試方法中,只能有一個讓它失敗的原因。不然每次運行測試,都要debug分析,是那個原因導致失敗。
而且我們知道,在第二次加入新的流程過程的時候,變化的只是業務流程,其實數據訪問那塊是沒有變化的,最后我們還是打開了數據訪問代碼的所在的類,修改方法,盡管沒有修改數據訪問方法。所以這些就要把數據訪問的代碼分析出來,讓變化和不變化的獨立--—分離變化點,萬一數據訪問代碼也變了,那就讓它們單獨的變化,這樣排錯也好點。
那么一個重要的設計原則就要用上:
S--Single Responsibility Principle (SRP)
也是我們常說的”單一職責原則”。意思很好理解:每個對象有僅僅有一個讓它變化的因素,也就是說每個對象的只關注一個或者一類功能,不要把很多的不同職能的東西全部糅在一個類里面。
但是上面的類的設計嚴格的講,就是違反了SRP原則。因為上面的兩個職能:保存業務類的信息和負責持久化數據。
需要增加或者修改一些數據訪問的方法,那么這個類就得不斷的改動,同理,業務類的流程的變更也改變數據訪問代碼雖在的類,應該把變化的點剝離出來.
用CustomerRepository來負責持久化Customer業務類的數據。這樣變化點就因為SRP原則就分離了。
這樣之后,ProcessOrder方法在加了新的處理流程之后,再次運行測試,只要測試不通過,那么可以肯定:流程代碼有問題。而且CustomerRepository隱藏數據的來源,幾乎沒有變化。
其實在我們傳統的設計方法中,對于”單一職責”的”渴望”還不是很明顯,因為如果改處理流程出了問題,debug進行看看就行了;在TDD的時候,因為加入了測試代碼,所以把業務流程代碼和數據訪問放在一起的設計讓測試代碼”感覺”到了一點點的迷惑:是流程問題還是別的問題?所以對“單一職責”的“渴望”稍微強了一點,這樣在設計時候,起碼就能夠改善一點點,有點“驅動好的設計”的意思。大家認為呢?
其實”單一職責”不僅僅使用在設計類上,在設計類的方法上也有參考價值,不能把一個方法設計的N復雜。最后還要提寫有關TDD的東西:
其實上面的那個測試寫的不夠好,因為我們測試成功的情況,也要測試失敗的情況。我們不能每次都去改測試代碼去替換數據。那么我們還不如直接設計兩個測試方法,如下:
Public void Test_OrderProcecss _Executed_Failed_With_InValidateData()
我們在單元測試的代碼中不要訪問數據庫,Web Service等外部的資源。例如在我們上面的CustomerRepository中,用它參與單元測試的時候,直接把數據hard code。運行單元測試是常常要運行的,如果用外部資源,如果因為網絡問題等導致測試失敗,就很容易把人搞迷惑:不清楚是功能失敗,還是其他的原因。
具體的我們以后再講述吧!
我是希望盡量把思考的過程通俗的講出來,所以顯得啰啰嗦嗦的!不知道大家是什么感受!希望大家反饋!
最后特別感謝 aohan提出的修改意見!
留言列表