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 之前,先检查一下代码是否使用了恰当的抽象。也许面向接口编程或使用依赖注入就能帮助解耦组件?很多时候,只需要做到清晰的关注点分离就足够了。

重构以简化测试

在上面的例子中,我们可以通过将文件 I/O 与分析逻辑解耦,轻松避免使用 mock 和临时文件。做法是将 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() 单独写一个测试,但这里的重点是将与数据源无关的部分解耦出来。)

成果

通过对代码稍作重构,我们获得了以下好处:

  • 测试简单:无需 mock 或临时文件。
  • 关注点分离:每个函数只做一件事。
  • 更易复用doSomething() 函数可以处理任意 io.Reader,能在其他地方被调用。如果需要,我们甚至可以把它移到独立的库中。

在 Reddit 上,用户 soapysops 提出了一个重要评论

一般来说,我倾向于不在 API 中接收文件名。文件名给不了用户足够的控制权。例如,它不允许你使用特殊的编码、特殊的文件权限,或是用 bytes.Buffer 来代替真实文件。接收文件名会给代码增加一个巨大的依赖:文件系统,以及所有与之相关的、和操作系统强相关的东西。

所以我可能会去掉基于文件名的 API,只暴露基于 io.Reader 的版本。这样一来,你就能获得完整的代码覆盖率、更快的测试,以及少得多的边界情况需要担心。

我完全赞同这个观点。
但很多时候,你不能轻易地改变面向用户的 API,因为它可能是公开的、已经有了使用者。上面的重构只是迈向更好架构的第一步。要在 Go 中写出健壮、测试充分的系统,你肯定还能做更多。

更多资源

如果这引起了你的兴趣,不妨也看看 justforfunc #29: dependency injection in a code review,其中也涵盖了同样的主题:

我推荐的一份很棒的资源是 Learn Go with Tests。它教你如何用 Go 进行测试驱动开发,帮你打下 TDD 的基础。

另一本是 Brian W. Kernighan(以 Unix 闻名)合著的 The Go Programming Language,它展示了如何编写清晰、地道的 Go 代码来解决实际问题。书中有一章专门讲接口和测试,也更详细地介绍了 io.Reader

Brian W. Kernighan 合著的《The Go Programming Language》一书(推广链接)
Brian W. Kernighan 合著的《The Go Programming Language》一书(推广链接)

本文章由 muse-spark-1.2-contributor 进行翻译

评论