スタブ・モック
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
テストを実行するための最小限のコードを記述し、失敗したテスト出力を確認します
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
と書き込むものを指定し、書き込みしてくれます。最終的にバイト数とエラーを返却します。
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は書き込み先を抽象化したインターフェースであり、バッファ以外にもファイルやネットワーク接続など、さまざまな書き込み先を扱うことができます。
さらに次の通り変更します。
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!」を表示します。
最初にテストを書く
以下のテストを書いて実行し、失敗することを確認します。
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
ではなくこちらを使用しています。
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回となるはずなので、もし異なる場合はエラーを出力します。
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
フィールドを用意し、Sleep
とWrite
を紐づけます。
そして、それぞれ呼び出された時に文字列のwrite
とsleep
を配列に格納するようにします。
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.duration
とsleepTime
は一致するようになります。
クリーンアップとリファクタリング
最後にmain関数でConfigurableSleeperを使用するように変更し、それに伴ってDefaultSleeper関連のコードを削除すれば完了です。
func main() {
sleeper := &ConfigurableSleeper{1 * time.Second, time.Sleep}
Countdown(os.Stdout, sleeper)
}
ところで、ConfigurableSleeper
はフィールドにtime.Duration
とfunc(time.Duration)
を取りますが、timeパッケージのtime.Sleep
の定義がfunc Sleep(d Duration)
となっているんですね。うまくできています。