LoginSignup
0
1

More than 1 year has passed since last update.

[Go] os.Stdin・os.Stdoutを用いた関数・メソッドのテスト

Last updated at Posted at 2022-03-17

テストケースの失敗例と解決策

Go言語でサポートされているテスト自動化ツールgo testにおいて,標準入出力を用いた関数・メソッドのテストを行う際にはひと工夫必要になる.

例として以下の関数をテストする場合を考える.

sample.go
/* パッケージ,インポート文は省略 */

// isFooは標準入力から文字列を受け取り,
// それが"Foo"と等しければtrueを,異なればfalseを返す.
// 比較の結果に加え,エラーが発生した場合はそれを,そうでなければnilを返す.
func isFoo() (bool, error) {

    // 標準入力を読み取るScannerを取得.
	scanner := bufio.NewScanner(os.Stdin)

    // スキャンを行い,エラーが有ればそれを返す.
	scanner.Scan()
	if err := scanner.Err(); err != nil {
		return false, err
	}

    // スキャンした文字列と"Foo"を比較した結果を返す.
	return scanner.Text() == "Foo", nil

isFoo()は呼び出されると標準入力を読み取り,入力文字列が文字列"Foo"と等しい場合にはtrueを返す.

このように標準入出力を利用する関数のテストは,単純に書いても意図した動作にならないことがある.以下がそのようなテストのコードである.

sample_test.go
/* パッケージ,インポート文は省略 */

func Test_isFoo_Bad1(t *testing.T) {

	// テストケースを用意する.
	tests := []struct {
		name    string
		want    bool
		wantErr bool
	}{
		{
			name:    "FooInput_returnTrueNil",
			want:    true,
			wantErr: false,
		},
		{
			name:    "nonFooInput_returnFalseNil",
			want:    false,
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {

			// isFoo()を呼び出すことで標準入力を読み取りたいが...
			got, err := isFoo()
			if (err != nil) != tt.wantErr {
				t.Errorf("isFoo() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if got != tt.want {
				t.Errorf("isFoo() = %v, want %v", got, tt.want)
			}
		})
	}
}

上記のテストを実行すると,

$ go test -v -run isFoo_Bad1
=== RUN   Test_isFoo_Bad1
=== RUN   Test_isFoo_Bad1/FooInput_returnTrueNil
    sample_test.go:260: isFoo() = false, want true
=== RUN   Test_isFoo_Bad1/nonFooInput_returnFalseNil
--- FAIL: Test_isFoo_Bad1 (0.00s)
    --- FAIL: Test_isFoo_Bad1/FooInput_returnTrueNil (0.00s)
    --- PASS: Test_isFoo_Bad1/nonFooInput_returnFalseNil (0.00s)
FAIL
exit status 1
FAIL    example.com/sample        0.002s

上の実行結果のように,入力を受け付ける間もなくテストが終了する.
それにもかかわらず,Scanner.Err()は何事も無かったかのようにnilを返している.

今度は上記のテストケースに変更を加え,テスト内から直接標準入力に入力をさせてみる.

sample_test.go
/* パッケージ,インポート文は省略 */

func Test_isFoo_Bad2(t *testing.T) {

	// テストケースを用意する.
	tests := []struct {
		name string

		// 標準入力へ入力する文字列
		input   string
		want    bool
		wantErr bool
	}{
		{
			name:    "FooInput_returnTrueNil",
			input:   "Foo",
			want:    true,
			wantErr: false,
		},
		{
			name:    "nonFooInput_returnFalseNil",
			input:   "foo",
			want:    false,
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {

			// テスト内から直接標準入力へ入力してみる.
			fmt.Fprintln(os.Stdout, tt.input)
			got, err := isFoo()
			if (err != nil) != tt.wantErr {
				t.Errorf("isFoo() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if got != tt.want {
				t.Errorf("isFoo() = %v, want %v", got, tt.want)
			}
		})
	}
}

先程と同様にテストを実行すると.

$ go test -v -run isFoo_Bad2
=== RUN   Test_isFoo_Bad2
=== RUN   Test_isFoo_Bad2/FooInput_returnTrueNil
Foo
    sample_test.go:300: isFoo() = false, want true
=== RUN   Test_isFoo_Bad2/nonFooInput_returnFalseNil
foo
--- FAIL: Test_isFoo_Bad2 (0.00s)
    --- FAIL: Test_isFoo_Bad2/FooInput_returnTrueNil (0.00s)
    --- PASS: Test_isFoo_Bad2/nonFooInput_returnFalseNil (0.00s)
FAIL
exit status 1
FAIL    example.com/sample        0.002s

入力されている形跡はあるが,こちらも失敗する.
isFoo()の最後のreturnの前に,fmt.Fprintf(os.Stderr, "string scanned: \"%v\"\n", scanner.Text())として,標準エラーにスキャンの結果を出力して確認すると,

$ go test -run isFoo_Bad2 -v
=== RUN   Test_isFoo_Bad2
=== RUN   Test_isFoo_Bad2/FooInput_returnTrueNil
Foo

string scanned: ""
    sample_test.go:302: isFoo() = false, want true
=== RUN   Test_isFoo_Bad2/nonFooInput_returnFalseNil
foo

string scanned: ""
--- FAIL: Test_isFoo_Bad2 (0.00s)
    --- FAIL: Test_isFoo_Bad2/FooInput_returnTrueNil (0.00s)
    --- PASS: Test_isFoo_Bad2/nonFooInput_returnFalseNil (0.00s)
FAIL
exit status 1
FAIL    example.com/sample        0.002s

上の実行結果のように,isFoo()側では空文字列""しか読み取れていない.
どうやらテストの実行中は,標準入出力が通常とは異なる挙動を取ると推測される.

この問題への対処法として,テストの実行中だけos.Stdinos.Stdoutを別のReaderWriterに差し替えることで,テスト対象の関数を書き換えることなくテストが行える.
具体的には以下の通り.

sample_test.go
/* パッケージ,インポート文は省略 */

func Test_isFoo_Good(t *testing.T) {

	// テスト用に一時的なReader,Writerを取得する.
	r, w, err := os.Pipe()
	if err != nil {
		t.Fatalf("os.Pipe(): %v", err)
	}

	// os.Stdin,os.Stdoutを取得したReader,Writerと差し替え,テスト終了時に復元する.
	osStdin, osStdout := os.Stdin, os.Stdout
	os.Stdin, os.Stdout = r, w
	defer func() { os.Stdin, os.Stdout = osStdin, osStdout }()

	// テストケースを用意する.入力は末尾の改行'\n'を忘れずに!
	tests := []struct {
		name    string
		input   string
		want    bool
		wantErr bool
	}{
		{
			name:    "FooInput_returnTrueNil",
			input:   "Foo\n",
			want:    true,
			wantErr: false,
		},
		{
			name:    "nonFooInput_returnFalseNil",
			input:   "foo\n",
			want:    false,
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {

			// Writer.Write()に渡せるよう,入力を[]byteに変換する.
			input := []byte(tt.input)

			// 標準出力へ書き込む.
			if n, err := os.Stdout.Write(input); err != nil {
				t.Errorf("input is %v bytes, but only %v byte written", len(input), n)
				return
			}

			got, err := isFoo()
			if (err != nil) != tt.wantErr {
				t.Errorf("isFoo() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if got != tt.want {
				t.Errorf("isFoo() = %v, want %v", got, tt.want)
			}
		})
	}
}

テストを実行すると,

$ go test -run isFoo_Good -v
=== RUN   Test_isFoo_Good
=== RUN   Test_isFoo_Good/FooInput_returnTrueNil
string scanned: "Foo"
=== RUN   Test_isFoo_Good/nonFooInput_returnFalseNil
string scanned: "foo"
--- PASS: Test_isFoo_Good (0.00s)
    --- PASS: Test_isFoo_Good/FooInput_returnTrueNil (0.00s)
    --- PASS: Test_isFoo_Good/nonFooInput_returnFalseNil (0.00s)
PASS
ok      example.com/sample        0.002s

テストは全てパスし,isFoo()側からも正しくスキャンできていることがわかる.

(終わり)

0
1
0

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
0
1