0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

learn-go-with-tests スタブ・モック

Posted at

スタブ・モック

3からカウントダウンするプログラムを作成するように求められました。各数値を新しい行に表示します(1秒の間隔を置いて)、ゼロに達すると「Go!」と表示します。そして終了します。

3
2
1
Go!

これに取り組むには、Countdownという関数を作成します。この関数をmainプログラム内に配置して、次のようにします。

package main

func main() {
    Countdown()

これはかなり簡単なプログラムですが、完全にテストするには、いつものように反復的、テストドリブンのアプローチを取る必要があります。反復とは、できる限り小さなステップを踏んでいることを確認することです。要件をできる限り小さくスライスして、動作するソフトウェアを使用できるようにすることは重要なスキルです。

作業を分割して反復する方法は次のとおりです。

  • 表示 3
  • 3、2、1 を表示してGo!
  • 各行の間で1秒待ちます

最初にテストを書く

func TestCountdown(t *testing.T) {
    buffer := &bytes.Buffer{}

    Countdown(buffer)

    got := buffer.String()
    want := "3"

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

出力をテストするために、bytes.Bufferという便利なツールを使います。bytes.Bufferは、データを書き込んで後で読み取ることができるメモリ上のバッファです。

&bytes.Buffer{}で新しいbytes.Bufferのポインタを作成し、それをCountDown関数に渡しています。
buffer.String()でbuffer内のデータを一括で文字列として取得しています。

ここでbuffer.String()とbuffer.Read()の違いが気になったので、以下に、buffer.String()buffer.Read()`の違いを簡潔に表にまとめました。

テストなどでは基本的にbuffer.String()で一括取得した方が簡潔に書けるので良さそうです。

メソッド 説明 使用例
buffer.String() バッファ内のすべてのデータを一括で文字列として取得したい場合に使用。 テストでバッファの内容全体を確認する場合。
buffer.Read() バッファからデータを逐次読み取りたい場合に使用。 大きなデータを一度に処理せず、チャンクごとに読み取る場合。

テストを試して実行する

テストを実行すると当然失敗します。
./countdown_test.go:11:2: undefined: Countdown

テストを実行するための最小限のコードを記述し、失敗したテスト出力を確認します

countdown.go
package mock

import "bytes"
func CountDown(out *bytes.Buffer){
}

テストを実行するとmoc_test.go:17: got "" want "3"と表示され失敗しますがいい感じです。

成功させるのに十分なコードを書く

以下のFprintで指定したバッファに書き込みます。

func Countdown(out *bytes.Buffer) {
    fmt.Fprint(out, "3")
}

今回使用しているfmt.Fprintは以下の定義です。
メモリバッファやファイル、ネットワーク接続などのio.Writerと書き込むものを指定し、書き込みしてくれます。最終的にバイト数とエラーを返却します。

pring.go/Fprint
func Fprint(w io.Writer, a ...any) (n int, err error) {
	p := newPrinter()
	p.doPrint(a)
	n, err = w.Write(p.buf)
	p.free()
	return
}

例えば、以下のようにバッファへ書き込んだ後に確認できます。

func main() {
    var buffer bytes.Buffer
    fmt.Fprint(&buffer, "Hello, world!") // バッファにデータを書き込む
    fmt.Println(buffer.String())         // バッファの内容を出力: "Hello, world!"
}

以上でテストはパスするようになります。

リファクタリング

*bytes.Bufferは機能しますが、代わりに汎用インターフェースを使用する方がよいことはわかっています。以下のように変更するとパスします。

func Countdown(out io.Writer) {
    fmt.Fprint(out, "3")
}

元々、Countdown関数は*bytes.Bufferを使用してデータを書き込んでいました。しかし、*bytes.Bufferは特定の型であり、汎用性がありません。代わりに、io.Writerという汎用インターフェースを使用することで、関数がより柔軟になります。

*bytes.Bufferではなく、io.Writerインターフェースを受け取るように変更します。io.Writerは書き込み先を抽象化したインターフェースであり、バッファ以外にもファイルやネットワーク接続など、さまざまな書き込み先を扱うことができます。

さらに次の通り変更します。

countdown.go
package mock

import (
    "fmt"
    "io"
    "os"
)

func Countdown(out io.Writer) {
    fmt.Fprint(out, "3")
}

func main() {
    Countdown(os.Stdout)
}

以下に、osパッケージで使用する主な変数と関数を表でまとめました。

変数/関数 説明 型/戻り値
os.Stdin 標準入力(通常はキーボード入力)を表すファイル *os.File
os.Stdout 標準出力(通常はコンソール出力)を表すファイル *os.File
os.Stderr 標準エラー出力(通常はエラーメッセージの出力先)を表すファイル *os.File
os.Environ 環境変数の一覧を返す関数 []string
os.Getenv 指定された名前の環境変数の値を返す関数 string
os.Setenv 環境変数を設定する関数 error
os.Unsetenv 環境変数を削除する関数 error
os.Args コマンドライン引数を格納するスライス []string
os.Exit プログラムを終了する関数。指定したステータスコードで終了 void
os.FileInfo ファイルの情報を格納するインターフェース os.FileInfo
os.TempDir システムのデフォルトの一時ディレクトリのパスを返す関数 string

これはささいなことのようですが、このアプローチはどのプロジェクトにもお勧めします。 機能のごく一部を取得し、テストに裏打ちされたend-to-endで機能するようにします。

次に、2,1を表示してから、「Go!」を表示します。

最初にテストを書く

以下のテストを書いて実行し、失敗することを確認します。

count_down_test.go
func TestCountdown(t *testing.T){
	buffer := &bytes.Buffer{}

	Countdown(buffer)

	got := buffer.String()
	want := `3
	2
	1
	Go!`

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

成功させるのに十分なコードを書く

次の通りコードを書きテストが成功します。
Fprintlnは改行が含まれるため今回はFprintではなくこちらを使用しています。

countdown.go
func Countdown(out io.Writer) {
    for i := 3; i > 0; i-- {
        fmt.Fprintln(out, i)
    }
    fmt.Fprint(out, "Go!")
}

リファクタリング

マジックナンバーを名前付き定数へリファクタリングする以外にリファクタリングすることは多くありません。

const finalWord = "Go!"
const countdownStart = 3

func Countdown(out io.Writer) {
    for i := countdownStart; i > 0; i-- {
        fmt.Fprintln(out, i)
    }
    fmt.Fprint(out, finalWord)
}

ここでプログラムを実行すると、目的の出力が得られるはずですが、1秒の一時停止による劇的なカウントダウンとしてはありません。

Goでは、time.Sleepでこれを実現できます。コードに追加してみてください。

func Countdown(out io.Writer) {
    for i := countdownStart; i > 0; i-- {
        time.Sleep(1 * time.Second)
        fmt.Fprintln(out, i)
    }

    time.Sleep(1 * time.Second)
    fmt.Fprint(out, finalWord)
}

プログラムを実行すると、期待どおりに機能します。

モック

テストは引き続き成功し、ソフトウェアは意図したとおりに機能しますが、いくつかの問題があります。

  • テストの実行には4秒かかる
    遅いテストは開発者の生産性を台無しにします。

  • time.Sleepが正しく呼ばれているかどうかは確認していない
    テストではカウントダウンの出力のみを確認しています。

つまり、実際のtime.Sleepを使うとテストが遅くなるためモックを使用し、あたかもtime.Sleepを使用しているかのような状況を作る必要があります。そして、Sleepが正しく呼ばれているかを確認するために、呼び出し回数を記録するスパイを用意します。

最初にテストを書く

実行した回数を記録するSpeSleeperを用意し、Countdownの引数として渡します。
(関数の中でカウントダウンするため使用します。)

そして、Sleepの実行回数が4回となるはずなので、もし異なる場合はエラーを出力します。

countdown_test.go
func TestCountdown(t *testing.T){
	buffer := &bytes.Buffer{}
	spySleeper := &SpySleeper{}

	Countdown(buffer, spySleeper)

	got := buffer.String()
    want := `3
2
1
Go!`

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

	if spySleeper.Calls != 4 {
		t.Errorf("not enough calls to sleeper. want 4 got %d", spySleeper.Calls)
	}
}

成功させるのに十分なコードを書く

次にSpySleeper構造体を用意し、これにSleepメソッドを紐付けます。そして、time.Sleepとしている箇所をSpySleeper.Sleep()に置き換えます。

しかし、置き換えたことでTime.Sleep()できなくなるので、DefaultSleeper構造体を擁しい、こちらにもSleepメソッドを紐付け、time.Sleepで1秒間スリープするようにし、main関数ではこちらを使用します。

このように分けることでテストと本番のコードを切り分けることが可能となります。

package mock

import (
    "fmt"
    "io"
    "os"
	"time"
)

type Sleeper interface {
	Sleep()
}

type DefaultSleeper struct{}

func (s *DefaultSleeper) Sleep() {
	time.Sleep(1 * time.Second)
}

// 構造体にCallsフィールドを用意
type SpySleeper struct {
	Calls int
}

// 構造体にメソッドを紐付け、Callsフィールドに加算
func (s *SpySleeper) Sleep() {
	s.Calls++
}

const finalWord = "Go!"
const countdownStart = 3

func Countdown(out io.Writer, spySleeper Sleeper) {
	for i := countdownStart; i > 0; i-- {

		spySleeper.Sleep()
			fmt.Fprintln(out, i)
	}

	spySleeper.Sleep()	
	fmt.Fprint(out, finalWord)
}

func main() {
	sleeper := &DefaultSleeper{}
	Countdown(os.Stdout, sleeper)
}

これでテストを実行するとテストに合格し、4秒はかかりません。

まだいくつかの問題

テストしていない重要なプロパティがまだあります。

Countdown は各表示の前にスリープする必要があります。

例:

Sleep

Print N

Sleep

Print N-1

Sleep

Print Go!

etc

私たちの最新の変更は、それが4回スリープしたと断言しているだけですが、それらのスリープは順序が狂って起こる可能性があります。

したがって、期待通りの順番でスリープと出力が行われているか確認する必要があります。

コードを次のように変更します。
実は順序を確認するテストを書いていないため、これでもテストがパスしてしまいます。

func Countdown(out io.Writer, spySleeper Sleeper) {
	for i := countdownStart; i > 0; i-- {
		spySleeper.Sleep()
	}

	for i := countdownStart; i > 0; i-- {
		fmt.Fprintln(out, i)
	}

	spySleeper.Sleep()	
	fmt.Fprint(out, finalWord)
}

しかし、上でテストがパスすることは期待する挙動ではありません。
したがって、関数を修正する必要があります。

すべての操作を1つのリストに記録したいと考えています。 ですから、両方のスパイを1つ作成します。

以下のように、CountdownOperationsSpyに文字列の配列Callsフィールドを用意し、SleepWriteを紐づけます。

そして、それぞれ呼び出された時に文字列のwritesleepを配列に格納するようにします。

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"

続いて、テストも修正します。

package mock

import (
	"bytes"
	"testing"
	"reflect"
)

func TestCountdown(t *testing.T) {

	t.Run("prints 3 to Go!", func(t *testing.T) {
			buffer := &bytes.Buffer{}
			Countdown(buffer, &CountdownOperationsSpy{})

			got := buffer.String()
			want := `3
2
1
Go!`

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

	t.Run("sleep before every print", func(t *testing.T) {
			spySleepPrinter := &CountdownOperationsSpy{}
			Countdown(spySleepPrinter, spySleepPrinter)

			want := []string{
					sleep,
					write,
					sleep,
					write,
					sleep,
					write,
					sleep,
					write,
			}

			if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
					t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
			}
	})
}

しかし、現時点では失敗するためCountdown関数を元に戻してテストを実行するとテストにパスします。

func Countdown(out io.Writer, sleeper Sleeper) {
	for i := countdownStart; i > 0; i-- {
			sleeper.Sleep()
			fmt.Fprintln(out, i)
	}

	sleeper.Sleep()
	fmt.Fprint(out, finalWord)
}

スリーパーを構成可能に拡張

Sleeperが構成可能であることは素晴らしい機能です。これは、メインプログラムでスリープ時間を調整できることを意味します。

最初にテストを書く

まず、設定とテストに必要なものを受け入れる ConfigurableSleeperという新しいタイプを作成しましょう。次にスパイ用にSpyTimeとそれに紐付けたSleepを用意します。

SpyTimeは、実際にスリープを行うのではなく、スリープの呼び出しとその時間を記録します。

type ConfigurableSleeper struct {
    duration time.Duration
    sleep    func(time.Duration)
}

type SpyTime struct {
	duration time.Duration
}

func (s *SpyTime) Sleep(duration time.Duration){
	s.duration = duration
}

次にテストを記述します。
5秒をスリープ時間として設定し、ConfigurableSleeperにセットします。
そして、それに紐づいたSleep関数を呼び出します。しかし、この時点ではSleep関数は未定義なので失敗します。

func TestConfigurableSleeper(t *testing.T) {
	sleepTime := 5 * time.Second
	spyTime := &SpyTime{}
	sleeper := ConfigurableSleeper{sleepTime, spyTime.Sleep}
	sleeper.Sleep()

	if spyTime.duration != sleepTime {
		t.Errorf("should have slept for %v but slept for %v", sleepTime, spyTime.durationSlept)
	}
}

そこで、ConfigurableSleeperのSleep関数を実装します。
これにより、ConfigurableSleeper構造体のsleepメソッドが実行されます。
つまり、これはSpyTime構造体のdurationフィールドに5秒を設定することと同義となります。

func (c *ConfigurableSleeper) Sleep() {
    c.sleep(c.duration)
}

したがって、問題ない場合はspyTime.durationsleepTimeは一致するようになります。

クリーンアップとリファクタリング

最後にmain関数でConfigurableSleeperを使用するように変更し、それに伴ってDefaultSleeper関連のコードを削除すれば完了です。

func main() {
    sleeper := &ConfigurableSleeper{1 * time.Second, time.Sleep}
    Countdown(os.Stdout, sleeper)
}

ところで、ConfigurableSleeperはフィールドにtime.Durationfunc(time.Duration)を取りますが、timeパッケージのtime.Sleepの定義がfunc Sleep(d Duration)となっているんですね。うまくできています。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?