重構 Go 程式碼以避免在單元測試中進行檔案 I/O
今天在工作中,我重構了一些簡單的 Go 程式碼,讓它更易於測試。這個想法是透過將資料輸入/輸出與資料處理分離,在不使用 mocking(模擬)或暫存檔的情況下,避免在單元測試中處理檔案。
來源:插圖由 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 函式內開啟該檔案,對其內容進行處理。
為程式碼撰寫第一個測試
該程式碼典型的 test harness(測試框架) 可能如下所示:
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)
}
})
}看起來沒問題吧?
問題所在
這樣做雖然可行,但在執行測試時進行 File I/O(檔案 I/O) 並不總是個好主意。首先,你可能在受限的環境中執行,無法存取該檔案。我們可以使用暫存檔來避免這個問題。
但磁碟 I/O 也可能會造成問題,導致測試不穩定、令人沮喪。
另一個處理程序也可能在測試期間修改檔案。所有這些問題都與你的程式碼本身無關。
此外,光看測試本身並不足以完全了解發生了什麼事。你還必須先去讀取文字檔的內容。
許多人建議使用 mocking來取代。有不少強大的函式庫,例如 spf13/afero,就是為此而設計。這些套件會在背景建立暫存檔,並在事後進行清理。
在我看來,mocking 應該是測試時的最後手段。在使用 mock 之前,先檢查你的程式碼是否使用了正確的抽象化。也許針對介面實作或使用 Dependency Injection(依賴注入) 有助於解耦元件?在大多數情況下,清晰的關注點分離就已足夠。
重構以讓測試更簡單
在上述的案例中,我們可以透過將 File I/O 與分析邏輯解耦,輕鬆避免使用 mocks 和暫存檔。我們的做法是重構 analyze 函式,讓它去呼叫 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() 另外撰寫一個測試,但這裡的重點是將與資料來源無關的部分解耦。)
結果
透過稍微重構程式碼,我們獲得了以下優點:
- 簡單的可測試性:不需要 mocks 或暫存檔。
- 關注點分離:每個函式只做一件事。
- 更容易重複使用程式碼:
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 進行 test-driven development(測試驅動開發),並幫助你建立 TDD 的基礎。
另一個是The Go Programming Language(《Go 程式設計語言》)一書,由 Brian W. Kernighan(布萊恩·柯林漢)(以 Unix 聞名)共同撰寫,書中展示了如何撰寫清晰且符合慣例的 Go 來解決現實世界的問題。它包含專門探討介面與測試的章節。也更詳細地介紹了 io.Reader。

隨機一篇部落格
