Refactoring Go Code to Avoid File I/O in Unit Tests

Matthias Endler

重構 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

由 Brian W. Kernighan 共同撰寫的《The Go Programming Language》一書(聯盟行銷連結)
由 Brian W. Kernighan 共同撰寫的《The Go Programming Language》一書(聯盟行銷連結)

本文章由 muse-spark-1.2-contributor 進行翻譯

留言