Go 言語(以下 golang)の
testing
で、関数がos.Exit(1)
するのをテストしたい。
func ExitIfNotZero(status int) {
if status > 0 {
os.Exit(status)
}
}
でも、テストで呼んじゃうとテストが終了してしまいます。
「"golang" test "os.Exit(1)"」でググってもドンピシャの記事がなかったので、自分のググラビリティとして。
TL; DR (今北産業)
-
関数を変数に代入して使う。オススメ
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.gopackage 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, ) } }
- オンラインで動作をみる(コメントなしバージョン) @ Go Playground
- この例だと osExit でキャプチャした後も実行が続行してしまいます。キャプチャ後に実行を止めたい場合は、
panic
を発生させ、テスト内でrecover()
を使ってエラーメッセージをキャプチャします
-
環境変数をフラグにして使う。
環境変数でフラグが立っていた場合のみ該当する関数を実行するようにテスト側で分岐させておき、処理を別プロセスで実行して、ステータスを取得する。- オンラインでサンプルの動作をみる @ Go Playground
-
上記の「環境変数をフラグにして使う」場合は、カバレッジには反映されないので注意。別プロセスでカバレッジを 100% にするには、いささか工夫が必要です。(記事反映準備中。コメントを参照のこと)
- オンラインでカバレッジ 100% のサンプルをみる @ paiza.IO
TS; DR (os.Exit()
を使っただけなのにテストに困ったコマケーこと)
Go では、アプリを終了する時以外は「基本的に os.Exit()
を使うのはよくない」とされます。特に goroutine
や defer
を使っている場合です。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 のユニットテスト機能 testing
で os.Exit(1)
する関数の動作テストをしたかったのです。
Golang には try
-catch
-finally
といった例外処理がないため、エラーを throw
するのではなく、戻り値に error
を返すのが一般的なようです。この時、俺様 error
インターフェースを実装して戻り値として返すのが応用が高いとされます。
- Go言語のエラーハンドリングについて @ Qiita
- 例外(exception)がない理由は? | FAQ @ Golang.jp
しかし、単純に os.Exit(1)
する関数の動作テストをするために実装するのかぁ、と思っていたところ中国の掲示板から「2014 年の Google I/O で言及されている」との情報を得ました。
「別プロセスで実行させる」という目から鱗の方法でした。これであれば、テストのヘルパー関数が t.Fatalf()
で終了するケースのテスト、つまりテストのテスト的なものにも使えます。
ヘルパー関数とは「テスト用の補助関数」です。つまり「テスト内(***_test.go
内)で定義されたテストにのみ使われるユーザ関数」のことです。
それではテストから別プロセスでテストを実行してみたいと思います。
別プロセスでテストを実行する
シンプルなサンプル
下記は sayonara()
がステータス 1(os.Exit(1)
)で終了することをテストするサンプルです。テストで自身を再帰的に呼び出しており、環境変数で動作・挙動を変更させている点に注目です。
package main
import (
"fmt"
"os"
)
// この関数の動作テストがしたい
func sayonara() {
fmt.Println("Sayonara!")
os.Exit(1)
}
func main() {
sayonara()
}
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)
}
- オンラインで動作をみる @ The Go Playground
問題は、この方法だとプロセスが別なためコードカバレッジに反映されないので網羅したことにならないんですよね。実際には網羅しているのに。
コメントを元に、カバレッジを 100% 網羅したサンプルは出来たのですが、やる気が帰って来ていないので準備中なので記事への反映はお待ちください。適宜、更新いたします。
- カバレッジ 100% のサンプルをオンラインでみる @ paiza.IO(いささか煩雑)
os.Exit()
をモック化する
その後、色々と触っていたら StackOverflow に os.Exit()
を関数に入れてモック化する方法がありました。
このテクニックは、外部パッケージのメソッド(struct などのオブジェクト関数)のモック化には使えませんが、関数であれば使えるので、こちらの方が圧倒的にテストを作成しやすかったです。特にテストのためだけにモック用のパッケージをインポートしたくない場合に、シンプルにテストできます。
// ExitIfNotZero は引数が 0(ゼロ)より大きい場合に、その値のステータスで os.Exit() します.
func ExitIfNotZero(status int) {
if status > 0 {
os.Exit(status)
}
}
var OsExit = os.Exit // 関数を変数に代入させてモック(なんちゃって os.Exit)を作る
// ExitIfNotZero は引数が 0(ゼロ)より大きい場合に、その値のステータスで os.Exit() します.
func ExitIfNotZero(status int) {
if status > 0 {
OsExit(status) // モックを代わりに使う
}
}
これは「関数もデータ型なので、変数に格納可能」という仕組みを使ったものです。
- 関数 | 他言語プログラマがgolangの基本を押さえる為のまとめ @ Qiita
テストはこちら。OsExit
に代入されていた関数をコピーしておき、テスト中は別の関数を割り当てていることに注目です。
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)
}
}
- オンラインで動作をみる @ Go Playground
参考文献
- Go言語のエラーハンドリングについて @ Qiita
- 例外(exception)がない理由は? | FAQ @ Golang.jp
- testing - 如何在Go中测试os.exit场景 @ coder.work (这是非常有用的信息。非常感谢你。)
- "Testing Techniques" P.23 by Andrew Gerrand @ Google I/O 2014