Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Go言語でTDDする勉強会 第5回(2020/01/22) 発表メモ

Go言語でTDDする勉強会 第5回(2020/01/22) 発表メモ

connpass

Go言語でTDDする勉強会 (初心者大歓迎!)

Slack

Join Weeyble on Slack. ( weeyble.slack.com )
事前に登録をお願いします

channel: #go

参加者への質問

前提

  • ローカル環境でGo言語が動作すること

本勉強会の管理者

今回の発表者


前回までの資料

第2回, 第3回 は ハンズオン手順毎に 細かく箇条書き
-> 面倒になってきたので、今回は概要のみ

今回の進め方

本 gist 資料 で概要を解説
 ↓
下記 教材を ハンズオン形式で解説 (時間無いので出来るだけコピペで済ませます)

1時間くらい経ったら途中、休憩を入れます(5分〜10分)

10. Mocking, 11. Concurrency まで進める予定です


教材

https://github.com/quii/learn-go-with-tests

教材の説明

  • Table of contentsGo fundamentals の欄
    • 1. Install Go から 17. Maths まで
  • さらに その下の Build an application の欄
    • HTTP server など、application を作成しながら TDD を学ぶ構成
  • 各セクション毎に、解説とソースコードが並ぶ構成
  • 全てのセクションの ページ先頭 に、ソースファイルが格納されているディレクトリへの リンク
    • さらにその直下に v1, v2, etc... のディレクトリ

各セクションの 解説内にソースコードも記述されて いますが、
たまに 情報不足の時があるのでその時は v1, v2, etc... のファイルを確認 して下さい

例: 2. Hello, worldFrench

func Hello(name string, language string) string {
	// 中略
	if language == french {
		return frenchHelloPrefix + name
	}
	// 中略
}

定数: french, frenchHelloPrefix の定義部分が載っていない

v6 のソースファイル で確認

// 前略
const french = "French"
// 中略
const frenchHelloPrefix = "Bonjour, "
// 以下略

ディレクトリ構成

2. Hello, World

  • it's up to you how you structure your folders. (フォルダをどのように構成するかはあなた次第です)
  • 最新のGoでは Go Modules を使用できますが、 本教材では $GOPATH/src 以下のディレクトリ を使用 しているので自分はそれに合わせます

(追記) 2020年1月30日 21:28 JST の commitGo Modules の説明が追記された模様です...


前提知識: TDD について

2. Hello, World

How to test

import "testing"

func TestHello(t *testing.T) {
	got := Hello()
	want := "Hello, world"

	if got != want {
		t.Errorf("got %q want %q", got, want)
	}
}

Run go test in your terminal.

Writing tests

xxx_test.go
The test function must start with the word Test
The test function takes one argument only t *testing.T

Discipline

Let's go over the cycle again

Write a test
Make the compiler pass
Run the test, see that it fails and check the error message is meaningful
Write enough code to make the test pass
Refactor

 


前提知識: Test Double (Stub, Mock, Spy)

wiki の解説

テストダブル (Test Double) とは、ソフトウェアテストにおいて、 テスト対象が依存しているコンポーネント置き換える代用品 のこと。
ダブルは代役、影武者を意味 する。

-> 個人的に 漫画: ハンターハンター の カストロ を連想します^^

テストスタブ (略...)
テストスパイ (略...)
モックオブジェクト (略...)
フェイクオブジェクト (略...)
ダミーオブジェクト (略...)

以下、説明がライブラリによって微妙に違う...?

 

参考サイト: 自動テストのスタブ・スパイ・モックの違い | gotohayato.com

スタブ: 指定された挙動をする機能
スパイ: スタブ + 記録機能
モック: スタブ + 処理中の検証機能

上述のとおり、スパイとモックは兄弟のような関係にあります

参考サイト: 8. テストダブル — PHPUnit latest Manual

実際のオブジェクトを置き換えて、 設定した 何らかの値を (オプションで) 返す ようなテストダブルのことを スタブ といいます

実際のオブジェクトを置き換えて(メソッドがコールされたことなどの) 期待する内容を検証 するテストダブルのことを モック といいます

参考サイト: Java - mockとspyの違いは?(Mockito)|teratail

Mockito#mock メソッドで生成したモックは ハリボテ(完全に偽物) ですが、
Mockito#spy メソッドを使ったモックは 指定したメソッドのみモック化(一部だけ偽物) され、 他の部分は元の実装のまま 動作します。

参考サイト: フロントエンドユニットテスト · takahashiakira/tech_for_web Wiki

スタブ とは依存するモジュールの代用に利用できる仮のモジュールです
(中略)
あらかじめ用意されたオブジェクトを返す ことだがけがスタブの役割です

モックメソッドが正しく呼び出されているかを検証 します
外部のオブジェクトに対して正しい型そして正しい値が渡されているかチェック します
モックのメソッドを呼んでも、 モック元になったオブジェクトの本物のメソッドは呼び出されません

スパイ は、 実際のオブジェクトをラップし一部のメソッドを置き換え ます
一般的には スパイは実際のオブジェクトに紐付け られ、 特定のメソッドだけを横取り します

参考サイト: SpockにおけるMock、Stub、Spyの違い - uehaj's blog

Stubは後述Mockの低機能版であり、具体的には 「モッキングができないMock」がStub です

Mock(もしくはGroovyMock)は、テスト対象が使うクラスを指定して、その クラスのインスタンスの真似をするオブジェクト(モックオブジェクト)を作ります
Mockのメソッドを呼んでも、 Mock元になったオブジェクトの本物のメソッドは呼び出されません

Spy(もしくはGroovySpy)は、 その対象とするオブジェクトが、実際に存在する必要 があります。
偽物だけではなく、本物インスタンスが存在していることが必要です。
(中略) Mockと同じようですが、 Spyでは戻り値の「本当の値」でのチェックができます

 

参考サイト: Go Mockでインタフェースのモックを作ってテストする #golang - Qiita

-> GoMock というライブラリがあるそうですが、 本教材では使用していません

 


10. Mocking

10. Mocking

要件

  • Countdown 関数 の test を行う

Countdown 関数 の出力

3
2
1
Go!
  • 1秒 Sleep -> Writer に 3 出力
  • -> 1秒 Sleep -> Writer に 2 出力
  • -> 1秒 Sleep -> Writer に 1 出力
  • -> 1秒 Sleep -> Writer に Go! 出力
func Countdown(out io.Writer) {
	// 中略
	time.Sleep(1 * time.Second) // 計4回 呼ばれている -> 時間がかかる
	fmt.Fprintln(out, i)
	// 中略
}

問題点(1): test 実行 に 4秒 かかる

  • 解決策(1): time.Sleep 処理部分 を DI するために interface を定義
type Sleeper interface {
	Sleep()
}
  • Countdown 関数 の 第2引数: sleeper Sleeper 追加
func Countdown(out io.Writer, sleeper Sleeper) {
	// 中略
	sleeper.Sleep()
	// 中略
}
  • time.Sleep(1 * time.Second) 呼び出し -> sleeper.Sleep() 呼び出し に変更
  • Sleeper interface を実装 する 4個 の 新規 struct を 作成
    • test では Sleep しない
      • SpySleeper を DI
      • 後で別の実装(CountdownOperationsSpy) に変更
    • main 関数 では 実際に Sleep する
      • DefaultSleeper を DI
      • 後で別の実装(ConfigurableSleeper) に変更

SpySleeper

  • Spies are a kind of mock which can record how a dependency is used (スパイ は、依存関係の 使用方法を記録 できる 一種のモック です)
  • They can record the arguments sent in, how many times it has been called, etc. (送信された 引数, 呼び出された回数 などを記録 できます。)
type SpySleeper struct {
	Calls int
}

func (s *SpySleeper) Sleep() {
	s.Calls++
}
  • In our case, we're keeping track of how many times Sleep() is called (このケースでは Sleep() が 何回呼び出されたか を追跡 しています)
	spySleeper := &SpySleeper{}
	Countdown(buffer, spySleeper)
	if spySleeper.Calls != 4 {
		// 中略
	}
  • assert that the sleep has been called 4 times (sleep が4回呼び出された ことを assert します)

DefaultSleeper

main 関数 で DI する Sleeper 実装

type DefaultSleeper struct {}

func (d *DefaultSleeper) Sleep() {
	time.Sleep(1 * time.Second)
}
  • sleep 時間は 1秒固定
    • 後述の ConfigurableSleeper では この秒数を設定変更可能に
func main() {
	sleeper := &DefaultSleeper{}
	Countdown(os.Stdout, sleeper)
}

 

問題点(2): 実装の順番 (SleepWriter への 出力の順番) が間違っていても test が通る

  • 以下のように 実装を変更 しても test が pass してしまう
    • 3秒 Sleep -> Writer に 3, 2, 1 出力
    • -> 1秒 Sleep -> Writer に Go! 出力
  • 3\n2\n1\nGo! と出力された事と 「sleep が4回呼び出された」事だけ しか assert していないため

order of operations

  • We have two different dependencies and we want to record all of their operations into one list. So we'll create one spy for them both. (2つの異なる依存関係 があり、 それらのすべての操作を1つのリストに記録 する必要があります。それで 両方のために1つのスパイを作成 します)

v4 の countdown_test.go

type CountdownOperationsSpy struct {
	Calls []string
}

func (s *CountdownOperationsSpy) Sleep() {
	s.Calls = append(s.Calls, sleep)
}

func (s *CountdownOperationsSpy) Write(p []byte) (n int, err error) {
	s.Calls = append(s.Calls, write)
	return
}

const write = "write"
const sleep = "sleep"
  • Our CountdownOperationsSpy implements both io.Writer and Sleeper, recording every call into one slice. (CountdownOperationsSpyio.WriterSleeper の両方を実装 し、 すべての呼び出し を 1つの slice に記録 します)

v4 の countdown_test.go

	spySleepPrinter := &CountdownOperationsSpy{}
	Countdown(spySleepPrinter, spySleepPrinter)
  • Sleep() メソッド を実装 -> Sleeper を満たす
    • Countdown 関数 の 第2引数 に渡せる
  • Write([]byte) (int, rror) メソッド を実装 -> io.Writer を満たす
    • Countdown 関数 の 第1引数 にも渡せる
	want := []string{
		sleep,
		write,
		sleep,
		write,
		sleep,
		write,
		sleep,
		write,
	}

	if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
		// 中略
	}
  • verifies our sleeps and prints operate in the order we hope (sleep と print が希望する順序で動作することを確認)
  • SpySleeper は不要になったので 削除

 

Extending Sleeper to be configurable

  • Sleeper を 構成可能に
    • Sleep 時間 を 調整 できるように
  • 新規 Sleeper (ConfigurableSleeper) を定義
    • DefaultSleeper を削除

main 関数側

type ConfigurableSleeper struct {
	duration time.Duration
	sleep    func(time.Duration)
}
  • sleep 関数 の シグネチャ」 は time.Sleep と同じ」 (func(time.Duration))
    • -> ConfigurableSleeper を初期化する時に「time.Sleep をDI」できる (下記 main 関数 参照)
func (c *ConfigurableSleeper) Sleep() {
	c.sleep(c.duration)
}
  • ConfigurableSleeperSleep() メソッド を実装 -> Sleeper interface を満たす
    • Countdown 関数 の 第2引数 に渡せる
    • Sleep() メソッド 内で sleep 関数 を呼び出す (c.sleep(c.duration))

 

func main() {
-	sleeper := &DefaultSleeper{}
+	sleeper := &ConfigurableSleeper{1 * time.Second, time.Sleep}
	Countdown(os.Stdout, sleeper)
}
  • ConfigurableSleeper を初期化する時に sleep func(time.Duration) に対して time.Sleep を DI

ConfigurableSleeper 自体 の test 追加

  • test では -> 下記 の 新規 spy (SpyTime) の Sleep メソッド を DI

v5 の countdown_test.go

type SpyTime struct {
	durationSlept time.Duration
}

func (s *SpyTime) Sleep(duration time.Duration) {
	s.durationSlept = duration
}
func TestConfigurableSleeper(t *testing.T) {
	// 中略
	spyTime := &SpyTime{}
	sleeper := ConfigurableSleeper{sleepTime, spyTime.Sleep}
	sleeper.Sleep()
	// 中略
}

ソース構成

 


11. Concurrency

11. Concurrency

要件

  • 関数 (func(string) bool) を custom type として定義
    • 引数: URL(string), 戻り値: URLアクセスした結果(bool)

v1 の CheckWebsites.go

type WebsiteChecker func(string) bool
  • 以下の 関数 CheckWebsites の test を行う

v1 の CheckWebsites.go

func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool {
	results := make(map[string]bool)

	for _, url := range urls {
		results[url] = wc(url)
	}
	return results
}
  • 第1引数: 上記関数 WebsiteChecker
  • 第2引数: アクセスするURL一覧 ([]string)
  • 戻り値: URL と アクセスした結果(bool) を map (results) に格納して返す

実際のHTTP呼び出し を行う実装 (test 対象ではない ので解説には登場しない)

v1 の CheckWebsite.go

func CheckWebsite(url string) bool {
	response, err := http.Head(url)
	if err != nil {
		return false
	}
	if response.StatusCode != http.StatusOK {
		return false
	}
	return true
}
  • Test Double に 置き換えられる 対象
  • この関数の test は 本セクション の 解説には登場しない

実際のHTTP呼び出し を行わずに関数をテスト

v1 の CheckWebsites_test.go

func mockWebsiteChecker(url string) bool {
	if url == "waat://furhurterwe.geds" {
		return false
	}
	return true
}

func TestCheckWebsites(t *testing.T) {
	// 中略
	got := CheckWebsites(mockWebsiteChecker, websites)
	// 中略
}
  • 関数 mockWebsiteChecker を 第1引数 に DI して test

v1 の CheckWebsites_benchmark_test.go

func slowStubWebsiteChecker(_ string) bool {
	time.Sleep(20 * time.Millisecond)
	return true
}

func BenchmarkCheckWebsites(b *testing.B) {
	// 中略
	for i := 0; i < b.N; i++ {
		CheckWebsites(slowStubWebsiteChecker, urls)
	}
}
  • 関数 slowStubWebsiteChecker を 第1引数 に DI して benchmark test

 

turn a function call into a go statement (関数呼び出し を go ステートメントに変換)

  • 問題点
    • 1つのURLずつ順番に 関数 WebsiteChecker 呼び出しするのは 遅い

v1 の CheckWebsites.go

	for _, url := range urls {
		results[url] = wc(url)
	}

 ↓

  • 解決策
    • url の数分(ループの回数) の goroutine を起動 して その中で 関数 WebsiteChecker 呼び出し を 並列実行
	for _, url := range urls {
		go func() {
			results[url] = wc(url)
		}()
	}

 

CheckWebsites.go

func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool {
	results := make(map[string]bool)

	for _, url := range urls {
		go func() {
			results[url] = wc(url)
		}()
	}
	return results
}

上記の書き方には test 失敗(期待値が異なる) する原因が2つエラー(panic) が発生 する原因が1つ

  • (1) map (results) の 結果が 0 のため test 失敗. (期待値は 3件)
    • CheckWebsites_test.go:XX: Wanted map[http://google.com:true http://blog.gypsydave5.com:true waat://furhurterwe.geds:false], got map[]
  • (2) map (results) の 結果が 1 のため test 失敗. (期待値は 3件)
    • CheckWebsites_test.go:XX: Wanted map[http://google.com:true http://blog.gypsydave5.com:true waat://furhurterwe.geds:false], got map[waat://furhurterwe.geds:false]
  • (3) map (results) に 複数の goroutine が同時にアクセス したため panic 発生
    • fatal error: concurrent map writes

※ 詳細は後述

注) 解説の中では 【最初に (1) が発生 -> (1) を解決すると (2) が発生 -> (2) を解決すると (3) が発生】 という順番になっていますが、皆さんのローカル環境次第で

  • いきなり (3) が発生
  • (1) が発生せずに (2) が発生
  • 何回実行しても (3) が発生してくれない

などの結果になる可能性があります

anonymous function (匿名関数)

func() {
	results[url] = wc(url)
}()
  • 関数の最後の () 呼び出し
    • 宣言と同時に実行
  • lexical scope への access を保持 (closure)
    • 変数 resultsurl への 参照 を保持

 

(1) test 失敗 原因-1 CheckWebsites is now returning an empty map

  • 原因
    • goroutine が 結果を map に登録 する 前 に CheckWebsites 関数が map を return したため
  • 対策
    • time.Sleep(2 * time.Second) 追加
    • goroutine が 結果を map に登録 する 十分 な時間を与える
func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool {
	results := make(map[string]bool)

	for _, url := range urls {
		go func() {
			results[url] = wc(url)
		}()
	}

	time.Sleep(2 * time.Second)  // 追加
	return results
}
  • 補足: time.Sleep よりも sync.WaitGroup を使用する方が良い です
    • 後の セクション(14. Sync) で解説するので詳細は割愛
func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool {
	results := make(map[string]bool)

	var wg sync.WaitGroup
	for _, url := range urls {
		wg.Add(1)
		go func(wg *sync.WaitGroup) {
			defer wg.Done()
			results[url] = wc(url)
		}(&wg)
	}

	wg.Wait()
	//time.Sleep(2 * time.Second) // 削除
	return results
}

(2) test 失敗 原因-2 only one result [ ループ + anonymous function (closure) + 遅延実行 ] ※ 重要!!

全ての goroutine同じ url にアクセスしたため
全件同じ map の key(=url) を更新 するので map の結果 が 1件 になってしまう

	for _, url := range urls {
		go func() {
			results[url] = wc(url)
		}()
	}
  • 原因
    • urls の数だけ ループ
      • ループ毎に ループ変数 url が更新 される
      • ループ の回数個の goroutine 起動
    • ループ 終了
    • 全ての goroutine【 ループの「最後の url の参照」を保持 】 したまま 処理を実行
      • url が スコープ外 -> heap に移される
  • 解決策
    • 匿名関数 に 引数 を追加 して, url を 匿名関数 の 引数 に渡す
      • url の参照ではなく 値の copy が渡される
        • 各 goroutine で別々の url を使用 して 処理を実行

v2 の CheckWebsites.go

	for _, url := range urls {
		go func(u string) {
			results[u] = wc(u)
		}(url)
	}
  • 補足1-1: 書籍 Go言語による並行処理 の 3.1 (p41) で同様の解説あり
  • 補足1-2: ASCII.jp:Go言語と並列処理goroutineと情報共有 参照
    • > 関数の引数として渡す方法と、クロージャのローカル変数にキャプチャして渡す方法とのあいだで、1つ違いがあるとすれば、次のようにforループ内でgoroutineを起動する場合です
    • > // goroutineが起動するときにはループが回りきって
    • > // 全部のtaskが最後のタスクになってしまう
    • > 単純なループに比べてgoroutineの起動が遅いため、クロージャを使ってキャプチャするとループが回るたびにプログラマーが意図したのとは別のデータを参照してしまいます
    • > その場合は関数の引数経由にして明示的に値コピーが行われるようにします

 

別解: (教材には記述ありませんが、以下の方法でも解決するはず...)

	for _, url := range urls {
		url := url
		go func() {
			results[url] = wc(url)
		}()
	}

 

 

前提知識: Channel

(3) エラー (panic) 発生 原因 fatal error: concurrent map writes -> Channel 書き込み に変更

  • 原因
    • race condition が発生したため
    • 複数の goroutine から map に同時にアクセス したため
    • map は thread safe ではない
  • エラー (panic) が再現しない場合
    • go test -race を実行 して確認

v3 の CheckWebsites.go

type result struct {
	string
	bool
}
  • 必要な情報は 「URL (string)」 と 「関数 WebsiteChecker の結果 (bool)」
    • -> custom type (result) を定義
	resultChannel := make(chan result)

	for _, url := range urls {
		go func(u string) {
			resultChannel <- result{u, wc(u)}
		}(url)
	}
  • 解決策
    • 複数の goroutine からは map の代わりに Channel に結果を送信
	results := make(map[string]bool)

	for i := 0; i < len(urls); i++ {
		result := <-resultChannel
		results[result.string] = result.bool
	}	
  • main の goroutine で Channel から結果を受けて 1個ずつ map に登録

補足

11. Concurrency には記述ありませんが、以下の方法でも解決するはず...

  • (1) sync.Mutex で map の 処理の間 Lock する 方法
  • (2) Go1.9 からは sync.Map 型 が追加されているのでそれを利用しても解決するはず...?

 

ソース構成

 


※今回はここまで

次回 -> 第6回(2020/02/05) 実施です (第6回(2020/02/05) 発表メモ)

 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.