測試用例是開發人員最後一塊遮羞布

測試用例是開發人員最後一塊遮羞布

最近一週寫一個比較複雜的業務模塊,越寫到後面真心越心虛。操作越來越複雜了,代碼也逐漸凌亂了起來。比如一個接口,傳入的是一個比較複雜的大json,我需要解析這個大json,然後根據json中字段進行增刪改查,調用第三方服務等操作。

告訴前端接口已經完成的時候,總是有點沒有底氣。說實話,在寫PHP的時候,我確實很少寫單元測試,大都是對著頁面進行一波一波的測試,現在想想,一個是懶,還有一個是確實PHP是不需要編譯的語言,沒有編譯時間,測試-修正,整個流程非常短。

但是這次是一個比較大的GoLang項目,如果還是按照“編譯-起服務-調用-調整代碼-編譯-起服務-調用-...” 這種循環來做調試,真是會瘋了的。所以我能靜下心好好研究研究如何寫Golang的單元測試了。

數據庫怎麼辦?

這個是第一個需要思考的問題。這個問題和語言無關。一旦有數據庫操作,就需要考慮如何在測試用例中如何處理數據庫操作。我想了想,無外乎兩種做法,一種是直接mock數據庫的返回對象。另外一種,是搭建一個測試DB,然後灌入假數據,進行測試。這兩種方式我選擇了後一種。有幾個理由:首先,mock數據庫返回數據是一個比灌入DB數據更為複雜的邏輯,數據庫返回的數據根據sql各種各樣,要想在每個環節都寫好數據庫操作返回,倒不如我直接偽造一些數據來的方便。其次,mock數據庫返回會丟失model層的測試邏輯,當然如果你是輕model層,整個model就只有一個orm,這個可能就不是理由了。

所以,我操作的第一步,從線上把數據庫表結構copy一份到我本地vagrant的mysql中。

這裡必須要注意,你的測試數據庫和測試代碼最好是同一個機器上,否則每跑一個測試用例,消耗的時間非常大,你的測試體驗也不會太好。

第三方請求怎麼辦?

我的代碼邏輯中也有一些第三方調用,調用其他服務。當然這裡也有同樣的兩種辦法,一種是直接在本地測試環境搭建第三方服務,另外一種是mock第三方服務的返回數據。這裡我選擇了mock數據的方式。基本想法是因為我這個測試畢竟不是一種全鏈路測試,測試的主體還是我的服務,我的服務基本上只包含服務+DB,如果要搭建第三方服務,這就有點捨本逐末的感覺了。

如何mock第三方服務呢?

查了下golang中mock的包有兩個比較出名,一個是golang官網出品的golang/mock,另外一個是monkey(https://github.com/bouk/monkey)。兩個相比之下,我感覺golang/mock是師出有名,但是不如monkey好用,monkey屬於黑科技,使用修改函數指針的方式進行mock函數。我想了想,實用第一位,投入了monkey的懷抱。

基本使用代碼如下:

 // mock路網接口
guard := monkey.Patch(lib.Curl, func(trace *lib.TraceContext, CurlType, urlString string, data url.Values, addToken bool) ([]byte, error) {
return []byte("{[\\"10010\\":\\"後廠村路\\"}"]), nil
})
defer guard.Unpatch()

將lib.Curl整個函數給mock了,並且在函數結束後修改mock的函數,保證不影響其他測試用例。

測試用例是開發人員最後一塊遮羞布

配置文件怎麼辦?

web服務一般都會有讀取配置的代碼,我的服務是讀取一個參數config=base.json來進行配置的讀取的。go test中是沒有辦法給test的代碼傳遞參數的,(我看網上的一些文章說有個-args的參數,但是我在go1.11版本中確實沒有看到這個參數)。於是我只能選擇使用環境變量的方式。在運行go test的時候,在最開頭的部分設置下當前這個go test的環境變量CONFIG_PATH,然後修改下我的初始化配置文件的代碼,允許傳入參數進行配置文件的讀取。

大概代碼如下:

在運行go test的時候設置環境變量:

CONFIG_PATH=/home/vagrant/foo/conf/yejianfeng/base.json go test foo/signaledit/... -v -test.run TestGetGroups
\t測試環境的初始化配置文件邏輯:
package test
import (
..
)
var HasSetup = false
// signalEdit初始化,只調用一次
func SetUpSignalEdit() {
if HasSetup == false {
gin.SetMode(gin.TestMode)
confPath := os.Getenv("CONFIG_PATH") // 獲取環境變量
commonlib.Init(confPath, "") // 初始化配置文件
conf.ParseLocalConfig()
db.InitDB()

HasSetup = true
}
DestroyTestData(db.EditDB)
CreateTestData(db.EditDB)
}

web怎麼進行單元測試?

關於這個,httptest這個包提供給我們想要的邏輯了,網上的文章也一大堆了。使用起來也是很方便,

 router := gin.New()
jc := Controller{}
// 燈組模型表獲取信息
router.GET("/group/all", jc.GroupAll)
...
//構建返回值
w := httptest.NewRecorder()
//構建請求
r, _ := http.NewRequest("GET", "/group/all?logic_junction_id=test_junction", nil)
//調用請求接口
router.ServeHTTP(w, r)
resp := w.Result()
body, _ := ioutil.ReadAll(resp.Body)

就沒有什麼好說的了。

關於數據初始化和銷燬

既然我選擇使用本地DB進行測試,那麼按照邏輯,需要在測試用例開始初始化DB數據,然後在測試用例結束後銷燬數據。這裡我還選擇在測試用例開始的時候,先銷燬數據,然後初始化數據,測試用例結束的時候不要銷燬數據。這樣做我承認有不好的地方,就是有可能會有髒數據。比較好的地方,就是我在單個測試用例跑完的時候,我有機會去數據庫看一眼現在數據庫裡面的測試數據是什麼樣子。

不管怎麼洋,數據初始化和銷燬的工作就變得異常重要了,它們必須是冪等,而且可以循環冪等。(銷燬-初始化)=(銷燬-銷燬-初始化)=(初始化-銷燬-初始化)。要做到這個我的感受必須藉助具體的業務數據表邏輯了。比如我的所有數據表都有一個路口id的字段,那麼我就很容易做到銷燬的冪等,我每次銷燬的時候,就只要把這個路口的所有數據刪除就可以了。如果沒有的話,由於我們的數據庫是本地數據庫,不妨採用整個數據表清空的方式操作。

數據初始化和銷燬的函數我封裝成兩個函數,放在一個包裡面

 var (
SignalID = int64(999999)
LogicJunctionId = "test_junction"
)
// 創建測試數據
func CreateTestData(db *gorm.DB) {
// SignalInfo表創建一條數據
signalInfo := &models.SignalInfo{}
signalInfo.Id = SignalID
signalInfo.Name = "測試路口id"
signalInfo.LogicJunctionId = LogicJunctionId
signalInfo.Status = 1
db.Create(signalInfo)
}

// 銷燬測試數據
func DestroyTestData(db *gorm.DB) {
db.Delete(&models.SignalInfo{}, "logic_junctionid=" + LogicJunctionId)
...
}
然後把上面說的初始化操作封裝成一個函數

var HasSetup = false
// signalEdit初始化,只調用一次
func SetUpSignalEdit() {
if HasSetup == false {
gin.SetMode(gin.TestMode)
confPath := os.Getenv("CONFIG_PATH")
commonlib.Init(confPath, "")
conf.ParseLocalConfig()
db.InitDB()
HasSetup = true
}
DestroyTestData(db.EditDB)
CreateTestData(db.EditDB)
}
所有測試用例都先調用下這個函數
 func TestGetGroups(t *testing.T) {
test.SetUpSignalEdit()
...
}

這裡真心要吐槽下testing框架,既然做了測試框架,SetUp函數,SetDown函數這些都不考慮,和主流的測試框架的思想真的有點偏差,導致像這種“普通”的初始化的需求都要自己寫方法來繞過,至少testing框架為應用思考的東西還是太少了。

測試用例是開發人員最後一塊遮羞布

測試用例的粒度

我一直知道寫好測試用例是一個難度不亞於開發的工作。測試用例有粒度問題,我覺得,測試用例的粒度宜大不宜小。我這個項目是controller-service-mmodels架構,controller一個函數就是一個接口,service一個函數是一個通用性比較高的服務,model是比較瘦的model,基本只做增刪改查。在我這個架構中,我寫的測試用例粒度大多數是controller級別的,有少數是service級別的,model級別的測試用例基本沒有。

測試用例粒度大一些,有個明顯的好處,就是對需求的容忍度高了很多。一般測試用例最痛的就是需求一旦修改了,我的業務邏輯就修改了,我的測試用例也要跟著修改。修改測試用例是很痛苦的事情。所以如果測試用例足夠大,比如和接口一樣大,那麼基本上,由於業務接口的兼容性要求,我們的測試用例的輸入輸出一般不會進行大的變動(雖然裡面的service或者model會進行比較大的變動)。這樣有一些需求變化了之後,我甚至不需要修改任何測試用例的代碼就可以。

當然有的測試用例粒度太大,一些小的分支可能就測試不到,或者很難構建測試數據,所以有的時候,還是需要一寫稍微小一點的粒度的測試用例。

另外對於不需要依賴測試數據的類庫函數,如果你對這個類庫函數的輸入輸出的需求變更有把握控制的話,(你需要對自己的這個判斷負責)這種類庫函數的測試用例則是越細越好。

其他原則性的東西

說說寫測試用例的一些原則性的東西。

檢驗邏輯抗需求變更能力越強越好

首先,測試用例的檢驗邏輯不是越全越好,而且有很多技巧。比如一個插入的接口,你測試是否插入成功,有很多時候,你根據判斷插入條數是否多一條會比你判斷這個插入條數的所有字段是否是你要求的更好。原則還是那個,測試用例的抗需求變更能力會更高,首先基本上如果我的插入邏輯很簡單,那麼插入成功就約等於插入的每個字段都滿足,當然這裡是約等於,但是因為業務代碼也是我自己寫的,心裡這個B數還是有的。然後,如果一旦需求變更我這個數據多了一個字段,那麼我這個測試用例基本不需要做任何修改就還可以繼續跑起來。

再次強調下,這裡的約等於的判斷就是看你對你業務代碼的感覺了。

並不是所有的錯誤都需要完美處理

測試代碼畢竟不像業務代碼那麼需要完美的嚴謹,所有的panic都是歡迎的。換句話說,我們業務代碼基本上對所有error都需要有所處理,但是測試用例並不一定了。如果我在上一行代碼中沒有處理這個error,那麼我傳遞給下一行的參數很可能就是nil,很有可能在下一行代碼中直接panic了一個錯誤出來。這個也能讓我發現我的錯誤。

所以,測試用例並不需要寫那麼嚴謹,有的地方直接panic錯誤也是一個很好的選擇。

Fatal和Error的選擇

基本上我覺得Error沒啥用,我目前的測試用例都要求所有的判斷節點都跑成功,任何一個地方失敗了,直接就報錯進行調試。我的精力也不允許我一次性能處理多個錯誤case,基本上調試失敗的測試用例是一個個調試的,所以error並沒有什麼用。

這點純粹我個人觀點,估計會有很多人不同意。

檢驗邏輯多用變量

檢驗邏輯儘量少用 response.Name == "測試路口" 這種代碼,能儘量找到替換"測試路口" 這個的變量儘量使用變量,同樣的理由,測試用例的抗需求變更能力會更高。

總結

測試用例是開發人員最後一塊遮羞布,寫Golang的代碼和寫PHP的代碼確實體驗完全不一樣,在Golang代碼中,首先寫測試用例異常方便了。其次,Golang的調試成本遠遠高於PHP,寫測試用例看起來是浪費時間,實際上是節省你的調試時間。最後,golang代碼的每次重構(增加一個字段,少一個字段)影響的文件數遠遠高於PHP,如果沒有這塊遮羞布,你怎麼確保你的代碼修改後還能正常運行呢?


分享到:


相關文章: