📝

Go で os.exit(1) する関数をユニットテストする

2025/02/22に公開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) する分にはメインのテストの失敗扱いにはならないので、これを上手いことラッピングしてやることでテストを設計できる。具体的には以下の通りになる。

  1. 環境変数(今回は FLAG_BE_CRASHED )が設定されている場合は、テスト対象関数を動かして return させる
  2. テスト本体ではテスト関数を環境変数付きでの別プロセスで動かす
  3. 別プロセスから返ってきた終了ステータスが 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

ngicksngicks

おっしゃられているsys.exitはcpythonのソースコードを見るとPyErr_SetObjectのwrapperです。

https://github.com/python/cpython/blob/v3.13.2/Python/sysmodule.c#L865-L886

Goのos.Exit_exit(2)と同等のものです。
より厳密にいうとsyscallの(linux/amd64では)exit_group(2)を呼び出します。

詳細

調べてみると、pythonでこれと同等なのはos._exitであるようです。
下記ソースからわかる通り、_exit(2)を呼び出すだけの単純なラッパーですね。

https://github.com/python/cpython/blob/v3.13.2/Modules/posixmodule.c#L6685-L6699

うろ覚えですが、Exit, exitatexit(3)でフックできるが、 _Exit, _exitはできないんですよね。(atexit(3)のページに_exitが呼ばれたらatexitの関数は実行されないとある)

ですのでフックする手段すらないはずです。
どちらにせよこの状態から通常の関数実行順序に戻ることはないでしょう。

もし Go でも Python と同じぐらいの分量かつ明瞭さでテストを書けるって場合はコメントで具体的なパターンを教えてほしい。

という問いに対してはたぶんないというのが回答になると思いますが下記のいずれかの方法でユニットテストを成立させること自体はできると思います。

関数を変数に入れて起き、テスト内で差し替え

すでにリンクされている記事でご覧になっている通り、最も手軽なのはテスト環境で差し替えたい関数を変数として置いて起き、テスト内で差し替える方法です。

この方法は、同一のパッケージ内からしか関数の差し替えができないというデメリットがあります。

something.go
package something

import "os"

var (
    osExit = os.Exit
)
something_test.go
package something

import (
	"runtime"
	"sync/atomic"
)

var (
    exitCode = atomic.Int64{}
)

func init() {
    osExit = func(code int) {
    	exitCode.Store(int64(code))
    	runtime.Goexit()
    }
}

runtime.Goexitを呼び出さないと呼び出したgoroutineが終了せずにそのまま続くためおかしな感じになりますから、呼び出したほうがいいと思いますが、この場合でもdeferで登録してある関数は実行されるためどちらにせよなんか変な感じになるかもしれません。

panic-recoverで代用

もしくはpanic-recoverでも似たようなことができます。
(panicがrecoverされずにいずれかのgoroutineを終了させると結局exit code 1での終了になってしまうので注意が必要です。)

package main

import (
	"errors"
	"fmt"
	"os"
)

type ExitError int

func (e ExitError) Error() string {
	return fmt.Sprintf("exit code: %d", e)
}

func handleExit() {
	rec := recover()
	if rec == nil {
		return
	}
	err, ok := rec.(error)
	if !ok {
		panic(rec)
	}
	var exitErr ExitError
	if !errors.As(err, &exitErr) {
		panic(rec)
	}
	os.Exit(int(exitErr))
}

func main() {
	defer handleExit()

	panic(ExitError(5))
}

テストではhandleExit以外でこのエラーをrecoverで回収すれば成立します。

ビルド時に差し替え

もし仮にpanic-recoverしたくなく、なおかつ別パッケージから関数を使用しながら挙動を変更する必要がある場合、以下の二つの方法があると思います

  • ビルド時にos/proc.goを別ファイルに差し替え
  • build constraintsでビルド時に呼び出す関数を切り替える

ビルド時のファイルの差し替えは--overlayオプションをbuildを行うコマンドに渡すことでできます。

https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies

-overlay file
read a JSON config file that provides an overlay for build operations.
The file is a JSON struct with a single field, named 'Replace', that
maps each disk file path (a string) to its backing file path, so that
a build will run as if the disk file path exists with the contents
given by the backing file paths, or as if the disk file path does not
exist if its backing file path is empty. Support for the -overlay flag
has some limitations: importantly, cgo files included from outside the
include path must be in the same directory as the Go package they are
included from, and overlays will not appear when binaries and tests are
run through go run and go test respectively.

つまり$(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の間にスペースを入れないように注意)のついた複数のファイルで、同じパッケージ下に同名のシンボルを定義します。

something.go
//go:build !replaceexit

package something

import "os"

func Exit(code int) {
    os.Exit(code)
}
something_replace.go
//go:build replaceexit

package something

func Exit(code int) {
    // ...do whatever...
}

この状態で、go buildとするとsomething.goの内容でビルドされ、go build -tags replaceexitとするとsomething_replace.goの内容でビルドされます。

私の知る限りメジャーな方法はこんなところです。