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

1. testdata 目录

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

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

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

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

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

  1. $GOROOT/src路径下(Go 1.14):
  2. $find . -name "testdata" -print
  3. ./cmd/vet/testdata
  4. ./cmd/objdump/testdata
  5. ./cmd/asm/internal/asm/testdata
  6. ... ...
  7. ./image/testdata
  8. ./image/png/testdata
  9. ./mime/testdata
  10. ./mime/multipart/testdata
  11. ./text/template/testdata
  12. ./debug/pe/testdata
  13. ./debug/macho/testdata
  14. ./debug/dwarf/testdata
  15. ./debug/gosym/testdata
  16. ./debug/plan9obj/testdata
  17. ./debug/elf/testdata

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

  1. $ls
  2. benchGray.png benchRGB.png invalid-palette.png
  3. benchNRGBA-gradient.png gray-gradient.interlaced.png invalid-trunc.png
  4. benchNRGBA-opaque.png gray-gradient.png invalid-zlib.png
  5. benchPaletted.png invalid-crc32.png pngsuite/
  6. benchRGB-interlace.png invalid-noend.png
  7. $ls testdata/pngsuite
  8. README basn2c08.png basn4a16.png ftbgn3p08.png
  9. README.original basn2c08.sng basn4a16.sng ftbgn3p08.sng
  10. ... ...
  11. basn0g16.sng basn4a08.sng ftbgn2c16.sng ftp1n3p08.sng

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

  1. // $GOROOT/src/image/png/reader_test.go
  2. var filenames = []string{
  3. "basn0g01",
  4. "basn0g01-30",
  5. "basn0g02",
  6. ... ...
  7. }
  8. func TestReader(t *testing.T) {
  9. names := filenames
  10. if testing.Short() {
  11. names = filenamesShort
  12. }
  13. for _, fn := range names {
  14. // 读取.png文件
  15. img, err := readPNG("testdata/pngsuite/" + fn + ".png")
  16. if err != nil {
  17. t.Error(fn, err)
  18. continue
  19. }
  20. ... ...
  21. // 比较读取的数据img与预期数据
  22. }
  23. ... ...
  24. }

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

  1. // testdata-demo1/attendee.go
  2. package attendee
  3. import (
  4. "encoding/xml"
  5. "strconv"
  6. )
  7. type Attendee struct {
  8. Name string
  9. Age int
  10. Phone string
  11. }
  12. func (a *Attendee) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
  13. tokens := []xml.Token{}
  14. tokens = append(tokens, xml.StartElement{
  15. Name: xml.Name{"", "attendee"}})
  16. tokens = append(tokens, xml.StartElement{Name: xml.Name{"", "name"}})
  17. tokens = append(tokens, xml.CharData(a.Name))
  18. tokens = append(tokens, xml.EndElement{Name: xml.Name{"", "name"}})
  19. tokens = append(tokens, xml.StartElement{Name: xml.Name{"", "age"}})
  20. tokens = append(tokens, xml.CharData(strconv.Itoa(a.Age)))
  21. tokens = append(tokens, xml.EndElement{Name: xml.Name{"", "age"}})
  22. tokens = append(tokens, xml.StartElement{Name: xml.Name{"", "phone"}})
  23. tokens = append(tokens, xml.CharData(a.Phone))
  24. tokens = append(tokens, xml.EndElement{Name: xml.Name{"", "phone"}})
  25. tokens = append(tokens, xml.StartElement{Name: xml.Name{"", "website"}})
  26. tokens = append(tokens, xml.CharData("https://www.gophercon.com/speaker/"+a.Name))
  27. tokens = append(tokens, xml.EndElement{Name: xml.Name{"", "website"}})
  28. tokens = append(tokens, xml.EndElement{Name: xml.Name{"", "attendee"}})
  29. for _, t := range tokens {
  30. err := e.EncodeToken(t)
  31. if err != nil {
  32. return err
  33. }
  34. }
  35. err := e.Flush()
  36. if err != nil {
  37. return err
  38. }
  39. return nil
  40. }

下面我们就来为Attendee的MarshalXML方法编写测试:

  1. // testdata-demo1/attendee_test.go
  2. package attendee
  3. import (
  4. "bytes"
  5. "encoding/xml"
  6. "io/ioutil"
  7. "path/filepath"
  8. "testing"
  9. )
  10. func TestAttendeeMarshal(t *testing.T) {
  11. tests := []struct {
  12. fileName string
  13. a Attendee
  14. }{
  15. {
  16. fileName: "attendee1.xml",
  17. a: Attendee{
  18. Name: "robpike",
  19. Age: 60,
  20. Phone: "13912345678",
  21. },
  22. },
  23. }
  24. for _, tt := range tests {
  25. got, err := xml.MarshalIndent(&tt.a, "", " ")
  26. if err != nil {
  27. t.Fatalf("want nil, got %v", err)
  28. }
  29. want, err := ioutil.ReadFile(filepath.Join("testdata", tt.fileName))
  30. if err != nil {
  31. t.Fatalf("open file %s failed: %v", tt.fileName, err)
  32. }
  33. if !bytes.Equal(got, want) {
  34. t.Errorf("want %s, got %s", string(want), string(got))
  35. }
  36. }
  37. }

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

  1. //testdata/attendee1.xml
  2. <attendee>
  3. <name>robpike</name>
  4. <age>60</age>
  5. <phone>13912345678</phone>
  6. <website>https://www.gophercon.com/speaker/robpike</website>
  7. </attendee>

执行该测试:

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

2. golden 文件惯用法

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

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

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

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

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

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

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

  1. // testdata-demo2/attendee_test.go
  2. ... ...
  3. var update = flag.Bool("update", false, "update .golden files")
  4. func TestAttendeeMarshal(t *testing.T) {
  5. tests := []struct {
  6. fileName string
  7. a Attendee
  8. }{
  9. {
  10. fileName: "attendee1.golden",
  11. a: Attendee{
  12. Name: "robpike",
  13. Age: 60,
  14. Phone: "13912345678",
  15. },
  16. },
  17. }
  18. for _, tt := range tests {
  19. got, err := xml.MarshalIndent(&tt.a, "", " ")
  20. if err != nil {
  21. t.Fatalf("want nil, got %v", err)
  22. }
  23. golden := filepath.Join("testdata", tt.fileName)
  24. if *update {
  25. ioutil.WriteFile(golden, got, 0644)
  26. }
  27. want, err := ioutil.ReadFile(golden)
  28. if err != nil {
  29. t.Fatalf("open file %s failed: %v", tt.fileName, err)
  30. }
  31. if !bytes.Equal(got, want) {
  32. t.Errorf("want %s, got %s", string(want), string(got))
  33. }
  34. }
  35. }

自己的实现与标准的比较.

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

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

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

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

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