聊聊單元測試
背景
關於單元測試,其實是我們討論的非常多的一點,作為一個測試人員,筆者唯一沒怎麼接觸的測試,其實就是單元測試。這段時間剛好在開發一些平臺,在程式碼中也涉及到了這塊,因此記錄一下自己的一些想法。
筆者用一個場景來說明一下思路。
開發一個查詢介面,接受頁面傳入的引數,再查詢配置服務獲取資料庫的配置資訊。最後拼成SQL之後查詢結果返回。
一個常見的程式碼
筆者這裡用Go寫一個虛擬碼來演示,忽略那些特有的語法,相信單純看邏輯應該是沒問題的。
func GetSomething(c *Request) {
userName := c.Query("user_name")
page := c.Query("page")
size := c.Query("size")
if userName == ""{
return error
}
if page == 0{
return error
}
if size == 0{
return error
}
rsp, err := QryDbInfoFromConfigCenter()
if err != nil{
return err
}
database := rsp.Get("Database")
table := rsp.Get("Table")
if database == ""{
return error
}
if table == ""{
return error
}
sql := fmt.Sprintf("select * from %s.%s where username='%s' and offset %d limit %d", database, table, userName, page, size)
resp, err := DoQryInfo(tableAddr, sql)
if err != nil{
return err
}
if resp.Code != 0{
return error
}
for(i:=0;i<len(resp.Data);i++){
if resp.Data[i].status == 0{
resp.Data[i].nickStatus = "成功"
} else if resp.Data[i].status == 1{
resp.Data[i].nickStatus = "失敗"
}
}
return resp.Data
}
上面的程式碼是一個非常典型的寫法,這種線性的寫法幾乎存在於接觸的80%的程式碼中。毫無疑問它是能夠正常工作的,並且書寫也非常方便,整個流程符合正常的線性思維。
但是,如果要對這樣的程式碼去做單元測試,幾乎沒辦法進行單元測試。因為它的每個步驟都耦合在一起,如果要測試,就必須準備一個查詢db配置的服務,準備一個有資料的db。這樣做單元測試的成本確實是非常高,測試用例於環境強關聯,侷限性非常大,並且跟做整合測試幾乎沒有區別。
筆者眼中的單元測試
筆者眼中的單元測試應該有這麼幾點:
- 不跟任何環境繫結,任意一個環境都能執行
- 要能夠覆蓋程式碼中所有於外部呼叫之外的程式碼
- 外部依賴不使用Mock或者部署真實服務來處理,而是放棄,留給整合測試。
改動原則
這裡其實涉及到了程式碼的變動,爭議應該是非常大的,筆者這裡闡述自己的理解。
這個查詢功能大致是這樣:接收請求資料->檢查資料是否合法->查詢DB資訊->檢查返回資訊的合法->對資料做一定的轉換(生成SQL)->請求DB查詢->解析返回結果->返回結果做一定的處理->返回。
大致可以分成這10步,其中除去開頭的接受資料和返回結果,有8步。其中外部依賴的是2步,查詢db資訊
和請求db查詢
。其他的步驟都是一些資料的轉換和處理。那麼程式碼應該把這些抽離出來作為單一功能的方法,這樣單純的資料處理的方法,就能夠不依賴任何環境從而進行單元測試驗證。
從這個思路來推導
- 請求資料合法性檢查沒問題,就可以保證沒有非法的引數進到流程中。
- 查詢配置中心返回的資料是合法的,就可以保證拼出來的SQL是正確的。
- 生成SQL的邏輯沒有問題,就可以保證請求db查詢的資料沒有問題。
- 查詢回來的資料結構轉換沒有問題,那麼返回的資料就不會有問題。
總結一下,就是通過這樣的拆分,確保了我們請求外部服務的時候,引數一定是按照約定傳的,如果有改動破壞了這個約定,單元測試就能發現。同樣,返回資料的解析處理也是按照約定處理的,如果有改動破壞了這個約定,單元測試也是能夠發現的。
如果有了這樣的保證,那麼外部服務是否真的去請求,實際上區別並不是特別大。
改動程式碼的結構
同樣用Go的虛擬碼來寫這個改造後的程式碼。
type Rqst struct{
UserName string
Page int32
Size int32
}
type DatabaseInfo struct {
Database string
Table string
}
func NewRqst(c *Request) *Rqst{
var r = new(Rqst)
r.UserName := c.Query("user_name")
r.Page := c.Query("page")
r.Size := c.Query("size")
}
func(r Rqst)checkParam()error{
if r.UserName == ""{
return error
}
if r.Page == 0{
return error
}
if r.Size == 0{
return error
}
}
func (r Rqst)buildQrySQL(d *DatabaseInfo)string{
sql := fmt.Sprintf("select * from %s.%s where username='%s' and offset %d limit %d", d.Database, d.Table, r.UserName, r.Page, r.Size)
}
func NewQryRsp(rsp *QryResp) *DatabaseInfo{
var d = new(DatabaseInfo)
d.Database := rsp.Get("Database")
d.Table := rsp.Get("Table")
return d
}
func(d *DatabaseInfo)checkResp(){
if d.Database == ""{
return error
}
if d.Table == ""{
return error
}
return d
}
func checkQryDbResult(resp *DoQryInfoResp)error{
if resp.Code != 0{
return error
}
return nil
}
func setNickStatus(resp *DoQryInfoResp){
for(i:=0;i<len(resp.Data);i++){
if resp.Data[i].status == 0{
resp.Data[i].nickStatus = "成功"
} else if resp.Data[i].status == 1{
resp.Data[i].nickStatus = "失敗"
}
}
}
func GetSomething(c *gin.Context) {
r := NewRqst(c)
err := r.checkParam()
if err != nil{
return err
}
_rsp, err := QryDbInfoFromConfigCenter()
if err != nil{
return err
}
rsp := NewQryRsp(_rsp)
err = rsp.checkResp()
if err != nil{
return err
}
sql := r.buildQrySQL(rsp)
resp, err := DoQryInfo(tableAddr, sql)
err = checkQryDbResult(resp)
if err != nil {
return err
}
setNickStatus(resp)
return resp.Data
}
從改造的結果來看,程式碼變多了很多,主要就是更多的結構體的定義和方法宣告的程式碼,實際的業務程式碼來看是差不多的。
但是改造後的優點確非常的明顯,主流程GetSomething
中的程式碼更清晰簡單,閱讀的人可以很快的明白這個介面到底只做什麼的,而不需要完全讀懂這個程式碼。
同樣,改造後,每個方法都是可以單獨的寫對應的測試用例,而這樣寫出來的單元測試用例由於只是一些資料變動的處理邏輯,沒有涉及到外部的請求,因此是可以在任意環境執行,並且結果可靠有效,不會出現環境問題導致的用例失敗。
總結和一些思考
這樣的做法,實際上涉及到了程式碼結構的變動,個人認為這樣寫會更加優雅易讀,但是由於每個人的想法、思維方式等都不同,包括工作經歷,也會影響這些,因此關於程式碼優雅性這塊不做更多的討論,讀者可以保留自己的想法。
對於單元測試來說,筆者做過一部分程式碼改造來實踐這部分內容,發現效果還不錯,確確實實幫忙發現了一些問題,應該說,是具有一定的合理性的。
最後還有一點關於程式碼覆蓋率的,業界普遍的要求單元測試的覆蓋率是80%。按照筆者最近的實踐來看,如果你的程式碼沒有寫很多廢話或者廢邏輯,這麼幹要達到80%,對於業務不復雜(也就是資料處理部分少)的工程來說還是有難度的。這或許是另一個值得探討和學習的點。
相關文章
- 聊聊前端單元測試前端
- 測試 之Java單元測試、Android單元測試JavaAndroid
- 單元測試:單元測試中的mockMock
- 單元測試,只是測試嗎?
- 單元測試-【轉】論單元測試的重要性
- golang單元測試Golang
- 單元測試真
- iOS 單元測試iOS
- python 單元測試Python
- 前端單元測試前端
- Flutter 單元測試Flutter
- 單元測試 Convey
- 單元測試工具
- 十五、單元測試
- Go單元測試Go
- SpringBoot單元測試Spring Boot
- 前端測試:Part II (單元測試)前端
- Junit單元測試—MavenMaven
- 單元測試框架 mockito框架Mockito
- 單元測試與MockitoMockito
- Source Generator 單元測試
- 單元測試?即刻搞定!
- 單元測試基礎
- JavaScript單元測試框架JavaScript框架
- 單元測試 -- mocha + chaiAI
- React元件單元測試React元件
- Spring Boot 單元測試Spring Boot
- Vue單元測試探索Vue
- Google 單元測試框架Go框架
- 測試氣味-整潔單元測試
- Go 單元測試之mock介面測試GoMock
- 單元測試 - 測試場景記錄
- 單元測試-一份如何寫好單元測試的參考
- 測試開發之單元測試-禪道結合ZTF驅動單元測試執行
- 測試夜點心:單元測試測什麼
- Flutter 學習之路 - 測試(單元測試,Widget 測試,整合測試)Flutter
- 程式碼重構與單元測試——重構1的單元測試(四)
- Vue 應用單元測試的策略與實踐 04 - Vuex 單元測試Vue