专栏目录
11 理解包导入路径的含义 44 利其器!Go常用工具大检阅 22 Go 并发模型和常见并发模式 35 告别乱码!GO语言字符集编码方案间转换 12 go语言 init 函数的妙用 41 与时俱进!使用module管理依赖包 1 参考 Go 项目布局设计你的项目结构 16 方法集合决定接口实现 17 go变长参数函数的妙用 7 go语言定义“零值可用”的类型 30 Go 惯例:将测试依赖的外部数据文件放在 testdata 下面 34 一文告诉你如何在 Go 中实现 HTTPS 通信 24 sync 包的正确使用姿势 6 Go“枚举常量”的惯用实现方法 4 变量声明形式尽量保持一致 2 gofmt:Go代码风格的唯一标准 3 Go 标识符的命名惯例 9 深入理解和高效运用切片 10 Go 字符串是原生类型 8 用复合字面值作初值构造器 13 Go 函数是“一等公民”的理解 14 defer 让你的代码更清晰 19 不要在函数参数中使用空接口(interface{}) 23 Go channel 的常见使用模式 18 定义小接口是 Go 的惯例 15 Go 方法的本质 21 面试必考!掌握 goroutine 的调度原理 29 Go 单元测试惯例:表驱动 26 if err != nil 重复太多可以这么办 27 不要让 panic 掺和到正常错误处理中 28 一文告诉你测试包的包名要不要带“\_test”后缀 31 为被测对象建立性能基准 25 别笑!这就是 Go 的错误处理哲学 32 掌握 Go 代码性能剖析神器:pprof 33 掌握 Go 代码调试利器:delve 39 慎用reflect包提供的反射能力 38 小心被kill!不要忽略对系统信号的处理 36 像极!bytes包和strings包的那些相似操作 40 与C互操作不是免费的!疑问了解cgo的使用成本 43 让你的Go包拥有个性化的导入路径 37 time包,你用对了吗 42 小即是美?构建最小Go程序容器镜像 20 要提高代码可测试性,请使用接口 45 未雨绸缪!Go语言常见“坑”大汇 5 无类型常量让代码更简化

30 Go 惯例:将测试依赖的外部数据文件放在 testdata 下面

九路
• 阅读 679

Go 惯例:将测试依赖的外部数据文件放在 testdata 下面

测试固件是 Go 测试执行所需的上下文环境,其中测试依赖的外部数据文件就是一种常见的测试固件(可以理解为静态测试固件,即无需在测试代码中为其单独编写固件的创建和清理辅助函数)。在一些包含文件 I/O 的包的测试中,我们经常需要从外部数据文件中加载数据或向外部文件写入结果数据以满足测试固件的需求。

在其他主流编程语言中,测试依赖的外部数据文件如何管理往往是由程序员们自行决定的。但 Go 语言是一门面向软件工程的语言。从工程化的角度出发,Go 的设计者们将一些在传统语言中由程序员自身习惯决定的事情都一一规范化了,这样可以最大程度地提升程序员间的协作效率。而对测试依赖的外部数据文件的管理就是 Go 语言在这方面的一个典型例子。本节我们就来看看 Go 管理测试依赖的外部数据文件所采用的一些惯例和最佳实践。

1. testdata 目录

Go 语言规定:Go 工具链将忽略名为testdata的目录。这样开发者在编写测试时,就可以在名为testdata的目录下存放和管理测试代码依赖的数据文件。而go test命令执行时会将被测试程序包源码所在目录设置为其工作目录,这样如果要使用testdata目录下的某数据文件,我们无需再处理各种恼人的路径问题,可以直接在测试代码中像下面这样定位到充当测试固件的数据文件:

f, err := os.Open("testdata/data-001.txt") 

如果考虑到不同操作系统对路径分隔符定义的差别(Windows 使用反斜线“\”,Linux/MacOS 使用斜线“/”),使用下面的方式可以使测试代码更具可移植性:

f, err := os.Open(filepath.Join("testdata", "data-001.txt")) 

testdata目录中管理测试依赖的外部数据文件的方式在标准库中有着广泛应用:

在$GOROOT/src路径下(Go 1.14):

$find . -name "testdata" -print
./cmd/vet/testdata
./cmd/objdump/testdata
./cmd/asm/internal/asm/testdata
... ...
./image/testdata
./image/png/testdata
./mime/testdata
./mime/multipart/testdata
./text/template/testdata
./debug/pe/testdata
./debug/macho/testdata
./debug/dwarf/testdata
./debug/gosym/testdata
./debug/plan9obj/testdata
./debug/elf/testdata 

image/png/testdata为例,这里存储着png包测试代码用作静态测试固件的外部依赖数据文件:

$ls
benchGray.png            benchRGB.png            invalid-palette.png
benchNRGBA-gradient.png        gray-gradient.interlaced.png    invalid-trunc.png
benchNRGBA-opaque.png        gray-gradient.png        invalid-zlib.png
benchPaletted.png        invalid-crc32.png        pngsuite/
benchRGB-interlace.png        invalid-noend.png

$ls testdata/pngsuite 
README            basn2c08.png        basn4a16.png        ftbgn3p08.png
README.original        basn2c08.sng        basn4a16.sng        ftbgn3p08.sng
... ...
basn0g16.sng        basn4a08.sng        ftbgn2c16.sng        ftp1n3p08.sng 

png包的测试代码将这些数据文件作为输入,并将经过被测函数(比如:png.Decode等)处理后得到的结果数据与预期数据对比:

// $GOROOT/src/image/png/reader_test.go

var filenames = []string{
        "basn0g01",
        "basn0g01-30",
        "basn0g02",
    ... ...
}

func TestReader(t *testing.T) {
        names := filenames
        if testing.Short() {
                names = filenamesShort
        }
        for _, fn := range names {
                // 读取.png文件
                img, err := readPNG("testdata/pngsuite/" + fn + ".png")
                if err != nil {
                        t.Error(fn, err)
                        continue
                }
                ... ...
                // 比较读取的数据img与预期数据
        }
        ... ...
} 

我们还经常将预期结果数据保存在文件中并放置在testdata下面,然后在测试代码中我们将被测对象输出的数据与这些预置在文件中的数据进行比较,一致则测试通过;反之,测试失败。我们来看一个例子:

// testdata-demo1/attendee.go
package attendee

import (
    "encoding/xml"
    "strconv"
)

type Attendee struct {
    Name  string
    Age   int
    Phone string
}

func (a *Attendee) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
    tokens := []xml.Token{}

    tokens = append(tokens, xml.StartElement{
        Name: xml.Name{"", "attendee"}})

    tokens = append(tokens, xml.StartElement{Name: xml.Name{"", "name"}})
    tokens = append(tokens, xml.CharData(a.Name))
    tokens = append(tokens, xml.EndElement{Name: xml.Name{"", "name"}})

    tokens = append(tokens, xml.StartElement{Name: xml.Name{"", "age"}})
    tokens = append(tokens, xml.CharData(strconv.Itoa(a.Age)))
    tokens = append(tokens, xml.EndElement{Name: xml.Name{"", "age"}})

    tokens = append(tokens, xml.StartElement{Name: xml.Name{"", "phone"}})
    tokens = append(tokens, xml.CharData(a.Phone))
    tokens = append(tokens, xml.EndElement{Name: xml.Name{"", "phone"}})

    tokens = append(tokens, xml.StartElement{Name: xml.Name{"", "website"}})
    tokens = append(tokens, xml.CharData("https://www.gophercon.com/speaker/"+a.Name))
    tokens = append(tokens, xml.EndElement{Name: xml.Name{"", "website"}})

    tokens = append(tokens, xml.EndElement{Name: xml.Name{"", "attendee"}})

    for _, t := range tokens {
        err := e.EncodeToken(t)
        if err != nil {
            return err
        }
    }

    err := e.Flush()
    if err != nil {
        return err
    }

    return nil
} 

attendee包中,我们为Attendee类型实现了MarshalXML方法,进而实现了 xml 包的Marshaler接口。这样,当我们调用 xml 包的MarshalMarshalIndent方法序列化上面Attendee实例时,我们实现的MarshalXML方法会被调用对Attendee实例进行 xml 编码。和默认的 xml 编码不同的是,在我们实现的MarshalXML方法中,我们会根据 Attendee 的 name 字段,自动在输出的 xml 格式数据中增加一个元素(element): website

下面我们就来为AttendeeMarshalXML方法编写测试:

// testdata-demo1/attendee_test.go 
package attendee

import (
    "bytes"
    "encoding/xml"
    "io/ioutil"
    "path/filepath"
    "testing"
)

func TestAttendeeMarshal(t *testing.T) {
    tests := []struct {
        fileName string
        a        Attendee
    }{
        {
            fileName: "attendee1.xml",
            a: Attendee{
                Name:  "robpike",
                Age:   60,
                Phone: "13912345678",
            },
        },
    }

    for _, tt := range tests {
        got, err := xml.MarshalIndent(&tt.a, "", "  ")
        if err != nil {
            t.Fatalf("want nil, got %v", err)
        }

        want, err := ioutil.ReadFile(filepath.Join("testdata", tt.fileName))
        if err != nil {
            t.Fatalf("open file %s failed: %v", tt.fileName, err)
        }

        if !bytes.Equal(got, want) {
            t.Errorf("want %s, got %s", string(want), string(got))
        }
    }
} 

接下来,我们将预期结果放入testdata/attendee1.xml中:

//testdata/attendee1.xml 
<attendee>
  <name>robpike</name>
  <age>60</age>
  <phone>13912345678</phone>
  <website>https://www.gophercon.com/speaker/robpike</website>
</attendee> 

执行该测试:

$go test -v .
=== RUN   TestAttendeeMarshal
--- PASS: TestAttendeeMarshal (0.00s)
PASS
ok      sources/testdata-demo1    0.007s 

测试通过是预料之中的事情。

2. golden 文件惯用法

在为上面例子准备预期结果数据文件:attendee1.xml时,你可能会有这样的问题:attendee1.xml中的数据从哪儿得到?

我们的确可以根据AttendeeMarshalXML方法的逻辑手工“造”出结果数据,但更快捷的方法是通过代码来得到预期结果。我们可以通过标准格式化函数输出对Attendee实例进行序列化后的结果。如果这个结果与我们的期望相符,那么这个结果就可以作为预期结果数据写入到attendee1.xml文件中:

got, err := xml.MarshalIndent(&tt.a, "", "  ")
if err != nil {
    ... ...
}
println(string(got)) // 这里输出xml编码后的结果数据 

如果仅是将标准输出中符合要求的预期结果数据手工拷贝到attendee1.xml文件中,那么标准输出中的不可见控制字符很可能会对最终拷贝的数据造成影响,从而导致测试失败。更有一些被测目标输出的是纯二进制数据,通过手工复制是无法实现预期>结果数据文件的制作的。因此,我们还是需要通过代码来实现attendee1.xml文件内容的填充,比如:

got, err := xml.MarshalIndent(&tt.a, "", "  ")        
if err != nil {
    ... ...
}  
ioutil.WriteFile("testdata/attendee1.xml", got, 0644) 

问题出现了!难道我们还要为每个testdata下面的预期结果文件单独编写一个小程序用于测试前写入预期数据?我们能否将采集预期数据到文件的过程与测试代码融合到一起呢?Go 标准库为我们提供了一种惯用法:golden 文件

我们将上面的例子改造为采用golden 文件模式(将attendee1.xml重命名为attendee1.golden以显式告诉大家该测试用例采用了 golden 文件惯用法):

// testdata-demo2/attendee_test.go
... ...

var update = flag.Bool("update", false, "update .golden files")

func TestAttendeeMarshal(t *testing.T) {
    tests := []struct {
        fileName string
        a        Attendee
    }{
        {
            fileName: "attendee1.golden",
            a: Attendee{
                Name:  "robpike",
                Age:   60,
                Phone: "13912345678",
            },
        },
    }

    for _, tt := range tests {
        got, err := xml.MarshalIndent(&tt.a, "", "  ")
        if err != nil {
            t.Fatalf("want nil, got %v", err)
        }

        golden := filepath.Join("testdata", tt.fileName)
        if *update {
            ioutil.WriteFile(golden, got, 0644)
        }

        want, err := ioutil.ReadFile(golden)
        if err != nil {
            t.Fatalf("open file %s failed: %v", tt.fileName, err)
        }

        if !bytes.Equal(got, want) {
            t.Errorf("want %s, got %s", string(want), string(got))
        }
    }
} 

在改造后的测试代码中,我们看到新增了一个名为update的变量以及它所控制的 golden 文件的预期结果数据采集过程:

if *update {
    ioutil.WriteFile(golden, got, 0644)
} 

这样,当我们执行下面命令时,测试代码会先将最新的预期结果写入testdata目录下的golden文件中,然后再将该结果与从golden文件中读出的结果做比较:

$go test -v . -update
=== RUN   TestAttendeeMarshal
--- PASS: TestAttendeeMarshal (0.00s)
PASS
ok      sources/testdata-demo2    0.006s 

显然这样执行的测试是一定会通过的,因为在此次执行中,预期结果数据文件的内容就是通过被测函数刚刚生成的。

但带有-update命令参数的go test命令仅在需要进行预期结果数据采集时才会执行,尤其是在因数据生成逻辑发生变化或类型结构定义发生变化需要重新采集预期结果数据时。比如我们给上面的Attendee结构体类型增加一个新字段topic,如果不重新采集预期结果数据,那么测试一定是无法通过的。

采用golden文件惯用法后,要格外注意在每次重新采集预期结果后,对golden 文件中的数据进行正确性检查,否则很容易出现预期结果数据不正确,但测试依然通过的情况。

3. 小结

本节要点回顾:

  • 面向工程的 Go 语言对测试依赖的外部数据文件的存放位置做了规范化,统一使用testdata目录;
  • 开发人员可以采用将预期数据文件放在testdata下的方式为测试提供静态测试固件;
  • golden 文件惯用法实现了testdata目录下测试依赖的预期结果数据文件的数据采集与测试代码的融合。
点赞
收藏
评论区
推荐文章

暂无数据