聊聊單元測試

點點寒彬發表於2020-12-27

背景

關於單元測試,其實是我們討論的非常多的一點,作為一個測試人員,筆者唯一沒怎麼接觸的測試,其實就是單元測試。這段時間剛好在開發一些平臺,在程式碼中也涉及到了這塊,因此記錄一下自己的一些想法。

筆者用一個場景來說明一下思路。

開發一個查詢介面,接受頁面傳入的引數,再查詢配置服務獲取資料庫的配置資訊。最後拼成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。這樣做單元測試的成本確實是非常高,測試用例於環境強關聯,侷限性非常大,並且跟做整合測試幾乎沒有區別。

筆者眼中的單元測試

筆者眼中的單元測試應該有這麼幾點:

  1. 不跟任何環境繫結,任意一個環境都能執行
  2. 要能夠覆蓋程式碼中所有於外部呼叫之外的程式碼
  3. 外部依賴不使用Mock或者部署真實服務來處理,而是放棄,留給整合測試。

改動原則

這裡其實涉及到了程式碼的變動,爭議應該是非常大的,筆者這裡闡述自己的理解。

這個查詢功能大致是這樣:接收請求資料->檢查資料是否合法->查詢DB資訊->檢查返回資訊的合法->對資料做一定的轉換(生成SQL)->請求DB查詢->解析返回結果->返回結果做一定的處理->返回。

大致可以分成這10步,其中除去開頭的接受資料和返回結果,有8步。其中外部依賴的是2步,查詢db資訊請求db查詢。其他的步驟都是一些資料的轉換和處理。那麼程式碼應該把這些抽離出來作為單一功能的方法,這樣單純的資料處理的方法,就能夠不依賴任何環境從而進行單元測試驗證。

從這個思路來推導

  1. 請求資料合法性檢查沒問題,就可以保證沒有非法的引數進到流程中。
  2. 查詢配置中心返回的資料是合法的,就可以保證拼出來的SQL是正確的。
  3. 生成SQL的邏輯沒有問題,就可以保證請求db查詢的資料沒有問題。
  4. 查詢回來的資料結構轉換沒有問題,那麼返回的資料就不會有問題。

總結一下,就是通過這樣的拆分,確保了我們請求外部服務的時候,引數一定是按照約定傳的,如果有改動破壞了這個約定,單元測試就能發現。同樣,返回資料的解析處理也是按照約定處理的,如果有改動破壞了這個約定,單元測試也是能夠發現的。

如果有了這樣的保證,那麼外部服務是否真的去請求,實際上區別並不是特別大。

改動程式碼的結構

同樣用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%,對於業務不復雜(也就是資料處理部分少)的工程來說還是有難度的。這或許是另一個值得探討和學習的點。

相關文章