讓你的 Backend 萬物皆虛,萬事皆可測 - Clean Architecture 測試篇
讓你的 Backend 萬物皆虛,萬事皆可測 - Clean Architecture 測試篇

讓你的 Backend 萬物皆虛,萬事皆可測 - Clean Architecture 測試篇

Tags
Kubernetes
ithome 2020 ironman
Clean Architecture
Date
Sep 22, 2020
本文章同時發佈於:
文章為自己的經驗與夥伴整理的內容,設計沒有標準答案,如有可以改進的地方,請告訴我,我會盡我所能的修改,謝謝大家~

大家好,繼昨天DAY07的介紹後,Clean Architecture 的威力大家已經見識到了,我們有著獨立可替換的彈性架構,那還有沒有什麼優點呢?有的,就是:
高可測試(Testability)性

一個簡單的測試

你可能像我一樣曾經寫過這樣的程式碼:
// ... 其他程式碼 import "service" func GetName() int { nameService := service.NewNameService() result := nameService.GetName() // ... 關於result的演算 return result } // ... 其他程式碼
如果有天要 unit test result的演算,必須要使nameService.GetName()會回傳固定值,而不是真的打到外部 API。
因為這樣如果測試出錯,我們才能精確的說是演算錯誤了,而非外部 API 出事了。
使nameService.GetName()回傳固定值的做法稱為mock,就是將nameService.GetName()替換成一個會回傳固定值的替身
但剛剛得程式碼,我們根本沒有機會進行替換動作,因為nameServiceGetName裡頭產生,其實我們只要做一個簡單的小動作,就可以避免這個事情發生,就是:
將所需的變數、實體、函示從外部帶入
package logic import "service" func GetName(nameService *service.Engine) int { result := nameService.GetName() // ... 關於result的演算 return result } // ... 其他程式碼
那麼我們就可以從外部做一個假的nameService丟入了,
// ... 其他程式碼 import ( "logic" "mock" "github.com/stretchr/testify/assert" ) func TestGetName(t *testing.T) { mockNameService := mock.NewNameService() result := logic.GetName(&mockNameService) assert.Equal(t, 3, result) }
摁…這不就單純只是不要把東西寫死在 function 中嗎?
對,其實這就是依賴注入(DI)的精神之一,只不過為了要避免爆炸,所以又需要 interface 去告訴不同 caller,此參數有哪些 function,這導致其他的理論產生。

如果是這樣的話那我不就每層都可以測試了?!

是的,因為:
Clean Architecture 每層都用依賴注入(DI),所以你每層都可以換成 Mock 實體來測試
接下來我們將說明每層測試的重點第一次測試可能會困惑的地方
以下的 code 全部都在Github-DAY07,可以直接 clone 下來參考對照會比較有感覺。

Repository 層

// ... 其他程式碼 import ( "context" "testing" digimonPostgresqlRepo "go-server/digimon/repository/postgresql" "github.com/stretchr/testify/assert" sqlmock "gopkg.in/DATA-DOG/go-sqlmock.v1" "go-server/domain" ) func TestGetByID(t *testing.T) { // !!! 講解1 !!! db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } // !!! 講解2 !!! mockDigimon := &domain.Digimon{ ID: "69770f2d-933e-474d-8357-a2f8a9c874df", Name: "Customer", Status: "Good", } // !!! 講解3 !!! rows := sqlmock.NewRows([]string{"id", "name", "status"}). AddRow(mockDigimon.ID, mockDigimon.Name, mockDigimon.Status) // !!! 講解4 !!! query := "SELECT id, name, status FROM digimons WHERE id =?" // !!! 講解4 !!! mock.ExpectQuery(query).WithArgs("69770f2d-933e-474d-8357-a2f8a9c874df").WillReturnRows(rows) // !!! 講解5 !!! d := digimonPostgresqlRepo.NewpostgresqlDigimonRepository(db) aDeit, _ := d.GetByID(context.TODO(), "69770f2d-933e-474d-8357-a2f8a9c874df") // !!! 講解5 !!! assert.Equal(t, mockDigimon, aDeit) } // ... 其他程式碼
測試重點: 呼叫外部物件的方式是否正確,並非測試外部物件
  • 講解1 - go-sqlmock製作一個假的mock DB: 如重點所說,我們並不關心外部 DB 的實際狀況,我們只關心組出來的SQL command是否正確,所以可以用 go-sqlmock 做一個假的 DB。
  • 講解2 - 產生在mock DB需要的假資料: 既然要測試的是GetByID的邏輯,那就必須先產生一組假的資料,讓GetByID真的有資料可以撈取。
  • 講解3 - 將假資料轉換成row的struct: 講解2產生的資料是domain.Digimon的 data struct,我們必須把它轉換成 row 的 data struct,才會符合真實對 DB 取 row 的狀況。
  • 講解4 - 預定mock DB會接收到何種SQL command: 這邊是重點,我們還必須預定好會回傳的值,mock DB 在接收到正確的 command 就會回傳預定的回傳值。
  • 講解5 - 把 mock DB 丟入後 Repository 層,驗證結果是否正確
第一次測試時你可能會困惑:「我都把 DB 做成假的了,那我在測試什麼?」,答案是SQL command是否組對
不論你用 gorm, SQL command,當傳進來的參數進行一連串運算後,最後一定會變為一個 SQL command,所以先設定好 mock DB要接受什麼樣的command接收正確之後要回傳什麼是測試目的,並且最後可以用回傳的值來驗證是否是我們一開始設定好的來驗證。

Usecase 層

// ... 其他程式碼 import ( "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "errors" ucase "go-server/digimon/usecase" "go-server/domain" "go-server/domain/mocks" ) func TestGetByID(t *testing.T) { // !!! 講解1 !!! mockDigimonRepo := new(mocks.DigimonRepository) mockDigimon := domain.Digimon{ ID: "2e72c27e-0feb-44e1-89ef-3d58fd30a1b3", Name: "Metrics", Status: "Good", } // !!! 講解2 !!! t.Run("Success", func(t *testing.T) { // !!! 講解3 !!! mockDigimonRepo. On("GetByID", mock.Anything, mock.MatchedBy(func(value string) bool { return value == "e9addf2d-8739-427a-8b30-2383b9b045b1" })). Return(&mockDigimon, nil).Once() // !!! 講解4 !!! u := ucase.NewDigimonUsecase(mockDigimonRepo) aDigimon, err := u.GetByID(context.TODO(), "e9addf2d-8739-427a-8b30-2383b9b045b1") // !!! 講解5 !!! assert.NoError(t, err) assert.NotNil(t, aDigimon) mockDigimonRepo.AssertExpectations(t) }) t.Run("Fail", func(t *testing.T) { mockDigimonRepo. On("GetByID", mock.Anything, mock.MatchedBy(func(value string) bool { return value == "e9addf2d-8739-427a-8b30-2383b9b045b1" })). Return(nil, errors.New("Get error")).Once() u := ucase.NewDigimonUsecase(mockDigimonRepo) aDigimon, err := u.GetByID(context.TODO(), "e9addf2d-8739-427a-8b30-2383b9b045b1") assert.Error(t, err) assert.Nil(t, aDigimon) mockDigimonRepo.AssertExpectations(t) }) } // ... 其他程式碼
測試重點: 業務邏輯路線是否預期,並非測試 Repository 層
Usecase 層沒有像 go-sqlmock 這樣通用的 mock 套件可以使用,但是還記得有 Domain 層定好的 interface 嗎?
我們可以使用mockery依照定好的interface生產出mock物件,依照mockery安裝好後:
$ cd go-server/domain $ mockery --all --keeptree
notion image
此時會多出 mocks 資料夾,這就是mock data struct
  • 講解1 - 透過mocks資料夾產生mockDigimonRepo
  • 講解2 - 依照不同情境區分測試: Usecase 層比較會有 不同的程式路線產生,例如:「數碼蛋參數如果沒帶的話、如果 DB 爆掉的話等等」,所以我們可以依照這些情境來區分測試
  • 講解3 - 設定mockDigimonRepo預定接收的參數與回傳的資料: 這邊是最有趣的部分,你可以透過.On()來定義 mockDigimonRepo 在 Usecase 層裡被呼叫的時候預定要接收什麼,並且回傳資料供邏輯繼續使用。
  • 講解4 - 將mockDigimonRepo丟入Usecase層並實際運行
  • 講解5 - 驗證Usecase回傳資料與mockDigimonRepo運行的結果: .AssertExpectations要稍微注意一下,他是在驗證.On()是否真的有被 call 到。
講解3處,有著強大的功能:
  • .On(): 預期要呼叫什麼 function。
  • mock.MatchedBy(): 預期參數要長什麼樣子。
  • .Return(): 指定回傳值。
  • .Once(): 將此.On效果只作用一次,這可以使你設定同個function不同次的.On都有不同結果。
  • .Run(): 雖然沒有出現在此測試,但非常有用。Golang 有者許多bind(&body)的指標綁定資料方式,這不是一進一出的方式,所以無法用.Return()來指定結果。而.Run()可以捕捉bind(&body)中的body指標位置,並且修改其內容,以達到指定結果的效果
這使業務邏輯所有的路線都可測試到,真的是非常有趣 XD。

Delivery 層

import ( "bytes" "encoding/json" "go-server/domain" "go-server/domain/mocks" "net/http" "net/http/httptest" "testing" digimonHandlerHttpDelivery "go-server/digimon/delivery/http" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func TestGetDigimonByDigimonID(t *testing.T) { mockDigimon := domain.Digimon{ ID: "8c862535-6de2-4da2-ad21-c853e4343bd7", Name: "III", Status: "good", } mockDigimonMarshal, _ := json.Marshal(mockDigimon) mockDigimonCase := new(mocks.DigimonUsecase) mockDigimonCase.On("GetByID", mock.Anything, mockDigimon.ID).Return(&mockDigimon, nil) r := gin.Default() handler := digimonHandlerHttpDelivery.DigimonHandler{ DigimonUsecase: mockDigimonCase, } r.GET("/api/v1/digimons/:digimonID", handler.GetDigimonByDigimonID) // !!! 講解1 !!! w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/digimons/8c862535-6de2-4da2-ad21-c853e4343bd7", nil) r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) assert.Equal(t, string(mockDigimonMarshal), w.Body.String()) }
測試重點: Delivery 層是否都正確接收了引擎的參數,並非測試 Usecase 層
Delivery 層與 Usecase 層概念相同(事實上每層都相同),都是創造上一層的 mock 實體,並專注測試此層的邏輯。
要注意的地方是,此測試並非真的起了一個 Server 以外部打 API 近來的方式測試。
而是在講解1處之前設定好了 router 後,透過httptest.NewRecorder()產生一個 request,以r.ServeHTTP(w, req)的方式丟入 Golang-Gin 的引擎中。

好用的測試指令

一次跑全部專案的測試,你可以把此指令放上 CI,這樣就可以在每次 CD 之前來測試一下!
$ cd go-server $ go test ./...
notion image
(cmd/main.go:31:3可以不必理會,那是因為讀取.env失敗的問題,但跟測試無關)
但是否我們程式中有沒測到的路線呢?這也是可以靠 Golang 自動偵測的,運行以下指令:
$ cd go-server $ go test -coverprofile cover.out ./... $ go tool cover -html=cover.out -o cover.html $ open cover.html
最後會開啟此 html file,
notion image
可以看到上方可以顯示測試覆蓋率(就是路線覆蓋率),而下方會顯示沒測試到的 code,太神奇啦!

參考