LoginSignup
8
3

【Golang】関数の "os.Exit(1)" エラー終了をテストする(testing & 別プロセスで実行またはモック化のたぐい)

Last updated at Posted at 2020-10-19

Go 言語(以下 golang)の testing で、関数が os.Exit(1) するのをテストしたい

例えばこんなのをテストしたい
func ExitIfNotZero(status int) {
	if status > 0 {
		os.Exit(status)
	}
}

でも、テストで呼んじゃうとテストが終了してしまいます。

「"golang" test "os.Exit(1)"」でググってもドンピシャの記事がなかったので、自分のググラビリティとして。

TL; DR (今北産業)

  1. 関数を変数に代入して使う。オススメ
    os.Exit() を変数に入れて使い、テスト中に代わりの関数を代入する。このような手法を monkey patchingモンキーパッチ)とも呼ぶらしい。🐒

    main.go
    // osExit は `os.Exit` のコピーです. os.Exit のテストをしやすくするた
    // めのものです.
    var osExit = os.Exit
    
    // ExitIfNotZero は status が 0 ではない場合 os.Exit(status) で終了
    // します.
    func ExitIfNotZero(status int) {
    	if status > 0 {
    		osExit(status)
    	}
    }
    
    main_test.go
    package main
    
    import (
    	"os"
    
    	"testing"
    )
    
    func TestExitIfNotZero(t *testing.T) {
    	// osExit のバックアップと defer でリカバー	
    	oldOsExit := osExit
    	defer func() { osExit = oldOsExit }()
    
    	// あとで OsExit 内で終了ステータスをキャプチャするための変数
    	var capture int
    
    	// osExit をモック(ダミーの関数を割り当てて別の挙動にする)。
        //
    	// 【詳細】
    	// 本来は os.Exit(code) するところを capture に割り当てる
    	// だけ。capture の値が変われば osExit まで到達したことにな
    	// る。
    	//
    	// この monkey patching (変数に代入しておき、テスト中に入
        // れ替えるモック手法)は os.Exit に限らず os.Stdin、
        // os.Args、json.Marshal などでも使える。
    	// 
    	// 利用しているモジュールの関数の挙動に依存しており、テスト中に挙
    	// 動を変える必要がある場合は、コピーした方を使うようにしておいた
    	// 方が楽にテストしやすくなる。つまり Dependency injection
        // に近い考え方。
        //
        // 【注意点】 テスト中にグローバル変数を置き換る(mock する)テスト
        // では t.Parallel() を使ったテストの並列実行はできません。
    	osExit = func(code int) { capture = code }
    
    	// テスト用(引数)のデータ
    	testStatus := 10
    
    	// テスト対象の関数を実行
    	ExitIfNotZero(testStatus)
    
    	// アサーション(期待する結果の検証)
    	// capture の値が 0(初期値)ではなく exptect の値に変わって
    	// いれば、ExitIfNotZero の OsExit 内までテストが到達したこ
    	// とになり、カバレッジにも反映される。
    	expect := testStatus // 10
    	actual := capture
    
    	if expect != actual {
    		t.Errorf(
    			"Fail assert equal. Expect: %v Actual: %v",
    			 expect, actual,
    		)
    	}
    }
    
  2. 環境変数をフラグにして使う。
    環境変数でフラグが立っていた場合のみ該当する関数を実行するようにテスト側で分岐させておき、処理を別プロセスで実行して、ステータスを取得する。

  3. 上記の「環境変数をフラグにして使う」場合は、カバレッジには反映されないので注意。別プロセスでカバレッジを 100% にするには、いささか工夫が必要です。(記事反映準備中。コメントを参照のこと)

TS; DR os.Exit() を使っただけなのにテストに困ったコマケーこと)

Go では、アプリを終了する時以外は「基本的に os.Exit() を使うのはよくない」とされます。特に goroutinedefer を使っている場合です。os.Exit() すると、即終了するので defer が発火されないからです。

ここでは、以下のように main() 関数内のみで os.Exit() を利用し、それ以下の階層にある関数は error を返すことでテストしやすいようにすることを前提としています。

func main() {
    if err := run(); err != nil {
        fmt.Fprint(os.Stderr, err.Error())
        os.Exit(1)
    }
}

func run() error {
    // do something

    return errors.New("something went wrong")
}

Golang のユニットテスト機能 testingos.Exit(1) する関数の動作テストをしたかったのです。

Golang には try-catch-finally といった例外処理がないため、エラーを throw するのではなく、戻り値に error を返すのが一般的なようです。この時、俺様 error インターフェースを実装して戻り値として返すのが応用が高いとされます。

しかし、単純に os.Exit(1) する関数の動作テストをするために実装するのかぁ、と思っていたところ中国の掲示板から「2014 年の Google I/O で言及されている」との情報を得ました。

「別プロセスで実行させる」という目から鱗の方法でした。これであれば、テストのヘルパー関数が t.Fatalf() で終了するケースのテスト、つまりテストのテスト的なものにも使えます。

ヘルパー関数とは「テスト用の補助関数」です。つまり「テスト内(***_test.go 内)で定義されたテストにのみ使われるユーザ関数」のことです。

それではテストから別プロセスでテストを実行してみたいと思います。

別プロセスでテストを実行する

シンプルなサンプル

下記は sayonara() がステータス 1(os.Exit(1))で終了することをテストするサンプルです。テストで自身を再帰的に呼び出しており、環境変数で動作・挙動を変更させている点に注目です。

main.go
package main

import (
	"fmt"
	"os"
)

// この関数の動作テストがしたい
func sayonara() {
	fmt.Println("Sayonara!")
	os.Exit(1)
}

func main() {
	sayonara()
}

main_test.go
package main

import (
	"os"
	"os/exec"
	"testing"
)

// 再帰的に Test_sayonara を呼び出す
func Test_sayonara(t *testing.T) {
	// 環境変数 "FLAG_RUN_SAYONARA" のフラグが立っていた場合に sayonara() を実行させる
	if os.Getenv("FLAG_RUN_SAYONARA") == "1" {
		sayonara()
		return
	}

	// 外部プロセスの実行コマンド設定
	var cmd = exec.Command(os.Args[0], "-test.run=Test_sayonara")

	// 外部プロセス実行時の環境変数をセット(FLAG_RUN_SAYONARA -> 1)
	cmd.Env = append(os.Environ(), "FLAG_RUN_SAYONARA=1")

	// 外部プロセスの外部実行
	var err = cmd.Run()

	// 外部プロセスの実行結果取得。エラーの場合は正常終了
	if e, ok := err.(*exec.ExitError); ok && !e.Success() {
		return
	}

	// 実行ステータスがエラーでない(ステータスが 0)の場合は Fail させる
	t.Fatalf("process ran with err %v, want exit status 1", err)
}

問題は、この方法だとプロセスが別なためコードカバレッジに反映されないので網羅したことにならないんですよね。実際には網羅しているのに。

コメントを元に、カバレッジを 100% 網羅したサンプルは出来たのですが、やる気が帰って来ていないので準備中なので記事への反映はお待ちください。適宜、更新いたします。

os.Exit() をモック化する

その後、色々と触っていたら StackOverflow に os.Exit() を関数に入れてモック化する方法がありました。

このテクニックは、外部パッケージのメソッド(struct などのオブジェクト関数)のモック化には使えませんが、関数であれば使えるので、こちらの方が圧倒的にテストを作成しやすかったです。特にテストのためだけにモック用のパッケージをインポートしたくない場合に、シンプルにテストできます。

変更前@main.go
// ExitIfNotZero は引数が 0(ゼロ)より大きい場合に、その値のステータスで os.Exit() します.
func ExitIfNotZero(status int) {
	if status > 0 {
		os.Exit(status)
	}
}
変更後@main.go
var OsExit = os.Exit // 関数を変数に代入させてモック(なんちゃって os.Exit)を作る

// ExitIfNotZero は引数が 0(ゼロ)より大きい場合に、その値のステータスで os.Exit() します.
func ExitIfNotZero(status int) {
	if status > 0 {
		OsExit(status) // モックを代わりに使う
	}
}

これは「関数もデータ型なので、変数に格納可能」という仕組みを使ったものです。

  • 関数 | 他言語プログラマがgolangの基本を押さえる為のまとめ @ Qiita

テストはこちら。OsExit に代入されていた関数をコピーしておき、テスト中は別の関数を割り当てていることに注目です。

main_test.go
func TestExitIfNotZero(t *testing.T) {
	oldOsExit := OsExit // 変数内の値(os.Exit関数)をコピー

	defer func() { OsExit = oldOsExit }() // テスト終了後に元に戻しておく

	var capture int // OsExit に到達したかチェックするための変数を用意

	OsExit = func(code int) { capture = code } // テスト用のダミー関数をモックに代入

	testStatus := 10 // テスト用の引数

	ExitIfNotZero(testStatus) // テストの実行

	// Assert equal
	expect := testStatus // 10
	actual := capture

	if expect != actual {
		t.Errorf("Fail assert equal. Expect: %v Actual: %v", expect, actual)
	}
}

参考文献

8
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3