📝

そのテストどこまで書きますか?

2020/12/13に公開

この記事は Recruit Engineers Advent Calender 2020 13日目の記事です。

はじめに

こんにちは、今年の春に新卒で入社した hatobus です。

今年もadvent calenderの季節がやってまいりました。
会社のslackでいろいろな方のtimesを徘徊していたところ、今年のadvent calenderに空きがありちょっと書いてみようかなというノリで登録したところ言質を取られてしまったのできちんと書こうと思います。

昨今のコロナ禍の中で入社したリクルートのエンジニアコースの新人たちは、例に漏れずフルリモートでの研修を体験しました。その中で、毎年行われているプログラミング言語Go翻訳者の1人である 柴田芳樹さんの Go研修 もフルリモートで開催されています。

柴田さんのGo研修では以下のような進め方で各自自習をしてから臨むような形になっています

  • Go研修当日までに予習する範囲になっている部分を読んでくる
  • 各所に存在する練習問題を解いてくる
  • 練習問題に関しては、テストを書けるものはテストコードも書いて一緒に提出する

これを全6回の研修で取り組み、 プログラミング言語Go を一冊読み切るというスタンスで研修が行われており、本を読んで疑問に思ったことや質問を講義パートで柴田さんに質問をするという流れで研修が行われています。

自分としては、Goについてはある程度慣れや親しみのある言語ですが、今ある知識をより深く、より広くするためにもこの研修に参加することを決めました。

今回はそのGo研修の一部から、自分がやっているGo関係のテストについての記事になります。

そもそもの話 どうしてテストを書くんですか?

テストを書く意味

そもそも、テストというのは何のために書くのでしょうか?
テストを書くモチベーションとしては主に以下の2つが考えられます。 (個人の考えです)

  1. テストコードは設計書の1つ
  2. リファクタリングやプロダクトを進化させるための足場

1. テストコードは設計書の一つ

ここで一つ考えてみましょう。
あなたはこの度新たなプロジェクトにアサインされました。これはある程度リリースから時間が経ったプロダクトで、新たな機能を開発するにあたってあなたが助っ人として呼び出されてきました。

-- プロダクトにアサインされた当日

プロジェクトリーダー「今日からよろしくお願いします!こちらコードとドキュメントです!」

slackでGHEとドキュメントのURLが送られてきました。早速コードを読んで、どんな処理がされているかを軽く頭に入れましょう。

... ...

コードを読んでいきましたが、どうやらテストコードは存在しないようです。

この状態で、プログラムは本当に正しく動いているのは分かるでしょうか?また、どの部分がどの処理を行っているのでしょうか?

ドキュメントを読めばどこに何があるかは分かるかもしれません。しかし、動作の方は実際に動かさないとわかりそうにもありません、そもそも考慮されていないケースや潜在的なバグも潜んでいるかもしれません。

もし、テストコードがあったら?

-- テストコードのある世界線

ここで、 テストコードは設計書の1つ という言葉が生きてきます。

テストコードは「Aが入力されたらBが出力される」ことを証明できるものです。テストが通っていれば、テスト対象の関数群にAという入力をしたらBという出力が出てくるというのが確約されているはずです。

つまり、テストコードを眺めれば、それが何をする関数/コードなのかがわかる手助けになります。

2. リファクタリングやプロダクトを進化させるための足場

機能のエンハンスや処理のリファクタリングをするというときに、何を元にコードを変更していけばいいでしょうか?テストコードがあれば、なにか変更した後にテストコードを動かせば動作が都度確認できて便利そうですね。

2年ほど前の記事ですが、 t_wada さんが仰っていたことがこれの説明になっています。

https://twop.agile.esm.co.jp/what-do-we-need-for-growth-of-future-65c43b5a8fe2

私はこういうコードを書いたからテストコードもこうなっておりますみたいな話になっちゃっているんだけど、そうじゃなくて、そこから先に行くための足場になってほしいのがテストコードなんですよね。

自分が書いたコードは、書いた当時どのように上手くいっていたとしてもいずれ負債となり、新たなコードに置き換えられたりリファクタリングされるという運命をたどります。そのようなとき、テストコードはそのときにあるコードを書き直すための補助として有効活用されるでしょう。

リファクタリングをしたあとにテストコードを実行して、テストが通るままで修正に間違いはないかを確認しながら行っていくのがリファクタリングの筋道となります。

https://speakerdeck.com/rtechkouhou/jian-tewakaru-tesutoqu-dong-kai-fa?slide=10

また、t_wadaさんは

私はこういうコードを書いたからテストコードもこうなっておりますみたいな話になっちゃっているんだけど、そうじゃなくて、そこから先に行くための足場になってほしいのがテストコードなんですよね。まだ見ぬ良い設計を追い求めるための足場になってほしいのがテストコードなんですよね

とも仰っており、プロダクトの成長や開発のためにはテストコードが重要ということを示しています。

プロダクトを成長させるための足場であり、開発者の理解の手助けになるようなものであることがテストコードには求められています。

テストを書こう

そう考えていた矢先に舞い込んできたGo研修です。

問題を解き、テストコードを書けるだけ書くというのが毎回のお題として存在しており、自分の中でテストコードを書けるだけ書くという挑戦が始まりました。

https://github.com/hatobus/go-training

この記事では、問題を解く中でいくつかのテストのパターンをコード付きで紹介していこうと思います。

独自定義したpackageのテスト

package main 以外のテストです。

package hoge として切った hoge package をテストするときには、通常書いているテストとは変わりはありません。

プログラミング言語Goの練習問題5.9を例に取ります。

練習問題 5.9 文字列 s 内のそれぞれの部分文字列 "$foo" を f("foo") が返すテキストで置換する関数を書きなさい。

これは、文中の $ で始まる任意の単語を探し、 $ 以降の文字列で関数 f を呼び出した結果に文字を置換するようにするプログラムです。

https://github.com/hatobus/go-training/blob/master/ch05/ex5_9/expand.go

package ex5_9

import (
	"regexp"
	"strings"
)

var pattern = regexp.MustCompile(`\$\w+`)

func Expand(s string, f func(string) string) string {
	return pattern.ReplaceAllStringFunc(s, func(s string) string {
		var dst string
		if strings.HasPrefix(s, "$") {
			dst = s[1:]
		} else {
			dst = s
		}
		return f(dst)
	})
}

少し卑怯な手かもしれませんが、Goのregexpパッケージに存在する ReplaceAllStringFunc という関数を使うとすぐに実現できます。

https://golang.org/pkg/regexp/#Regexp.ReplaceAllStringFunc

テストコードは以下の通り。何の変哲もないテーブルドリブンテストです。

package ex5_9

import (
	"sort"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"
)

func TestExpand(t *testing.T) {
	testData := map[string]struct {
		srcString        string
		genReplaceString func(string) string
		expectString     string
	}{
		"引っかかった文字を大文字に変換する": {
			srcString: "abc$def",
			genReplaceString: func(s string) string {
				return strings.ToUpper(s)
			},
			expectString: "abcDEF",
		},
		"引っかかった文字を辞書順に並び替え": {
			srcString: "abc$dbaetc",
			genReplaceString: func(s string) string {
				ss := strings.Split(s, "")
				sort.Strings(ss)
				return strings.Join(ss, "")
			},
			expectString: "abcabcdet",
		},
		"引っかかった文字を削除する": {
			srcString: "abc$def",
			genReplaceString: func(s string) string {
				return ""
			},
			expectString: "abc",
		},
		"引っかからなかったらそのまま": {
			srcString: "abcdef",
			genReplaceString: func(s string) string {
				return strings.ToUpper(s)
			},
			expectString: "abcdef",
		},
	}

	for testName, tc := range testData {
		// 5.6.1 レキシカルスコープ規則
		tc := tc
		t.Run(testName, func(t *testing.T) {
			t.Parallel()

			out := Expand(tc.srcString, tc.genReplaceString)

			if diff := cmp.Diff(tc.expectString, out); diff != "" {
				t.Fatalf("invalid output, diff: %v", diff)
			}
		})
	}
}

できるだけすぐ終わらせたいために、テーブルドリブンテストを並列に実行しています。

tc := tc という表記に戸惑う方がいるかもしれませんが、Goでは、ループによって作成されるすべての関数地は、同じ変数を「捕捉」して、共有します。つまり、そのループ内部で使われている瞬間の値ではなく、アドレス化可能なメモリの位置が共有されており、 tc の値は引き続き行われているループで更新されています。
もし、この表記が存在しなかった場合には同じメモリの位置を見に行くことになり、1つのテストケースが並列に実行されることになり、正しくテストが行えないことになります。

つまり、 tc := tc はサブテストを作成するfor文内部で tc という変数を宣言し、それを外側の tc で初期化してループ内で使えるようにします。

並列にテストを行うためにスコープの規則に気をつける必要がありますが、基本的にはこのようにテストを行っています。

main packageのテスト

次はmain packageのテストです。

main packageの場合には引数や標準入出力の概念が存在し、外からそれを適切に入力してあげる必要がありそうです。これはどうやって解決しましょう...?

https://github.com/hatobus/go-training/tree/b4a83984d0ceb265f0cbe379805ef3d16106e92a/ch04/ex4_1/ex4.2

練習問題 4.2 デフォルトで標準入力のSHA256ハッシュを表示するプログラムを書きなさい。ただし、SHA384ハッシュやSHA512ハッシュを表示するコマンドラインのフラグもサポートしなさい

標準入力で入力された文字を指定された方法でハッシュ化し、標準出力に出力するという問題です。この問題のテストをしたいと考えた時、以下のような動作が必要そうです。

  1. ハッシュ化アルゴリズムを指定するフラグを入力してコマンドを動かす
  2. テストする文字を標準入力へ入力する
  3. 標準出力が正しいものかどうか調べる

フラグを指定してコマンドを動かす

今回自分が書いたプログラムでは -type の後にハッシュ化アルゴリズムを指定することができます

  • SHA-256を使う場合 ... sha256
  • SHA-384を使う場合 ... sha384
  • SHA-512を使う場合 ... sha512
  • デフォルト(-typeを指定しない) ... SHA-256

つまり、 go run sha256_cmd.go -type sha512 としてコマンドを動かすと、標準入力で入力された文字列をSHA-512でハッシュ化したものに変換して出力するというプログラムが起動します。

goから外部コマンドを実行するには os/exec パッケージが便利です。

https://golang.org/pkg/os/exec/

これを使って、実際に引数を追加して sha256_cmd.go を起動できるようにします。

mode := "sha512"
args := []string{"run", "sha256_cmd.go"}

if tc.mode != "" {
	args = append(args, "-type", mode)
}

cmd := exec.Command("go", args...)

これで go run sha256_cmd.go -type sha512 というコマンドを起動できるようになりました。

exec.Commandの第一引数はコマンド名で、第二引数は可変長引数になっており、オプションを任意の数持てるようになっています。そのため、 argsargs... としてargsのスライスを展開して渡しています。

ひとまずSTEP1である ハッシュ化アルゴリズムを指定するフラグを入力してコマンドを動かす という課題は解決できました。

テストする文字を標準入力へ入力する

os/exec でコマンドを実行するときに、標準入力を渡したいときに使えるのが StdinPipe です。

https://golang.org/pkg/os/exec/#Cmd.StdinPipe

StdinPipeはコマンドの標準入力に接続されるパイプであり、 Writer のインターフェースを持っています。そのため、 io.WriteStringなどでバイト列を標準入力に渡すことができます。

stdin, err := cmd.StdinPipe()
if err != nil {
	t.Fatal(err)
}

input := []string{"a", "b", "c"}
	
go func() {
	defer stdin.Close()
	for _, in := range input {
		io.WriteString(stdin, in+"\n")
	}
}()

if err := cmd.Run(); err != nil {
	t.Fatal(err)
}

cmd.Run() でコマンドが動いた後に標準入力に input の内容が渡されるようになりました。
stdin.Close()が呼ばれるとコマンドは終了します。

これによってSTEP2である、 テストする文字を標準入力へ入力する が解決されました。

標準出力が正しいものかどうか調べる

ここまでくれば後は標準出力のverificationです。

exec.Command で作成される Go の Cmd 構造体には Stdout が存在します。これは該当のプロセスの標準出力を扱うことができ、Stdoutnil が指定されたときには、プロセスの標準出力を /dev/null に接続し、ファイルが指定されたときにはそのファイルに出力を書き込むことができます。
それ以外の場合、プロセスの実行中に別のgoroutineがpipeを介してプロセスから読み取り、そのデータを対応するwriterに渡します。

つまり、 io.Writer インターフェースを提供するものをつなげることができるため、この Stdoutbytes.Buffer を渡すことが可能です。

https://golang.org/pkg/bytes/#Buffer

bytes.Buffer は、書き込みと読み込みどちらもできるBufferで、これをStdoutとつなげて、プロセスの標準出力を保存することが可能です。

var stdout bytes.Buffer
cmd.Stdout = &stdout

if err := cmd.Run(); err != nil {
	t.Fatal(err)
}

if diff := cmp.Diff(stdout.String(), expectOutput); diff != "" {
	t.Fatalf("invalid output, diff: %v", diff)
}

これによって、期待した出力通りに標準出力が出たかどうかを知ることができ、テストに組み込むことができます。

これを一つにまとめたテストコードがこちらです。

https://github.com/hatobus/go-training/blob/b4a83984d0ceb265f0cbe379805ef3d16106e92a/ch04/ex4_1/ex4.2/sha256_cmd_test.go

mainパッケージの標準入出力を使用したテストでは、上のような3つを利用することでテストすることができました!

外部リソースに依存したプログラムのテスト

プログラミング言語Goにある問題には、外部リソースを使用する問題も存在します。

練習問題 5.13 必要に応じてディレクトリを作成しながら、見つけたページの複製をローカルに作成するようなクローラを作りなさい。
異なるドメインのページは複製しないようにし、例えば、元のページが golang.org であれば、そこにあるすべてのファイルは保存するが、vimeo.comからのファイルは保存しないようにするということです。

5章ではHTMLをダウンロードし、ローカルに同じページを複製するようなクローラーを作成する問題がいくつかあります。このクローラーをテストすることを考えてみましょう。

テストに使うウェブサイトのURLを適当に決めたいところですが、実在するウェブサイトを使ってしまうと問題が生じます。

  • サイトに負荷がかかる
  • HTMLが変更される可能性がある

テストの度にそのサイトへ訪問し、HTMLソースを取得してくるのはサイトに負荷がかかる可能性があります。常識的な範疇でアクセスするプログラムならば、そこまで影響はありませんが、バグによって大量のリクエストが発生し、そのサイトが落ちてしまう可能性もあります。

https://ja.wikipedia.org/wiki/岡崎市立中央図書館事件

自分が在籍していた大学でも、学生が書いたプログラムにバグや設定のミスがあり、サーバーを落とすようなプログラムを書いてしまうという事件が数回ありました。

また、それをなんとか回避したとしても、ソース元のHTMLは変更される可能性があり、今テストが通っても将来的にそのテストが通るかどうか不透明ということもあります。

httptestパッケージを使う

それを回避するためには httptest パッケージを使用します。

https://golang.org/pkg/net/http/httptest/

net/http/httptest パッケージはHTTPのテストを書くのに便利なパッケージです。

テストコードで、ダミーのサーバーを立ち上げたいときに使うのが NewServer であり、これはGoで書いた http.HandlerFunc を渡すと、 渡したハンドラーをローカルで立ち上げてくれます。

func prepareTestHandler(t testing.TB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		io.WriteString(w, "hello")
	}
}

func TestHttpTestHandler(t *testing.T) {
	handler := prepareTestHandler(t)
	ts := httptest.NewServer(handler)
	defer ts.Close()
	
	t.Log(ts.URL) // http://localhost:[port]
}

テスト用のサーバーはローカルのポートが自動で選択されて、そこで待ち受けるようになります。URLは NewServer の返り値である httptest.ServerURL に保存されています。指定された名前のHTMLファイルをレスポンスとして返すようなハンドラーを作成し、ローカルのURLを用いてテストを書いていけば、全てがローカルで完結する形で上で書いた2つの問題点を解決することができます。

テスト用のHTMLファイルを作成する

指定された名前のHTMLファイルをレスポンスとして返すようなハンドラーを作成しても、それに使用するHTMLファイルを作成しなければいけません。また、今回は同一ドメイン内に遷移するような href が存在するときにそちらのHTMLファイルも取得できる必要もあります。

テストサーバーはポートを固定できればいいですが、NewServerで起動したときには自動でポートが選択されるために、URLが毎回変わります。 (注: 実は固定できるみたいです)
URLが変わるために、hrefで指定するURLは毎回変わってしまいます。これは index.html をテンプレートで作成することで解決しました。

<!DOCTYPE html>

<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Golang Ex5-13</title>
</head>
<body>

<h1>Ex5-13 index HTML!</h1>
<p>Test index.html</p>

<ul>
    <li><a href="{{.}}/one.html">one.html</a></li>
    <li><a href="{{.}}/two.html">two.html</a></li>
    <li><a href="http://info.cern.ch/hypertext/WWW/TheProject.html">www.html</a></li>
</ul>
</body>
</html>

hrefの指定先に、先程NewServerで作成されたモックサーバーのURLを入れることで、テスト用のhtmlファイルが自動生成されます。

ts := httptest.NewServer(getHTMLHandler(t))
defer ts.Close()

tpl := template.Must(template.ParseFiles("util/index.html"))

f, err := os.Create(filepath.Join(origHTMLFilesDir, "index.html"))
if err != nil {
	t.Fatal(err)
}
defer f.Close()

err = tpl.Execute(f, ts.URL)
if err != nil {
	t.Fatal(err)
}

https://github.com/hatobus/go-training/blob/master/ch05/ex5_13/crawl_replica_test.go

そんなこんなで作成されたテストがこちら、ローカルの index.html には one.html two.html 2つのローカルファイルへのリンクがあり、 別ホストのhttp://info.cern.ch/hypertext/WWW/TheProject.html へのリンクも存在します。

レプリカとしてローカルに保存されたファイルの中身を比較して、きちんとしたファイルがとってこれたかどうかも見ています。

外部リソースを使用するテストの場合には httptest パッケージを使うことで、安全にテストできます!

柴田さんに聞いてみた

ここまでテストを書いてみて、自分の中で「どこまでテストを書けばいいのかどうか?ここまで外部のサービスをモックするべきなのか?」という認識が曖昧になったときがありました。Go研修の中で、柴田さんに自由に質問できる時間があるので、自分が疑問に思っていたこととともに、「どこまでサービスをモックするべきか?」を聞いてみたところ、昨年柴田さんが登壇された GDG Dev Fest Tokyo 2019の資料とともにとても勉強になる回答をいただきました。

https://www.slideshare.net/yoshikishibata/gdg-dev-fest-tokyo-2019

柴田さんの仰っていたことをまとめると、

現在、様々なサービスはGitHub等でAPIが公開されていることが多く、それについてソースコードを調べることができるので難しくはない。
手元ですべてモックできれば、テスト対象からそのサービスが実際に呼ばれたかも確認できる。

とのことでした。それまで自分が開発するときには、サービスをどこまでモックするか、どうモックするかというのを考えていた自分からするととても規模の大きい話が出てきてびっくりしましたが、全体をモックすることの重要度や有効性を再確認ができました。

最後に ... テストを書いていきましょう

ここまで長い記事&乱文になってしまいましたが、ここまで読んでいただきありがとうございます。

テストというと、どうしても「自分はこう書いたので、テストコードはこうなります」という意味付けで、自分の実装を証明するためだけのものになってしまいがちです。設計書という観点から見ればそれでもいいかもしれませんが、テストコードはそのコードをより良いものへ変えることができるような足場で無くてはいけません。

より良い設計やより良いコードを書けるようになるためにも、テストコード、書いていきましょう!!!

読んでいただきありがとうございました!

Discussion