重構 Go 程式碼以避免在單元測試中進行檔案 I/O
原文由 Matthias Endler 于 發布,訂閱此部落格
今天在工作時,我重構了一段簡單的 Go 程式碼,讓它更容易測試。想法是透過將資料的輸入/輸出與資料處理分開,在不使用 mock 或暫存檔的情況下,避免在單元測試中處理檔案。
來源:插圖由 Marcus Olsson CC BY-NC-SA 4.0
我很驚訝在 StackOverflow 這類網站上找不到簡單的說明,所以自己寫了一些筆記,讓以後有需要的人可以參考。
範例程式碼
最初的版本長這樣:
package main
import (
"bufio"
"io/ioutil"
"os"
)
func main() {
analyze("test.txt")
}
func analyze(file string) error {
handle, err := os.Open(file)
if err != nil {
return err
}
defer handle.Close()
scanner := bufio.NewScanner(handle)
for scanner.Scan() {
// Do something with line
_ = scanner.Text()
}
return nil
}如你所見,我們以檔名作為輸入,然後在 analyze 函式內開啟該檔案並對其內容做一些處理。
為程式碼撰寫第一個測試
針對這段程式碼,典型的測試程式可能會長這樣:
package main
import "testing"
func Test_analyze(t *testing.T) {
t.Run("Test something", func(t *testing.T) {
if err := analyze("test.txt"); (err != nil) != false {
t.Errorf("analyze() error = %v", err)
}
})
}看起來沒什麼問題?
問題在哪裡
這樣做雖然可行,但在測試中執行檔案 I/O 並不總是個好主意。原因之一是,你可能在受限的環境中執行測試,根本無法存取該檔案。我們可以用 暫存檔 來避開這個問題。
但磁碟 I/O 本身也可能出問題,導致測試不穩定、讓人感到挫折。
另一個行程也可能在測試期間修改該檔案。這些問題都與你的程式碼本身無關。
再者,光看測試本身無法完全了解發生了什麼事,你還得先去讀那個文字檔的內容。
很多人會建議改用 mock。為此有不少強大的函式庫,例如 spf13/afero。這些套件會在背景建立暫存檔,並在事後自動清理。
在我看來,mock 應該是測試時的最後手段。在考慮 mock 之前,先檢查程式碼是否用了正確的抽象。也許針對介面來實作,或使用依賴注入(Dependency Injection)就能幫助元件解耦?很多時候,只要做到清晰的關注點分離就夠了。
重構以讓測試更簡單
以上面的例子來說,我們可以輕鬆地透過將檔案 I/O 與分析邏輯解耦,來避免使用 mock 和暫存檔。做法是重構 analyze 函式,讓它去呼叫 doSomething,而 doSomething 接受一個 io.Reader 作為參數。(目前你也可以先用字串陣列來代替。)
現在我們的 main.go 長這樣:
package main
import (
"bufio"
"io"
"os"
)
func main() {
analyze("test.txt")
}
func analyze(file string) error {
handle, err := os.Open(file)
if err != nil {
return err
}
defer handle.Close()
return doSomething(handle)
}
func doSomething(handle io.Reader) error {
scanner := bufio.NewScanner(handle)
for scanner.Scan() {
// Do something with line
_ = scanner.Text()
}
return nil
}現在我們可以單獨測試真正的分析邏輯:
package main
import (
"strings"
"testing"
)
func Test_analyze(t *testing.T) {
t.Run("Test something", func(t *testing.T) {
if err := doSomething(strings.NewReader("This is a test string")); (err != nil) != false {
t.Errorf("analyze() error = %v", err)
}
})
}我們把 analyze("test.txt") 改成了 doSomething(strings.NewReader("This is a test string"))。(當然,我們也應該為 analyze() 另外寫一個測試,但這裡的重點是將與資料來源無關的部分解耦。)
成果
透過稍微重構程式碼,我們獲得了以下好處:
- 測試簡單:不需要 mock 或暫存檔。
- 關注點分離:每個函式只做一件事。
- 更容易重複使用程式碼:
doSomething()函式可以搭配任何io.Reader使用,也能在其他地方呼叫。如果需要,甚至可以把它移到獨立的函式庫中。
在 Reddit 上,使用者 soapysops 提出了一個 重要的觀點:
一般來說,我偏好不在 API 中接受檔名。檔名無法給使用者足夠的控制權。舉例來說,它不讓你使用特殊的編碼、特別的檔案權限,或是用 bytes.Buffer 來代替實際的檔案。接受檔名會讓程式碼多出一個巨大的依賴:檔案系統,以及所有與作業系統相關的細節。
所以我大概會直接移除以檔名為基礎的 API,只保留以 io.Reader 為基礎的版本。這樣就能擁有完整的程式碼涵蓋率、快速的測試,以及少得多需要擔心的邊界情況。
我完全同意這個看法。
但很多時候你 無法輕易改變面向使用者的 API,因為該 API 可能是公開的,而且已經有使用者在用了。上面的重構只是 邁向更好架構的第一步。要開始在 Go 中撰寫穩健且經過良好測試的系統,還有很多可以做的事。
更多資源
如果這引起了你的興趣,也可以看看 justforfunc #29: dependency injection in a code review,它涵蓋了相同的主題:
我推薦的一個很棒的資源是 Learn Go with Tests。它透過 Go 來教你測試驅動開發(TDD),幫助你打好 TDD 的基礎。
另一個推薦是 The Go Programming Language 這本書,由 Brian W. Kernighan(以 Unix 聞名)共同撰寫,展示了如何撰寫清晰且符合慣例的 Go 程式來解決實際問題。書中有專門探討介面與測試的章節,也更詳細地介紹了 io.Reader。

隨機一篇部落格

留言
登入後參與討論