フューチャー技術ブログ

Goのテストでファイルの読み書きを扱いたい

プロセス外部への副作用がないコードならテストは難しくありませんが、ファイルの読み書き、ネットワーク、データベースを使いだすと気にしなければならないことが増えます。今回はファイルの読み書きを扱うテストを書こうとしていろいろ調べたりしたことをまとめます。

単体のファイルの読み書きであれば、io.Readerio.Writerを引数にすれば解決することも多いですが、複数のファイルがフォルダに格納されており、それを一括で読み書きするようなケースを想定しています。

fs.FS

Go 1.16で追加されたのがfs.FSインタフェースです。ファイルシステムを抽象化するインタフェースです。go embedでプログラムに埋め込んだファイル群をアクセスしたり、os.DirFS()で実フォルダのfs.FSインタフェースを取り出したりできます。テスト時はすでに実行ファイルに組み込まれたデータをファイルとして読み込むため、安定して高速なテスト実行ができます。

os.DirFS()の内部で使われる型はstringに対するtype宣言だけで実装されており、実にGo的な感じですね。

Go 1.22ではarchive/zipでAddFS()メソッドが増えたり、Go 1.23ではos.CopyFS()なんかも増えたり、fs.FSを使う機能も増えています。公式の抽象化の流れに乗っておけば今後便利になる一方なので便利ですね!

というだけで落とし穴が何もないならシンプルでこの記事を書くまでもないのですが、fs.FSには読み込み用の機能しかありません。書き込み用のAPIが欲しいプロポーザルもありましたが、現時点では否決されています。あとは、僕がはまったのは、.とか_で始まるファイル名が無視されることです。

ということで、書き込みが不要なファイルのパースやロードのテストであればこれを使うのが良いでしょう。検索すると、書き込みもできる互換サードパーティライブラリとかもあるにはあります。標準でなくても良ければそれでも良いでしょう。

os.MkdirTemp()とtesting.T.TempDir()

書き込みが必要なテストの場合は一次フォルダを作る方法もあります。os.MkdirTemp()で一次フォルダを作成してそのパスが得られます。Go 1.15からテストの中で使うフォルダをさっと作るtesting.T.TempDir()メソッドが増えました。

しかし、テストは毎回成功するわけではありません。失敗した場合にそのフォルダ中を見て検死したいと思うでしょう。そのようなユースケースを考えるとこの2つはちょっと使いにくなります。検死を前提とすると、次のようなロジックになるはずです。

  1. テストの準備の中で、前回の一次フォルダがあったら削除する
  2. 一次フォルダを作る
  3. テスト実行
  4. 失敗しなかったら一次フォルダを削除する

まず、os.MkdirTemp()は実装を見ると乱数を含んだパスを返します。テストの中で作って、t.Cleanup()でその中で削除まで全部自動でやるならいいのですが、乱数を含んでしまうので、前述のステップの1番で確実に削除するのが難しくなります。かといって、テストするたびにゴミが残り続けるのも困ったものです。

https://cs.opensource.google/go/go/+/refs/tags/go1.23.2:src/os/tempfile.go;l=99

後者のtesting.T.TempDir()は、テストケースが終わったら問答無用で削除してくるため、検死はできませんし、内部ではos.MkdirTemp()を使っているのでパス名もランダムです。

サードパーティライブラリがtempフォルダを要求してきて、なおかつそのサードパーティライブラリが失敗することはあまり考えることはなく、検死しなくてもいい、みたいなケースなら合うかと思います。

os.Getwd()

GoにはC/C++の__FILE__だったり、Pythonの__file__、Node.jsの__dirname__に相当するものはないため、現在のソースコードのフォルダの場所を知ることはできません。できないはず、と思っていたのですがテストに関しては例外で、os.Getwd()でテストのソースがあるフォルダをワーキングディレクトリとして取得できます。実装依存で仕様にはない動作だと思うので、今後もこの結果が保証されるかはわかりませんが、まあ変わることもないかなと。

サブフォルダの中にあるテストを、その中からgo testで実行しても、モジュールのルートからgo test ./…と実行しても、どちらもそのソースがあるフォルダが取得できました。

テスト時に読み込み対象のファイルを事前に置いておきつつ書き込みも同じフォルダでやるとかだとこの方式になるかと思います。

自分にあったtesting.T.TempDir()を作ってみる

testing.T.TempDir()はテストの中から使うにはお手軽ですが、僕の考える「検死可能な一時フォルダ」として使うには不便なのでそういうヘルパー関数を作ってみました。osパッケージのMkdirTemp()はビルドターゲットがjsやwasmのときのエラー処理とかもあるのですが、雑に実装はしております。

パッケージ名やテスト名などをつけた一時フォルダを作ります。t.Failed()でテストが失敗したかどうかがわかるので、その時はテスト完了時の処理の中で削除せずに残すという実装になっています。

package main

import (
"math/rand"
"os"
"path/filepath"
"runtime/debug"
"testing"
)

// ヘルパー
func MkdirForTest(t *testing.T, name string) string {
t.Helper()
i, ok := debug.ReadBuildInfo()
if !ok {
t.Fatal("cannot read build info")
}
workDir := filepath.Join(os.TempDir(), i.Path, t.Name(), name)
err := os.RemoveAll(workDir)
if err != nil {
t.Fatal(err)
}
err = os.MkdirAll(workDir, 0755)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if !t.Failed() {
os.RemoveAll(workDir)
} else {
t.Logf(" workDir: %s", workDir)
}
})
return workDir
}

// 利用例
func TestWriteFile(t *testing.T) {
workDir := MkdirForTest(t, "writefile")
// ファイルを作る
f, err := os.Create(filepath.Join(workDir, "test.txt"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
if rand.Intn(2) == 0 { // ランダムで失敗するテスト
t.Fatal("error happened")
}
}

一時フォルダができてしまえば、os.CopyFS()を使って、読み込みテストの対象のファイルを持ってきて、読み書き両方が必要なテストとかもできますね。

まとめ

最近はテストの書きやすさとかだけではなく、失敗した時の問題発見のしやすさ、修正のしやすさも大事だなと思って、設計に少しでも反映させられたらと思っています。そんな中、ファイルの読み書きを両方扱うテストを書くことがあり、しばらくはfs.FSを使って便利にテストしていたのですが、書き込みのテストで別の方法が必要になったのでやり方を調べたりしてました。

だいたい方針としては以下のような判断で適切な方法が選べるんじゃないかなと思います。

  • 読み込みのみ→go embedでfs.FS利用
  • 書き込みのみ→検死が必要なら今回紹介したようなロジックを実装、不要ならtesting.T.TempDir()
  • 読み書き両方必要
    • 読み書きのフォルダを分けても問題ないなら上記の2つの組み合わせ
    • 同じフォルダであるなら一時フォルダを作ってos.CopyFS()で必要なファイルを配置
    • 上記に該当するが.もしくは_で始まるファイル名などがあってうまくコピーできないならos.Getwd()