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

Matthias Endler

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

由布萊恩·柯林漢共同撰寫的《Go 程式設計語言》一書(聯盟行銷連結)
由布萊恩·柯林漢共同撰寫的《Go 程式設計語言》一書(聯盟行銷連結)

原文由 Matthias Endler 發布

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