Working With Legacy Code ========== 一個系統中的業務流程,往往會與多個物件有所關聯. ![](https://i.imgur.com/ZWlnIB5.png) ```Controller的action handler依賴service物件, service又依賴repository物件, repostiory物件又依賴database.``` 要做單元測試前,可能必須要先準備好這些所有物件, 然後把一群物件都放入測試當值中,但它們彼此依賴又環環相扣. 要測試handler, 卻事先要準備好service, service又需要先準備好repository, repository又需要db; 所以測試一個物件, 要先準備好除了自己之外的QQ ## 單元測試的前提 1. 快速的定位出錯誤點 2. 花費的時間夠快,需要的測試環境不需太複雜(不需要準備好DB, SMTP...) ## 感測 & 分離 ### 感測Sensing 適用場景: ```We can't access values our code computes``` Solution: ```break dependencies to sense the value``` ### 分離Separation 適用場景: ```we can't even get a piece of code into a test harness to run``` Solution: ```break dependencies to separate the code``` ## Dependency-Breaking Techniques 解依賴的三個基本技巧 ![](https://i.imgur.com/fHkaJ9F.png) ### 參數化方法Parameterize Method ```go= func Method1() { mResult := new(Result); mReuslt.xxxx(); .... } ``` 這樣子Method1高度耦合於Result物件, 我們無法感測到Result的值. 可以選擇```參數化方法```來讓Method1來感測到Result的值. 白話文, Result的值與內容是我們可以完全掌控的. ```go= func Method1(mResult Result) { mReuslt.xxxx(); .... } ``` ### 參數調配Adapt Parameter 當無法對一個引述的型別使用介面提取, 或者該引述難以偽裝時. ```go= type ARMDispatcher struct { marketBinding []string } func NewARMDispatcher() *ARMDispatcher { return &ARMDispatcher{ marketBinding = make([]string, 0) } } //HttpsParameters 是原生的Http參數型別, 難以偽裝與測試; // 且過程中還需要感測其值 func (a *ARMDispatcher) Populate(parameters HttpsParameters) { var values []string = parameters.GetCipherSuites() if values != nil && len(values) > 0 { a.marketBinding = append(a.marketBinding, values[0]) } } ``` 1. 替Populate用到的HttpsParameters類型新增interface, 且簡單能表達意圖就好 2. 為新interface建立一個產品程式碼的實現類型 3. 為新interface建立一個測試程式碼的偽造類型 4. 寫一個簡單測試用例, 將偽裝物件傳給該目標方法 5. 修改原來方法使其能使用新的引數型別 6. 執行測試, 確保work ```go= type ParameterSource interface { GetParameterValue() string } type HttpParameterSource struct { mHttpsParameters HttpsParameters } func NewHttpParameterSource(httpsParameters HttpsParameters) *HttpParameterSource { return &HttpParameterSource{ mHttpsParameters = httpsParameters } } func (p *ParameterSource) GetParameterValue() string { var values []string = p.mHttpsParameters.GetParameterValue() if values != nil && len(values) > 0 { return values[0] } return ""; } ``` ```go= type ARMDispatcher struct { marketBinding []string } func NewARMDispatcher() *ARMDispatcher { return &ARMDispatcher{ marketBinding = make([]string, 0) } } func (a *ARMDispatcher) Populate(parameterSource ParameterSource) { var values string = parameters.GetParameterValue() if values != nil { a.marketBinding = append(a.marketBinding, values) } } ``` ```go= type FakeParameterSource struct { values string } func NewFakeParameterSource(values string) *FakeParameterSource { return &FakeParameterSource{ values = values } } func (p *FakeParameterSource) GetParameterValue() string { return values } func TestARMDispatcherPopulate(t *testing.T) { armDispatcher := &ARMDispatcher{} fakeParameterSource := NewFakeParameterSource("Hello World") armDispatcher.Populate(fakeParameterSource) assert.Equal(t,1, len(armDispatcher.marketBinding) ) assert.Equal(t, "Hello World", armDispatcher.marketBinding[0]) } ``` Adapt Parameter通常都會修改道原來的簽章, 為了把意圖通用化. https://gunnarpeipman.com/refactoring-adapt-parameter/ ### 介面提取Extract Interface Extract Interface並沒有要一次提取依賴類別上所有Public methods.(記得SOLID中的ISP嗎) 可以考慮一步一步地提取出所需要的. ```go= type PaydayTransaction struct { transactionLog *TransactionLog } func NewPaydayTransaction(transactionLog *TransactionLog) *PaydayTransaction { return &PaydayTransaction{ transactionLog : transactionLog } } func (p *PaydayTransaction) Run() { p.transactionLog.SaveTransaction() } type TransactionLog struct { } func NewTransactionLog() *TransactionLog { return &TransactionLog{} } func (p *TransactionLog) SaveTransaction() { // call db } func (p *TransactionLog) RecordError(code int) { // log error } ``` 1. 建立一個新interface, 取有意義的命名, 暫時別加入任何方法 2. 建立一個目標類別來實作該interface 3. 把想使用偽造物件的地方, 通通從原來類別,改成引用這新interface 4. 按下編譯, 編譯器會說缺少什麼方法, 再慢慢新增對應方法 ```go= type ITransactionRecorder interface { SaveTransaction() } ``` ```go= type PaydayTransaction struct { transactionRecorder ITransactionRecorder } func NewPaydayTransaction(transactionRecorder ITransactionRecorder) *PaydayTransaction { return &PaydayTransaction{ transactionRecorder : transactionRecorder } } func (p *PaydayTransaction) Run() { p.transactionRecorder.SaveTransaction() } ``` ```go= type FakeTransactionLog struct { IsSave bool } func NewFakeTransactionLog() ITransactionRecorder{ return &TransactionLog{ IsSave : false } } func (p *FakeTransactionLog) SaveTransaction() { // do something p.IsSave = true } func (p *FakeTransactionLog) RecordError(code int) { // log error } func TestPayday(t *testing.T) { fakeTransactionLog := NewFakeTransactionLog() paydayTransaction := NewPaydayTransaction(fakeTransactionLog) paydayTransaction.Run() assert.Equal(t,true, fakeTransactionLog.IsSave ) } ``` https://docs.microsoft.com/zh-tw/visualstudio/ide/reference/extract-interface?view=vs-2019 其他好用技巧 但不是物件導向語言未必能實作 ### Extract and Override ### Pull Up Feature 1. Pull Up Field 2. Pull Up Method 3. Pull Up Constructor Body ### Push Down Dependency 1. Push Down Method 2. Push Down Field https://refactoring.guru/push-down-method https://faculty.csbsju.edu/jschnepf/CS230/Slides/Refactoring.pdf # Test Doubles Test doubles : 一個概念名詞, 描述測試中所有non-production ready的依賴物件. 讓依賴物件看起來的樣貌跟行為非常相似release-intended的版本, 但內容裡卻是簡化非常多的版本. Test doubles有5種, 但大致區分成兩大類 ![](https://i.imgur.com/EPVfoXu.png) - Stub : 協助模擬資料的交互與感測 - Mock : 有助於模擬與檢查, 其交互對象的內部狀態(呼叫次數,執行多久,非同步的執行狀態...) ![](https://i.imgur.com/oLU0ZzJ.png) ![](https://i.imgur.com/DWE0HvW.png) [reference](https://enterprisecraftsmanship.com/posts/when-to-mock/) [reference2](https://www.softwaretestingmagazine.com/knowledge/unit-testing-fakes-mocks-and-stubs/)