一時的なファイル出力を伴うテストにtesting.TB.TempDir()が便利そう

たまたまファイル出力を伴うようなコードを書いていて、これのテストにtesting.TB.TempDir()が使えることがわかったのでメモ。1.15から追加されていた模様。

便利なのは自動でcleanupされる点

使い方

例えば以下の様な処理があるとする。特に意味自体は無いが特定のディレクトリ以下の様なファイルを出力するような処理。

current/
├── 1.txt // one
├── 2.txt // two
└── 3.txt // three

0 directories, 3 files
func Run(d string) error {
}
    {
        f, err := os.Create(filepath.Join(d, "1.txt"))
        if err != nil {
            return err
        }
        fmt.Fprintln(f, "one")
    }
    {
        f, err := os.Create(filepath.Join(d, "2.txt"))
        if err != nil {
            return err
        }
        fmt.Fprintln(f, "two")
    }
    {
        f, err := os.Create(filepath.Join(d, "3.txt"))
        if err != nil {
            return err
        }
        fmt.Fprintln(f, "three")
    }
    return nil
}

このような関数をテストしたい。このときにtesting.TB.TempDir()が使える。

テストコード

自動でテキトーな位置に一時的なファイルを作ってくれる。そしてテストの終了後にはきれいに消してくれる。

package main

import (
    "io/ioutil"
    "testing"
)

func TestRun(t *testing.T) {
    d := t.TempDir()
    t.Logf("outdir: %s", d)
    if err := Run(d); err != nil {
        t.Errorf("!! %+v", err)
    }

    files, err := ioutil.ReadDir(d)
    if err != nil {
        t.Errorf("!!! %+v", err)
    }

    wantFiles := []string{"1.txt", "2.txt", "3.txt"}
    if len(files) != len(wantFiles) {
        t.Errorf("mismatch the number of files, got=%d, want=%d", len(files), len(wantFiles))
    }

    for i, f := range files {
        if f.Name() != wantFiles[i] {
            t.Errorf("files[%d], %s != %s", i, f.Name(), wantFiles[i])
        }
    }
}

実際以下の様に実行してみたときにアクセスされたファイル達。

$ go test -v -test.testlogfile=x.log
=== RUN   TestRun
    main_test.go:10: outdir: /var/folders/b7/2rk7xp2d0hb2r21zbzjwxb_m0000gn/T/TestRun675610132/001
--- PASS: TestRun (0.00s)
PASS
ok      github.com/podhmo/individual-sandbox/daily/20210105/example_go/00gen    0.011s

$ cat x.log
# test log
getenv TMPDIR
open /var/folders/b7/2rk7xp2d0hb2r21zbzjwxb_m0000gn/T/TestRun675610132/001/1.txt
open /var/folders/b7/2rk7xp2d0hb2r21zbzjwxb_m0000gn/T/TestRun675610132/001/2.txt
open /var/folders/b7/2rk7xp2d0hb2r21zbzjwxb_m0000gn/T/TestRun675610132/001/3.txt
open /var/folders/b7/2rk7xp2d0hb2r21zbzjwxb_m0000gn/T/TestRun675610132/001
stat /var/folders/b7/2rk7xp2d0hb2r21zbzjwxb_m0000gn/T/TestRun675610132/001/1.txt
stat /var/folders/b7/2rk7xp2d0hb2r21zbzjwxb_m0000gn/T/TestRun675610132/001/2.txt
stat /var/folders/b7/2rk7xp2d0hb2r21zbzjwxb_m0000gn/T/TestRun675610132/001/3.txt
open /var/folders/b7/2rk7xp2d0hb2r21zbzjwxb_m0000gn/T

テストの失敗時にファイルを残す

テストの失敗時に実際のファイルを覗きたいという動機は確かにありそう。

issueを覗いて見たら、以下の様にして書けばというようなことが勧められていた。

  defer func() { if t.Failed() { os.Exit(1) } }

もしくは

  defer log.Fatalf("check out the failing files in %v", t.TempDir())

os.Exit()自体はcleanupを呼ばずに終了するので確かに良さそう1

実装について

ファイルの削除の処理の登録自体は、素直にtesting.TB.Cleanup()に削除用の処理が渡されているみたい。

func (c *common) TempDir() string {
...
        c.tempDir, c.tempDirErr = os.MkdirTemp("", pattern)
        if c.tempDirErr == nil {
            c.Cleanup(func() {
                if err := os.RemoveAll(c.tempDir); err != nil {
                    c.Errorf("TempDir RemoveAll cleanup: %v", err)
                }
            })
        }
}

今までこの種の処理を書くときには、以下の様なインターフェイスのものを作りがちだったのだけれど、まぁ確かにCleanupで省略できるような書き方もありといえばありかもしれない。

func setupSomething(t testing.TB) (cleanup func(), err error)

go test自体はパッケージごとにビルドして実行になるし、恐れず使っちゃって良いような気もしている。結構↑のインターフェイスはテストの行数をいたずらに増やしてだるいと言うような感覚があったし。

gist


  1. こういう組み合わせに気づけないとダメみたいなパズル感がgoにはあるかもしれない。ところで、os.Exit()の挙動に関しては、deferを全然尊重してくれないみたいなissueが作られてしまっている。os.Exit()とはそういうものだろうという感覚があるが、文字通りの杓子定規な理解から来るとこういう理解になってしまうんだろうか?goに限らずpythonでもこの種の下層の部分の不理解が原因の質問を見ることがたまにある。