重构 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。

随机一篇博客

评论
登录后参与讨论