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

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

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

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

[「"golang" test "os.Exit(1)"」でググって](https://www.google.com/search?q=%22golang%22+test+%22os.Exit(1)%22&tbs=qdr:y)もドンピシャの記事がなかったので、自分のググラビリティとして。

## TL; DR <sub><sup><sub><sup>（今北産業）</sup></sub></sup></sub>

1. **関数を変数に代入して使う。**<sup><font color=red>オススメ</font></sup><br>`os.Exit()` を変数に入れて使い、テスト中に代わりの関数を代入する。このような手法を [monkey patching](https://www.google.com/search?q=monkey+patching+in+Go)（[モンキーパッチ](https://ja.wikipedia.org/wiki/%E3%83%A2%E3%83%B3%E3%82%AD%E3%83%BC%E3%83%91%E3%83%83%E3%83%81)）とも呼ぶらしい。🐒

	```go: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)
		}
	}
	```

	```go: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,
			)
		}
	}
	```
	- [オンラインで動作をみる（コメントなしバージョン）](https://go.dev/play/p/ehQFz9djEKi) @ Go Playground
	- この例だと osExit でキャプチャした後も実行が続行してしまいます。<font color="darkorange">**キャプチャ後に実行を止めたい**</font>場合は、`panic` を発生させ、テスト内で `recover()` を使ってエラーメッセージをキャプチャします
      - [オンラインで動作をみる（panicバージョン）](https://go.dev/play/p/tYRtViAfBgc) @ Go 

2. **環境変数をフラグにして使う。**<br>環境変数でフラグが立っていた場合のみ該当する関数を実行するようにテスト側で分岐させておき、処理を別プロセスで実行して、ステータスを取得する。
    - [オンラインでサンプルの動作をみる](https://play.golang.org/p/RtFM-VrrVm2) @ Go Playground
2. 上記の「環境変数をフラグにして使う」場合は、<font color=darkorange>カバレッジには反映されない</font>ので注意。別プロセスでカバレッジを 100% にするには、いささか工夫が必要です。（記事反映準備中。[コメント](#comment-2f55c169b593fb6733be)を参照のこと）
    - [オンラインでカバレッジ 100% のサンプルをみる](https://paiza.io/projects/jcHdWpFPWZZigI2NZ_Ro5A) @ paiza.IO

## TS; DR <sub><sup><sub><sup>（`os.Exit()` を使っただけなのにテストに困ったコマケーこと）</sup></sub></sup></sub>

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

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

```go
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 の[ユニットテスト](https://ja.wikipedia.org/wiki/%E5%8D%98%E4%BD%93%E3%83%86%E3%82%B9%E3%83%88)機能 `testing` で `os.Exit(1)` する関数の動作テストをしたかったのです。

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

- [Go言語のエラーハンドリングについて](https://qiita.com/nayuneko/items/3c0b3c0de9e8b27c9548) @ Qiita
- [例外(exception)がない理由は?](http://golang.jp/go_faq#exceptions) | FAQ @ Golang.jp



しかし、単純に `os.Exit(1)` する関数の動作テストをするために実装するのかぁ、と思っていたところ[中国の掲示板](https://www.coder.work/article/24812)から「[2014 年の Google I/O で言及](https://talks.golang.org/2014/testing.slide#23)されている」との情報を得ました。

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

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

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

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

#### シンプルなサンプル

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

```go:main.go
package main

import (
	"fmt"
	"os"
)

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

func main() {
	sayonara()
}

```
```go: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)
}

```

- [オンラインで動作をみる](https://play.golang.org/p/RtFM-VrrVm2) @ The Go Playground

問題は、この方法だとプロセスが別なため[コードカバレッジ](https://ja.wikipedia.org/wiki/%E3%82%B3%E3%83%BC%E3%83%89%E7%B6%B2%E7%BE%85%E7%8E%87)に反映されないので網羅したことにならないんですよね。実際には網羅しているのに。

[コメント](#comment-2f55c169b593fb6733be)を元に、カバレッジを 100% 網羅した[サンプルは出来た](https://paiza.io/projects/jcHdWpFPWZZigI2NZ_Ro5A)のですが、~~やる気が帰って来ていないので~~準備中なので記事への反映はお待ちください。適宜、更新いたします。

- [カバレッジ 100% のサンプルをオンラインでみる](https://paiza.io/projects/jcHdWpFPWZZigI2NZ_Ro5A) @ paiza.IO（いささか煩雑）

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

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

- [Testing os.Exit scenarios in Go with coverage information (coveralls.io/Goveralls)](https://stackoverflow.com/a/40801733/8367711) @ StackOverflow

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

```golang:変更前@main.go
// ExitIfNotZero は引数が 0（ゼロ）より大きい場合に、その値のステータスで os.Exit() します.
func ExitIfNotZero(status int) {
	if status > 0 {
		os.Exit(status)
	}
}
```

```golang:変更後@main.go
var OsExit = os.Exit // 関数を変数に代入させてモック（なんちゃって os.Exit）を作る

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

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

- [関数](https://qiita.com/tfrcm/items/e2a3d7ce7ab8868e37f7#%E9%96%A2%E6%95%B0) | 他言語プログラマがgolangの基本を押さえる為のまとめ @ Qiita

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

```golang: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)
	}
}

```

- [オンラインで動作をみる](https://play.golang.org/p/N1FmYf2UYeY) @ Go Playground

## 参考文献

- [Go言語のエラーハンドリングについて](https://qiita.com/nayuneko/items/3c0b3c0de9e8b27c9548) @ Qiita
- [例外(exception)がない理由は?](http://golang.jp/go_faq#exceptions) | FAQ @ Golang.jp
- [testing - 如何在Go中测试os.exit场景](https://www.coder.work/article/24812) @ coder.work （这是非常有用的信息。非常感谢你。）
- ["Testing Techniques" P.23](https://talks.golang.org/2014/testing.slide#23) by Andrew Gerrand @ Google I/O 2014