📝
Go で os.exit(1) する関数をユニットテストする
Go にはまだ深く入れ込んでいないが、いちいち迂遠なことを強いられている気がする。
要約
-
os.exit(1)
するような関数は、別プロセスで対応するテスト関数を動かす - プロセスから返ってきた終了ステータスをチェックして、
os.exit(1)
していたら OK とする
詳細
やりたかったこと
この世には os.Exit(1)
するような関数が存在する。それが Go の流儀に沿っているかはわからないが、とにかく存在する。ここでは以下のような関数があると仮定する。
func ExitWithZero() {
os.Exit(0)
}
func ExitWithOne() {
os.Exit(1)
}
これら2つに対するテストコードは、最も素朴な形では以下の通りになる。
ただ、TestExitWithZero
はパスするが TestExitWithOne
はパスしない。何故ならば ExitWithOne()
は終了ステータスとして 1
を返す(= 異常終了する)からである。
func TestExitWithZero(t *testing.T) {
ExitWithZero()
}
func TestExitWithOne(t *testing.T) {
ExitWithOne()
}
解決策
os.exit(1)
するテストを別プロセスで実行してやる。別プロセスで os.Exit(1)
する分にはメインのテストの失敗扱いにはならないので、これを上手いことラッピングしてやることでテストを設計できる。具体的には以下の通りになる。
- 環境変数(今回は
FLAG_BE_CRASHED
)が設定されている場合は、テスト対象関数を動かしてreturn
させる - テスト本体ではテスト関数を環境変数付きでの別プロセスで動かす
- 別プロセスから返ってきた終了ステータスが
1
であればテスト成功とする
サンプルコードは以下の通り。
func TestExitWithOne2(t *testing.T) {
if os.Getenv("FLAG_BE_CRASHED") == "1" {
ExitWithOne()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestExitWithOne2")
cmd.Env = append(os.Environ(), "FLAG_BE_CRASHED=1")
err := cmd.Run()
var e *exec.ExitError
if errors.As(err, &e) && !e.Success() {
return
}
log.Fatalf(err.Error())
}
うわぁ、複雑!
終わりに
調べが悪いだけかもしれないが、同じような回避方法は他の人も実践してるっぽい(資料1, 資料2)ので、そこそこ妥当な手法ではあるようだ。
ちなみに Python だと今回と同じことは以下のコードで済んでしまう。例外を捕捉することにはなるが、プロセスを生やすみたいな曲芸はしないので Go の場合よりだいぶ見通しが良いように思える。
import sys
def exit_with_one():
sys.exit(1)
def test_exit_with_one():
try:
exit_with_one()
except SystemExit as e:
assert e.code == 1
もし Go でも Python と同じぐらいの分量かつ明瞭さでテストを書けるって場合はコメントで具体的なパターンを教えてほしい。
Discussion
おっしゃられている
sys.exit
はcpythonのソースコードを見るとPyErr_SetObjectのwrapperです。Goの
os.Exit
は_exit(2)と同等のものです。より厳密にいうとsyscallの(linux/amd64では)exit_group(2)を呼び出します。
詳細
これはstubなので下記が呼び出されるはずです。
$SYS_exit_group
は直上で231として定義されています。下記によると231は
exit_group
です。下記によると
_exit(2)
がこれのglibcラッパーとなるとあります。調べてみると、pythonでこれと同等なのは
os._exit
であるようです。下記ソースからわかる通り、
_exit(2)
を呼び出すだけの単純なラッパーですね。うろ覚えですが、
Exit
,exit
はatexit(3)
でフックできるが、_Exit
,_exit
はできないんですよね。(atexit(3)のページに_exitが呼ばれたらatexitの関数は実行されないとある)ですのでフックする手段すらないはずです。
どちらにせよこの状態から通常の関数実行順序に戻ることはないでしょう。
という問いに対してはたぶんないというのが回答になると思いますが下記のいずれかの方法でユニットテストを成立させること自体はできると思います。
関数を変数に入れて起き、テスト内で差し替え
すでにリンクされている記事でご覧になっている通り、最も手軽なのはテスト環境で差し替えたい関数を変数として置いて起き、テスト内で差し替える方法です。
この方法は、同一のパッケージ内からしか関数の差し替えができないというデメリットがあります。
runtime.Goexitを呼び出さないと呼び出したgoroutineが終了せずにそのまま続くためおかしな感じになりますから、呼び出したほうがいいと思いますが、この場合でもdeferで登録してある関数は実行されるためどちらにせよなんか変な感じになるかもしれません。
panic-recoverで代用
もしくはpanic-recoverでも似たようなことができます。
(panicがrecoverされずにいずれかのgoroutineを終了させると結局exit code 1での終了になってしまうので注意が必要です。)
テストでは
handleExit
以外でこのエラーをrecoverで回収すれば成立します。ビルド時に差し替え
もし仮にpanic-recoverしたくなく、なおかつ別パッケージから関数を使用しながら挙動を変更する必要がある場合、以下の二つの方法があると思います
os/proc.go
を別ファイルに差し替えビルド時のファイルの差し替えは
--overlay
オプションをbuildを行うコマンドに渡すことでできます。つまり
$(go env GOROOT)/src/os/proc.go
をコピーし、Exit
関数の内容を差し替えたうえで{"Replace": {/*...*/}}
というjsonファイルで差し替えを指定したうえでgo test -overlay ./overlay.json ./path/to/test/target
とするわけです。テスト対象のソースに対する変更がゼロになるメリットがありますが、かなり大袈裟ですよね。この機能はエディターやlinterが保存されていない編集中のソースコードの型チェックなどを行うのに使われることが多いはずなので、我々が直接使う機会はあんまりないかもですね。
build constraintsを用いるには、下記のように、
//go:build <tag>
(//
とgo
の間にスペースを入れないように注意)のついた複数のファイルで、同じパッケージ下に同名のシンボルを定義します。この状態で、
go build
とするとsomething.go
の内容でビルドされ、go build -tags replaceexit
とするとsomething_replace.go
の内容でビルドされます。私の知る限りメジャーな方法はこんなところです。