單元測試中如何解決文件依賴問題

現如今的 Web 應用程序往往採用 RESTful API 接口形式對外提供服務,後端接口直接向前端返回 HTML 文件的情況越來越少,所以在程序中操作文件的場景也變少了。不過有些時候還是需要對文件進行操作,比如某個 API 接口需要返回應用程序的 ChangeLog,那麼這個接口就可以通過讀取項目的 CHANGELOG.md 文件內容,將其發送給前端。

在編寫單元測試時,文件就成了被測試代碼的外部依賴,本文就來講解下測試過程中如何解決文件外部依賴問題。

獲取 ChangeLog 程序示例

假設我們有一個函數,可以讀取項目的 ChangeLog 信息並返回。

程序代碼實現如下:

package main

import (
 "io"
 "os"
)

var (
 version        = "dev"
 commit         = "none"
 builtGoVersion = "unknown"
 changeLogPath  = "CHANGELOG.md"
)

type ChangeLogSpec struct {
 Version        string
 Commit         string
 BuiltGoVersion string
 ChangeLog      string
}

func GetChangeLog() (ChangeLogSpec, error) {
 data, err := os.ReadFile(changeLogPath)
 if err != nil {
  return ChangeLogSpec{}, err
 }

 return ChangeLogSpec{
  Version:        version,
  Commit:         commit,
  BuiltGoVersion: builtGoVersion,
  ChangeLog:      string(data),
 }, nil
}

GetChangeLog 函數實現比較簡單,首先從 changeLogPath 文件路徑中讀取 ChangeLog 內容,然後結合程序版本號、COMMIT 信息、Go 版本號一起組裝成 ChangeLogSpec 結構體,並返回。

使用臨時文件測試

現在,我們要對 GetChangeLog 函數進行單元測試。

可以發現,GetChangeLog 函數內部依賴了 changeLogPath 文件路徑,然後從中讀取內容。所以,在編寫測試時,我們要考慮 changeLogPath 文件如何指定。

我們最先想到的就是指定 changeLogPath 文件的真實路徑。但是,這可能會存在問題,比如本地環境和 CI 環境下 changeLogPath 文件路徑不同,那麼在編寫測試代碼時,就要考慮根據不同的測試環境執行不同邏輯。所以,這種方式不應該成爲首選方案。

不過,我們可以換種思路,Go 語言提供了 os.CreateTemp 方法,可以創建一個臨時文件。那麼,我們就可以考慮在測試函數開始時創建一個臨時文件來保存 ChangeLog,然後爲 changeLogPath 變量賦值爲臨時文件路徑,測試代碼執行完成後刪除臨時文件,這樣就能夠解決單元測試中依賴外部文件的問題。

按照這個思路,編寫的單元測試代碼如下:

func TestGetChangeLog(t *testing.T) {
 // 創建臨時文件
 // 第一個參數傳 "",表示在操作系統的臨時目錄下創建該文件
 // 文件文件名會以第二個參數作爲前綴,剩餘的部分會自動生成,以確保併發調用時生成的文件名不重複
 f, err := os.CreateTemp("""TEST_CHANGELOG")
 assert.NoError(t, err)
 defer func() {
  _ = f.Close()
  // 儘管操作系統會在某個時間自動清理臨時文件,但主動清理是創建者的責任
  _ = os.RemoveAll(f.Name())
 }()

 changeLogPath = f.Name()

 data := `
# Changelog
All notable changes to this project will be documented in this file.
`
 _, err = f.WriteString(data)
 assert.NoError(t, err)

 expected := ChangeLogSpec{
  Version:        "v0.1.1",
  Commit:         "1",
  BuiltGoVersion: "1.20.1",
  ChangeLog: `
# Changelog
All notable changes to this project will be documented in this file.
`,
 }

 actual, err := GetChangeLog()
 assert.NoError(t, err)
 assert.Equal(t, expected, actual)
}

我們首先通過 os.CreateTemp("", "TEST_CHANGELOG") 創建了一個臨時文件,然後將 data 內容寫入臨時文件作爲 ChangeLog,再然後將臨時文件名稱 f.Name() 賦值給 changeLogPath,之後就可以調用 GetChangeLog 函數進行測試了。

對於程序版本號、COMMIT 信息、Go 版本號這幾個變量,因爲都是全局變量,所以也屬於外部依賴。

對於全局變量的依賴,我們可以在 init 函數中對其進行初始化,這樣就相當於在測試環境中固定了這幾個變量的值,便於測試。

func init() {
 version = "v0.1.1"
 commit = "1"
 builtGoVersion = "1.20.1"
}

筆記:你也可以在 TestMain 函數中對其進行初始化。

使用 go test 來執行測試函數:

$ go test -v -run="TestGetChangeLog$"     
=== RUN   TestGetChangeLog
--- PASS: TestGetChangeLog (0.00s)
PASS
ok      github.com/jianghushinian/blog-go-example/test/file     0.562s

測試通過。

使用 Go embed 測試

以上我們介紹了使用臨時文件的方式來解決被測試函數依賴外部文件的問題。

不過我們在測試中提供的 ChangeLog 內容不多:

 data := `
# Changelog
All notable changes to this project will be documented in this file.
`

爲了讓單元測試更加可靠,你也許想測試 ChangeLog 內容比較多的情況下,GetChangeLog 函數能否正常工作。

我們可以編寫一個真實的 CHANGELOG.md 文件,存放於 testdata/CHANGELOG.md 路徑下:

# Kubernetes v0.1.1

## 主要特性和改進

- 添加了一些新的主要特性和改進。

## 重要變更

- 這裏列出了對現有功能的重要變更。

## API 變更

- 在 API 中進行的重要變更和更新。

## 已知問題

- 列出了已知的問題和限制。

## Bug 修復

- 修復了以下已知 Bug。

## 改進和優化

- 對現有功能進行了改進和優化。

## 安全性更新

- 列出了安全性方面的更新和修復。

## 已棄用功能

- 列出了已被棄用的功能。

## 警告和提醒

- 列出了需要注意的警告和提醒事項。

## 社區貢獻者

- 致謝並列出了爲此版本做出貢獻的社區成員。

更詳細的信息可以查閱 Kubernetes 官方文檔和發佈說明。

此時,我們可以使用 Go 提供的 embed 技術來將文件內容嵌入到 Go 變量中。

embed []byte

embed 可以實現在 Go 程序編譯時,直接將文件內容嵌入到 Go 變量。embed 目前支持嵌入兩種基礎類型的變量,分別是 []bytestrings。嵌入這兩種類型變量方式相同,本小節就像大家演示下如何通過將文件嵌入 []byte 變量的方式來編寫 GetChangeLog 函數的單元測試。

GetChangeLog 函數編寫的單元測試代碼如下:

package main

import (
 _ "embed"
 "os"
 "testing"

 "github.com/stretchr/testify/assert"
)

//go:embed testdata/CHANGELOG.md
var changelog []byte

func TestGetChangeLog_by_embed(t *testing.T) {
 f, err := os.CreateTemp("""TEST_CHANGELOG")
 assert.NoError(t, err)
 defer func() {
  _ = f.Close()
  _ = os.RemoveAll(f.Name())
 }()

 changeLogPath = f.Name()

 _, err = f.Write(changelog)
 assert.NoError(t, err)

 expected := ChangeLogSpec{
  Version:        "v0.1.1",
  Commit:         "1",
  BuiltGoVersion: "1.20.1",
  ChangeLog:      string(changelog),
 }

 actual, err := GetChangeLog()
 assert.NoError(t, err)
 assert.Equal(t, expected, actual)
}

單元測試中,我們最需要關注的是這行代碼:

//go:embed testdata/CHANGELOG.md
var changelog []byte

//go:embed 是一個指令註釋,用來標記嵌入指令,注意冒號 : 前後沒有空格,testdata/CHANGELOG.md 指明要嵌入的文件。

在嵌入指令下方,緊挨着我們定義了變量 var changelog []byte 用來接收被嵌入文件的內容。

程序編譯後,變量 changelog 的值就是 testdata/CHANGELOG.md 文件中的內容了。

注意,文件開頭的 import 中要導入 embed,嵌入指令纔可以使用。

之後的單元測試代碼改動就比較小了,僅用 changelog 變量替換了原來代碼中的 data 變量。

使用 go test 來執行測試函數:

$ go test -v -run="TestGetChangeLog_by_embed"
=== RUN   TestGetChangeLog_by_embed
--- PASS: TestGetChangeLog_by_embed (0.00s)
PASS
ok      github.com/jianghushinian/blog-go-example/test/file     0.365s

單元測試仍能通過。

embed fs.FS

Go embed 技術不僅能夠嵌入文件到基礎類型變量,還能直接將文件嵌入爲一個文件系統。

爲了演示這一強大的功能,我們修改下 GetChangeLog 函數代碼,讓其接收一個 io.Reader 類型的參數,然後從這個參數中讀取 ChangeLog 內容,而不再是通過讀取指定路徑下的 ChangeLog 內容。

修改後程序代碼如下:

func GetChangeLogByIOReader(reader io.Reader) (ChangeLogSpec, error) {
 data, err := io.ReadAll(reader)
 if err != nil {
  return ChangeLogSpec{}, err
 }

 return ChangeLogSpec{
  Version:        version,
  Commit:         commit,
  BuiltGoVersion: builtGoVersion,
  ChangeLog:      string(data),
 }, nil
}

如下是爲新的 GetChangeLogByIOReader 函數編寫的單元測試代碼:

//go:embed testdata/CHANGELOG.md
var fs embed.FS

func TestGetChangeLogByIOReader(t *testing.T) {
 f, err := fs.Open("testdata/CHANGELOG.md")
 assert.NoError(t, err)

 data, err := io.ReadAll(f)
 assert.NoError(t, err)

 // 將數據的讀取位置重置到開頭
 _, err = f.(io.ReadSeeker).Seek(0, 0)
 assert.NoError(t, err)

 expected := ChangeLogSpec{
  Version:        "v0.1.1",
  Commit:         "1",
  BuiltGoVersion: "1.20.1",
  ChangeLog:      string(data),
 }

 actual, err := GetChangeLogByIOReader(f)
 assert.NoError(t, err)
 assert.Equal(t, expected, actual)
}

我們同樣使用 //go:embed testdata/CHANGELOG.md 來指定嵌入的文件,不過,這次定義的變量 var fs embed.FS 是一個文件系統,裏面包含了被嵌入的文件。

在測試代碼中,使用 fs.Open("testdata/CHANGELOG.md") 打開文件內容,得到 fs.File 類型對象,之後就可以像其他 Go 文件對象一樣操作它。

使用 go test 來執行測試函數:

$ go test -v -run="TestGetChangeLogByIOReader"                         
=== RUN   TestGetChangeLogByIOReader
--- PASS: TestGetChangeLogByIOReader (0.00s)
PASS
ok      github.com/jianghushinian/blog-go-example/test/file     0.135s

測試通過。

總結

本文向大家介紹了在 Go 中編寫單元測試時,如何解決文件外部依賴的問題。

Go 語言提供了 os.CreateTemp 方法,可以創建一個臨時文件,我們可以利用這個方法來解決文件外部依賴。

此外,Go 語言還提供了 embed 技術,能夠在程序編譯時直接將文件內容嵌入到 Go 變量中。這項技術雖然不是爲單元測試而生的,但我們可以藉此來解決文件外部依賴問題。本文爲大家演示瞭如何將文件嵌入到 []byte 和文件系統,兩種方案用法差異不大,可以根據需求和喜好進行選擇。

本文完整代碼示例我放在了 GitHub 上,歡迎點擊查看。

希望此文能對你有所幫助。

參考

聯繫我

微信:jianghushinian

郵箱:jianghushinian007@outlook.com

博客地址:https://jianghushinian.cn

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/YGGqjsupLvNXFfhzpwoBdA