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 函数中打开该文件,以便对其内容进行处理。

为代码编写第一个测试

针对这段代码,一个典型的测试框架可能如下所示:

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 可能会引发问题,导致测试不稳定,也令人沮丧。
另一个进程也可能在测试期间修改该文件。所有这些问题都与我们的代码无关。

此外,仅仅查看测试、弄清楚测试到底在做什么还不够。你还得先阅读那个文本文件。

很多人反而建议使用模拟。为此有不少功能强大的库,例如 spf13/afero。这些软件包会在后台创建临时文件,并在之后进行清理。

在我看来,说到测试,模拟应该是最后的手段。在进行模拟之前,先确认代码使用了正确的抽象。也许基于接口实现,或使用依赖注入(Dependency Injection)能够帮助组件解耦?很多时候,你只需要清晰地分离关注点(separation of concerns)。

重构以简化测试

在上面的例子中,我们可以将文件 I/O 与分析过程解耦,从而轻松避免使用模拟和临时文件。具体做法是重构 analyze 函数,让它调用接收 io.ReaderdoSomething。(暂时也可以使用字符串数组。)

现在,我们的 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() 单独编写测试,但这里的重点是将与数据源无关的部分解耦。)

结果

通过对代码进行小幅重构,我们获得了以下优势:

  • 易于测试:不需要模拟或临时文件。
  • 关注点分离:每个函数只做一件事。
  • 更易于复用代码doSomething() 函数可以与任何 io.Reader 一起使用,也可以从其他地方调用。如果愿意,我们甚至可以将它移到自己的库中。

在 Reddit 上,用户 soapysops 提出了一个重要的看法

一般来说,我更倾向于不在 API 中接收文件名。文件名无法为用户提供足够的控制权。比如,它不允许你使用不常见的编码、特殊的文件权限,或者使用 bytes.Buffer 来代替实际文件。接收文件名会给代码添加一个巨大的依赖:文件系统,以及与之相关的所有特定于操作系统的内容。

所以我可能会彻底删除基于文件名的 API,只暴露一个基于 io.Reader 的 API。这样一来,你就能获得完整的代码覆盖率、快速的测试,并且需要操心的边界情况也会少得多。

我完全赞同这个观点。
但很多时候,你无法轻易地直接改变面向用户的 API,因为 API 可能是公开的,而且已经有用户在使用。上面的重构只是迈向更好架构的第一步。在 Go 中开始编写健壮、经过充分测试的系统,你绝对还可以做更多事情。

更多资源

如果这引起了你的兴趣,也可以看看 justforfunc #29:代码审查中的依赖注入,其中讨论了同一个主题:

我推荐的一项优秀资源是 Learn Go with Tests(《通过测试学习 Go》)。它通过 Go 教授测试驱动开发(test-driven development),帮助你打好 TDD 基础。

另一个资源是《Go 编程语言》,该书由 Brian W. Kernighan(布莱恩·W·柯尼汉)等人合著(他因 Unix 而闻名),展示了如何使用清晰、符合 Go 惯用风格的代码解决现实世界的问题。书中有专门的一章介绍接口和测试,也更详细地讲解了 io.Reader

由 Brian W. Kernighan 合著的《Go 编程语言》一书(联盟链接)
由 Brian W. Kernighan 合著的《Go 编程语言》一书(联盟链接)

原文由 Matthias Endler 发布

本文章由 openai/gpt-5.6-luna 进行翻译