時間
Time
製品の所有者は、テキサス・ホールデム・ポーカーをプレイする人々のグループを支援することで、コマンドラインアプリケーションの機能を拡張することを望んでいます。

ポーカーに関する十分な情報

ポーカーについて多くのことを知る必要はありません。一定の時間間隔で、すべてのプレーヤーに着実に増加する「ブラインド(強制bet)」値を通知する必要があるということだけです。
私たちのアプリケーションは、ブラインドがいつ上がるべきか、そしてどれくらいあるべきかを追跡するのに役立ちます。
  • 開始すると、何人のプレイヤーがプレイしているかを尋ねます。これは、「ブラインド」ベットが上がるまでの時間を決定します。
    • 基本時間は5分です。
    • すべてのプレーヤーについて、1分が追加されます。
    • 例:6人のプレーヤーは、ブラインドでは11分に相当します。
  • ブラインドタイムが終了した後、ゲームはプレイヤーにブラインドベットの新しい金額を警告する必要があります。
  • ブラインドは100チップから始まり、その後200、400、600、1000、2000となり、ゲームが終了するまで2倍になります(「ルース勝利」の以前の機能は、ゲームを終了するはずです)。

コードの注意

前の章では、{name} winsのコマンドを既に受け入れているコマンドラインアプリケーションを開始しました。これが現在のCLIコードの外観ですが、開始する前に他のコードについてもよく理解してください。
1
type CLI struct {
2
playerStore PlayerStore
3
in *bufio.Scanner
4
}
5
6
func NewCLI(store PlayerStore, in io.Reader) *CLI {
7
return &CLI{
8
playerStore: store,
9
in: bufio.NewScanner(in),
10
}
11
}
12
13
func (cli *CLI) PlayPoker() {
14
userInput := cli.readLine()
15
cli.playerStore.RecordWin(extractWinner(userInput))
16
}
17
18
func extractWinner(userInput string) string {
19
return strings.Replace(userInput, " wins", "", 1)
20
}
21
22
func (cli *CLI) readLine() string {
23
cli.in.Scan()
24
return cli.in.Text()
25
}
Copied!

time.AfterFunc

プレーヤーの数に応じて特定の期間にブラインドベット値を印刷するようにプログラムをスケジュールできるようにしたいと考えています。
必要なことの範囲を制限するために、ここではプレーヤーの数を忘れて5人のプレーヤーがいると仮定し、ブラインドベットの新しい値が10分ごとに出力されるかどうかをテストします
いつものように、標準ライブラリはfunc AfterFunc(d Duration, f func()) *Timerでカバーされています。
AfterFuncは継続時間が経過するのを待ってから、独自のgoroutineでfを呼び出します。これは、Stopメソッドを使用して呼び出しをキャンセルするために使用できるTimerを返します。
期間は、2つの瞬間間の経過時間をint64ナノ秒カウントとして表します。
タイムライブラリには、これらのナノ秒を乗算できるようにする定数がいくつかあります。これにより、これらの定数は、私たちが行うシナリオの種類に対して、より読みやすくなります。
1
5 * time.Second
Copied!
PlayPoker」を呼び出すと、すべてのブラインドアラートをスケジュールします。
これをテストするのは少し難しいかもしれません。各期間が正しいブラインド量でスケジュールされていることを確認する必要がありますが、time.AfterFuncのシグネチャを見ると、2番目の引数は実行される関数です。 Goでは関数を比較できないため、送信された関数をテストすることはできません。そのため、実行に時間と印刷する量を必要とする、time.AfterFuncの周りにある種のラッパーを書く必要があります。私たちはそれをスパイすることができます。

最初にテストを書く

スイートに新しいテストを追加する
1
t.Run("it schedules printing of blind values", func(t *testing.T) {
2
in := strings.NewReader("Chris wins\n")
3
playerStore := &poker.StubPlayerStore{}
4
blindAlerter := &SpyBlindAlerter{}
5
6
cli := poker.NewCLI(playerStore, in, blindAlerter)
7
cli.PlayPoker()
8
9
if len(blindAlerter.alerts) != 1 {
10
t.Fatal("expected a blind alert to be scheduled")
11
}
12
})
Copied!
SpyBlindAlerterを作成し、それをCLIに挿入しようとしています。次に、 PlayPokerを呼び出した後、アラートがスケジュールされていることを確認します。
(最初に最も単純なシナリオを実行することを思い出して、次に反復します。)
ここにSpyBlindAlerterの定義があります
1
type SpyBlindAlerter struct {
2
alerts []struct {
3
scheduledAt time.Duration
4
amount int
5
}
6
}
7
8
func (s *SpyBlindAlerter) ScheduleAlertAt(duration time.Duration, amount int) {
9
s.alerts = append(s.alerts, struct {
10
scheduledAt time.Duration
11
amount int
12
}{duration, amount})
13
}
Copied!

テストを実行してみます

1
./CLI_test.go:32:27: too many arguments in call to poker.NewCLI
2
have (*poker.StubPlayerStore, *strings.Reader, *SpyBlindAlerter)
3
want (poker.PlayerStore, io.Reader)
Copied!

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

新しい引数を追加しましたが、コンパイラは不満を言っています。 厳密に言えば、最小限のコードはNewCLI*SpyBlindAlerterを受け入れるようにすることですが、少し騙して、依存関係をインターフェイスとして定義しましょう。
1
type BlindAlerter interface {
2
ScheduleAlertAt(duration time.Duration, amount int)
3
}
Copied!
そして、それをコンストラクタに追加します。
1
func NewCLI(store PlayerStore, in io.Reader, alerter BlindAlerter) *CLI
Copied!
他のテストはNewCLIBlindAlerterが渡されていないので失敗します。
BlindAlerterをスパイすることは他のテストには関係ないので、テストファイルに追加します。
1
var dummySpyAlerter = &SpyBlindAlerter{}
Copied!
そして、コンパイルの問題を修正するために他のテストでそれを使ってください。 これを「ダミー"dummy"」と表記することで、テストの読者には重要ではないことが明らかになります。
これでテストはコンパイルされ、新しいテストは失敗します。
1
=== RUN TestCLI
2
=== RUN TestCLI/it_schedules_printing_of_blind_values
3
--- FAIL: TestCLI (0.00s)
4
--- FAIL: TestCLI/it_schedules_printing_of_blind_values (0.00s)
5
CLI_test.go:38: expected a blind alert to be scheduled
Copied!

通過するのに十分なコードを書く

これをPlayPokerメソッドで参照できるようにするためにBlindAlerterCLIのフィールドとして追加する必要があります。
1
type CLI struct {
2
playerStore PlayerStore
3
in *bufio.Scanner
4
alerter BlindAlerter
5
}
6
7
func NewCLI(store PlayerStore, in io.Reader, alerter BlindAlerter) *CLI {
8
return &CLI{
9
playerStore: store,
10
in: bufio.NewScanner(in),
11
alerter: alerter,
12
}
13
}
Copied!
テストを通過させるために、BlindAlerterを好きなもので呼び出すことができます。
1
func (cli *CLI) PlayPoker() {
2
cli.alerter.ScheduleAlertAt(5*time.Second, 100)
3
userInput := cli.readLine()
4
cli.playerStore.RecordWin(extractWinner(userInput))
5
}
Copied!
次は、5人のプレイヤーのために、私たちが望むすべてのアラートのスケジュールを確認したいと思います。

最初にテストを書く

1
t.Run("it schedules printing of blind values", func(t *testing.T) {
2
in := strings.NewReader("Chris wins\n")
3
playerStore := &poker.StubPlayerStore{}
4
blindAlerter := &SpyBlindAlerter{}
5
6
cli := poker.NewCLI(playerStore, in, blindAlerter)
7
cli.PlayPoker()
8
9
cases := []struct {
10
expectedScheduleTime time.Duration
11
expectedAmount int
12
}{
13
{0 * time.Second, 100},
14
{10 * time.Minute, 200},
15
{20 * time.Minute, 300},
16
{30 * time.Minute, 400},
17
{40 * time.Minute, 500},
18
{50 * time.Minute, 600},
19
{60 * time.Minute, 800},
20
{70 * time.Minute, 1000},
21
{80 * time.Minute, 2000},
22
{90 * time.Minute, 4000},
23
{100 * time.Minute, 8000},
24
}
25
26
for i, c := range cases {
27
t.Run(fmt.Sprintf("%d scheduled for %v", c.expectedAmount, c.expectedScheduleTime), func(t *testing.T) {
28
29
if len(blindAlerter.alerts) <= i {
30
t.Fatalf("alert %d was not scheduled %v", i, blindAlerter.alerts)
31
}
32
33
alert := blindAlerter.alerts[i]
34
35
amountGot := alert.amount
36
if amountGot != c.expectedAmount {
37
t.Errorf("got amount %d, want %d", amountGot, c.expectedAmount)
38
}
39
40
gotScheduledTime := alert.scheduledAt
41
if gotScheduledTime != c.expectedScheduleTime {
42
t.Errorf("got scheduled time of %v, want %v", gotScheduledTime, c.expectedScheduleTime)
43
}
44
})
45
}
46
})
Copied!
テーブルベースのテストはここではうまく機能し、要件が何であるかを明確に示しています。テーブルを実行し、SpyBlindAlerterをチェックして、アラートが正しい値でスケジュールされているかどうかを確認します。

テストを実行してみる

こんな感じで失敗を重ねるといいですよ。
1
=== RUN TestCLI
2
--- FAIL: TestCLI (0.00s)
3
=== RUN TestCLI/it_schedules_printing_of_blind_values
4
--- FAIL: TestCLI/it_schedules_printing_of_blind_values (0.00s)
5
=== RUN TestCLI/it_schedules_printing_of_blind_values/100_scheduled_for_0s
6
--- FAIL: TestCLI/it_schedules_printing_of_blind_values/100_scheduled_for_0s (0.00s)
7
CLI_test.go:71: got scheduled time of 5s, want 0s
8
=== RUN TestCLI/it_schedules_printing_of_blind_values/200_scheduled_for_10m0s
9
--- FAIL: TestCLI/it_schedules_printing_of_blind_values/200_scheduled_for_10m0s (0.00s)
10
CLI_test.go:59: alert 1 was not scheduled [{5000000000 100}]
Copied!

通過するのに十分なコードを書く

1
func (cli *CLI) PlayPoker() {
2
3
blinds := []int{100, 200, 300, 400, 500, 600, 800, 1000, 2000, 4000, 8000}
4
blindTime := 0 * time.Second
5
for _, blind := range blinds {
6
cli.alerter.ScheduleAlertAt(blindTime, blind)
7
blindTime = blindTime + 10*time.Minute
8
}
9
10
userInput := cli.readLine()
11
cli.playerStore.RecordWin(extractWinner(userInput))
12
}
Copied!
それは、私たちがすでに持っていたものよりもそれほど複雑ではありません。 私たちは今、blindsの配列を反復処理し、増加するblindTimeでスケジューラを呼び出しています

リファクタリング♪

PlayPoker」を少し明確にするために、スケジュールされたアラートをメソッドにカプセル化できます。
1
func (cli *CLI) PlayPoker() {
2
cli.scheduleBlindAlerts()
3
userInput := cli.readLine()
4
cli.playerStore.RecordWin(extractWinner(userInput))
5
}
6
7
func (cli *CLI) scheduleBlindAlerts() {
8
blinds := []int{100, 200, 300, 400, 500, 600, 800, 1000, 2000, 4000, 8000}
9
blindTime := 0 * time.Second
10
for _, blind := range blinds {
11
cli.alerter.ScheduleAlertAt(blindTime, blind)
12
blindTime = blindTime + 10*time.Minute
13
}
14
}
Copied!
最後に、テストは少し不格好に見えます。同じものを表す2つの匿名構造体、ScheduledAlertがあります。それを新しい型にリファクタリングして、いくつかのヘルパーを比較してみましょう。
1
type scheduledAlert struct {
2
at time.Duration
3
amount int
4
}
5
6
func (s scheduledAlert) String() string {
7
return fmt.Sprintf("%d chips at %v", s.amount, s.at)
8
}
9
10
type SpyBlindAlerter struct {
11
alerts []scheduledAlert
12
}
13
14
func (s *SpyBlindAlerter) ScheduleAlertAt(at time.Duration, amount int) {
15
s.alerts = append(s.alerts, scheduledAlert{at, amount})
16
}
Copied!
タイプに String()メソッドを追加したので、テストが失敗した場合にうまく表示されます。
新しいタイプを使用するようにテストを更新します。
1
t.Run("it schedules printing of blind values", func(t *testing.T) {
2
in := strings.NewReader("Chris wins\n")
3
playerStore := &poker.StubPlayerStore{}
4
blindAlerter := &SpyBlindAlerter{}
5
6
cli := poker.NewCLI(playerStore, in, blindAlerter)
7
cli.PlayPoker()
8
9
cases := []scheduledAlert{
10
{0 * time.Second, 100},
11
{10 * time.Minute, 200},
12
{20 * time.Minute, 300},
13
{30 * time.Minute, 400},
14
{40 * time.Minute, 500},
15
{50 * time.Minute, 600},
16
{60 * time.Minute, 800},
17
{70 * time.Minute, 1000},
18
{80 * time.Minute, 2000},
19
{90 * time.Minute, 4000},
20
{100 * time.Minute, 8000},
21
}
22
23
for i, want := range cases {
24
t.Run(fmt.Sprint(want), func(t *testing.T) {
25
26
if len(blindAlerter.alerts) <= i {
27
t.Fatalf("alert %d was not scheduled %v", i, blindAlerter.alerts)
28
}
29
30
got := blindAlerter.alerts[i]
31
assertScheduledAlert(t, got, want)
32
})
33
}
34
})
Copied!
自分で「assertScheduledAlert」を実装します。
ここではかなりの時間をかけてテストを作成しており、アプリケーションと統合しないことはややエッチです。これ以上の要件に取り掛かる前に、その問題に取り組みましょう。
アプリを実行してみてください。アプリがコンパイルされず、「NewCLI」への引数が足りないという不満があります。
アプリケーションで使用できるBlindAlerterの実装を作成してみましょう。
BlindAlerter.goを作成し、BlindAlerterインターフェースを移動して、以下の新しいものを追加します
1
package poker
2
3
import (
4
"fmt"
5
"os"
6
"time"
7
)
8
9
type BlindAlerter interface {
10
ScheduleAlertAt(duration time.Duration, amount int)
11
}
12
13
type BlindAlerterFunc func(duration time.Duration, amount int)
14
15
func (a BlindAlerterFunc) ScheduleAlertAt(duration time.Duration, amount int) {
16
a(duration, amount)
17
}
18
19
func StdOutAlerter(duration time.Duration, amount int) {
20
time.AfterFunc(duration, func() {
21
fmt.Fprintf(os.Stdout, "Blind is now %d\n", amount)
22
})
23
}
Copied!
type は、structだけでなく、インターフェイスを実装できることに注意してください。 1つの関数が定義されたインターフェースを公開するライブラリを作成する場合、MyInterfaceFuncタイプも公開することは一般的な慣用法です。
このタイプはあなたのインターフェースも実装するfuncになります。こうすることで、インターフェイスのユーザーは、関数だけでインターフェイスを実装することができます。 空のstructタイプを作成する必要はありません。
次に、関数と同じシグネチャを持つ関数StdOutAlerterを作成し、time.AfterFuncを使用して、それをos.Stdoutに出力するようにスケジュールします。
この動作を確認するには、NewCLIを作成するmainを更新します。
1
poker.NewCLI(store, os.Stdin, poker.BlindAlerterFunc(poker.StdOutAlerter)).PlayPoker()
Copied!
実行する前に、「CLI」の「blindTime」の増分を10分ではなく10秒に変更すると、実際の動作を確認できます。
10秒ごとに予想されるように、ブラインド値が出力されるはずです。 CLIに「Shaun wins」と入力すると、プログラムが予期したとおりに停止することに注意してください。
ゲームは常に5人でプレイされるとは限らないため、ゲームを開始する前に、ユーザー数を入力するようユーザーに促す必要があります。

最初にテストを書く

確認するために、StdOutに書き込まれた内容を記録するプレーヤーの数を入力するように求めています。 これを数回実行しました。os.Stdoutio.Writerであることを知っているので、テストで依存性注入を使用してbytes.Bufferを渡し、私たちのコードが何を書くか見てください。
このテストでは、他の共同編集者についてはまだ気にしていないため、テストファイルでダミーを作成しました。
CLIに4つの依存関係があることに少し注意する必要があります。 これは、多すぎる責任を持ち始めているように思われます。とりあえずそれと共存して、この新しい機能を追加するときにリファクタリングが出現するかどうか見てみましょう。
1
var dummyBlindAlerter = &SpyBlindAlerter{}
2
var dummyPlayerStore = &poker.StubPlayerStore{}
3
var dummyStdIn = &bytes.Buffer{}
4
var dummyStdOut = &bytes.Buffer{}
Copied!
これが新しいテストです。
1
t.Run("it prompts the user to enter the number of players", func(t *testing.T) {
2
stdout := &bytes.Buffer{}
3
cli := poker.NewCLI(dummyPlayerStore, dummyStdIn, stdout, dummyBlindAlerter)
4
cli.PlayPoker()
5
6
got := stdout.String()
7
want := "Please enter the number of players: "
8
9
if got != want {
10
t.Errorf("got %q, want %q", got, want)
11
}
12
})
Copied!
mainos.Stdoutになるものを渡し、何が書き込まれるかを確認します。

テストを実行してみます

1
./CLI_test.go:38:27: too many arguments in call to poker.NewCLI
2
have (*poker.StubPlayerStore, *bytes.Buffer, *bytes.Buffer, *SpyBlindAlerter)
3
want (poker.PlayerStore, io.Reader, poker.BlindAlerter)
Copied!

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

新しい依存関係があるため、NewCLIを更新する必要があります
1
func NewCLI(store PlayerStore, in io.Reader, out io.Writer, alerter BlindAlerter) *CLI
Copied!
その他のテストは、NewCLIに渡されるio.Writerがないため、コンパイルに失敗します。
他のテスト用に「dummyStdout」を追加します。
新しいテストはそのように失敗するはずです。
1
=== RUN TestCLI
2
--- FAIL: TestCLI (0.00s)
3
=== RUN TestCLI/it_prompts_the_user_to_enter_the_number_of_players
4
--- FAIL: TestCLI/it_prompts_the_user_to_enter_the_number_of_players (0.00s)
5
CLI_test.go:46: got '', want 'Please enter the number of players: '
6
FAIL
Copied!

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

CLIに新しい依存関係を追加して、PlayPokerで参照できるようにする必要があります。
1
type CLI struct {
2
playerStore PlayerStore
3
in *bufio.Scanner
4
out io.Writer
5
alerter BlindAlerter
6
}
7
8
func NewCLI(store PlayerStore, in io.Reader, out io.Writer, alerter BlindAlerter) *CLI {
9
return &CLI{
10
playerStore: store,
11
in: bufio.NewScanner(in),
12
out: out,
13
alerter: alerter,
14
}
15
}
Copied!
最後に、ゲームの開始時にプロンプ​​トを書き込むことができます。
1
func (cli *CLI) PlayPoker() {
2
fmt.Fprint(cli.out, "Please enter the number of players: ")
3
cli.scheduleBlindAlerts()
4
userInput := cli.readLine()
5
cli.playerStore.RecordWin(extractWinner(userInput))
6
}
Copied!

リファクタリング♪

定数に抽出する必要があるプロンプトの重複文字列があります。
1
const PlayerPrompt = "Please enter the number of players: "
Copied!
テストコードとCLIの両方でこれを使用してください。
次に、番号を送信して抽出する必要があります。 目的の効果があったかどうかを確認する唯一の方法は、スケジュールされたブラインドアラートを確認することです。

最初にテストを書く

1
t.Run("it prompts the user to enter the number of players", func(t *testing.T) {
2
stdout := &bytes.Buffer{}
3
in := strings.NewReader("7\n")
4
blindAlerter := &SpyBlindAlerter{}
5
6
cli := poker.NewCLI(dummyPlayerStore, in, stdout, blindAlerter)
7
cli.PlayPoker()
8
9
got := stdout.String()
10
want := poker.PlayerPrompt
11
12
if got != want {
13
t.Errorf("got %q, want %q", got, want)
14
}
15
16
cases := []scheduledAlert{
17
{0 * time.Second, 100},
18
{12 * time.Minute, 200},
19
{24 * time.Minute, 300},
20
{36 * time.Minute, 400},
21
}
22
23
for i, want := range cases {
24
t.Run(fmt.Sprint(want), func(t *testing.T) {
25
26
if len(blindAlerter.alerts) <= i {
27
t.Fatalf("alert %d was not scheduled %v", i, blindAlerter.alerts)
28
}
29
30
got := blindAlerter.alerts[i]
31
assertScheduledAlert(t, got, want)
32
})
33
}
34
})
Copied!
いたたたた!たくさんの変更。。
  • StdInのダミーを削除し、代わりに、7を入力するユーザーを表すモックバージョンを送信します。
  • また、ブラインドアラートでダミーを削除して、プレーヤー数がスケジュールに影響を与えていることを確認できるようにしました
  • スケジュールされているアラートをテストします

テストを実行してみます

テストはコンパイルされ、失敗するはずですが、5人のプレーヤーに基づいてゲームをハードコーディングしているため、スケジュールされた時間が間違っていると報告されます。
1
=== RUN TestCLI
2
--- FAIL: TestCLI (0.00s)
3
=== RUN TestCLI/it_prompts_the_user_to_enter_the_number_of_players
4
--- FAIL: TestCLI/it_prompts_the_user_to_enter_the_number_of_players (0.00s)
5
=== RUN TestCLI/it_prompts_the_user_to_enter_the_number_of_players/100_chips_at_0s
6
--- PASS: TestCLI/it_prompts_the_user_to_enter_the_number_of_players/100_chips_at_0s (0.00s)
7
=== RUN TestCLI/it_prompts_the_user_to_enter_the_number_of_players/200_chips_at_12m0s
Copied!

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

この作業を行うために必要なすべての罪を自由に犯すことを忘れないでください。 作業用ソフトウェアができたら、作成しようとしている混乱のリファクタリングに取り掛かることができます。
1
func (cli *CLI) PlayPoker() {
2
fmt.Fprint(cli.out, PlayerPrompt)
3
4
numberOfPlayers, _ := strconv.Atoi(cli.readLine())
5
6
cli.scheduleBlindAlerts(numberOfPlayers)
7
8
userInput := cli.readLine()
9
cli.playerStore.RecordWin(extractWinner(userInput))
10
}
11
12
func (cli *CLI) scheduleBlindAlerts(numberOfPlayers int) {
13
blindIncrement := time.Duration(5+numberOfPlayers) * time.Minute
14
15
blinds := []int{100, 200, 300, 400, 500, 600, 800, 1000, 2000, 4000, 8000}
16
blindTime := 0 * time.Second
17
for _, blind := range blinds {
18
cli.alerter.ScheduleAlertAt(blindTime, blind)
19
blindTime = blindTime + blindIncrement
20
}
21
}
Copied!
  • numberOfPlayersInputを文字列に読み込みます
  • ユーザーから入力を取得するために cli.readLine()を使用し、次にエラーシナリオを無視して、Atoiを呼び出して整数に変換します。後でそのシナリオのテストを作成する必要があります。
  • ここから、scheduleBlindAlertsを変更して、多数のプレーヤーを受け入れます。次に、ブラインド量を反復するときに「blindTime」に追加するために使用する「blindIncrement」時間を計算します
新しいテストは修正されましたが、システムが機能するのは、ユーザーが数字を入力してゲームを開始した場合に限られるためです。 ユーザー入力を変更してテストを修正する必要があります。 これにより、番号とそれに続く改行が追加されます(これは、現在のアプローチのさらに多くの欠陥を強調しています)。

リファクタリング♪

これは少し恐ろしいことだと思いますか? テストを聞いてみましょう
  • いくつかのアラートをスケジュールしていることをテストするために、4つの異なる依存関係を設定しました。システムに thing の依存関係がたくさんある場合、それは多すぎることを意味します。視覚的には、テストが雑然としていることがわかります。
  • 私には、ユーザー入力の読み取りと、実行したいビジネスロジックとの間をより明確に抽象化する必要があるように感じます
  • より適切なテストは、_このユーザー入力が与えられた場合、正しいタイプのプレーヤーで新しいタイプGameを呼び出すかどうかです。
  • 次に、スケジューリングのテストを新しいGameのテストに抽出します。
最初に「Game」に向けてリファクタリングでき、テストは引き続き成功します。必要な構造変更を行ったら、懸念の分離を反映するためにテストをリファクタリングする方法について考えることができます
リファクタリングに変更を加えるときは、それらをできるだけ小さくして、テストを再実行し続けるようにしてください。
まず自分で試してください。 「Game」が提供するものと「CLI」が行うべきことの境界について考えてください。
今のところは、NewCLIの外部インターフェースを変更しないでください。 テストコードとクライアントコードを同時に変更したくないからです。
これは私が思いついたものです。
1
// game.go
2
type Game struct {
3
alerter BlindAlerter
4
store PlayerStore
5
}
6
7
func (p *Game) Start(numberOfPlayers int) {
8
blindIncrement := time.Duration(5+numberOfPlayers) * time.Minute
9
10
blinds := []int{100, 200, 300, 400, 500, 600, 800, 1000, 2000, 4000, 8000}
11
blindTime := 0 * time.Second
12
for _, blind := range blinds {
13
p.alerter.ScheduleAlertAt(blindTime, blind)
14
blindTime = blindTime + blindIncrement
15
}
16
}
17
18
func (p *Game) Finish(winner string) {
19
p.store.RecordWin(winner)
20
}
21
22
// cli.go
23
type CLI struct {
24
in *bufio.Scanner
25
out io.Writer
26
game *Game
27
}
28
29
func NewCLI(store PlayerStore, in io.Reader, out io.Writer, alerter BlindAlerter) *CLI {
30
return &CLI{
31
in: bufio.NewScanner(in),
32
out: out,
33
game: &Game{
34
alerter: alerter,
35
store: store,
36
},
37
}
38
}
39
40
const PlayerPrompt = "Please enter the number of players: "
41
42
func (cli *CLI) PlayPoker() {
43
fmt.Fprint(cli.out, PlayerPrompt)
44
45
numberOfPlayersInput := cli.readLine()
46
numberOfPlayers, _ := strconv.Atoi(strings.Trim(numberOfPlayersInput, "\n"))
47
48
cli.game.Start(numberOfPlayers)
49
50
winnerInput := cli.readLine()
51
winner := extractWinner(winnerInput)
52
53
cli.game.Finish(winner)
54
}
55
56
func extractWinner(userInput string) string {
57
return strings.Replace(userInput, " wins\n", "", 1)
58
}
59
60
func (cli *CLI) readLine() string {
61
cli.in.Scan()
62
return cli.in.Text()
63
}
Copied!
「ドメイン」の観点から
  • 何人がプレイしているかを示す「Game」を「開始Start」したい
  • 勝者を宣言して「Game」を「終了Finish」したい
新しいGameタイプはこれをカプセル化します。
この変更により、BlindAlerterPlayerStoreGameに渡されました。 これは、結果のアラートと保存を担当するためです。
私たちのCLIは今ちょうど関係しています
  • 既存の依存関係を使用してGameを構築します(これは次にリファクタリングします)
  • ユーザー入力をGameのメソッド呼び出しとして解釈する
「大きな」リファクタリングを行わないようにしたいと考えています。 これにより、長期間にわたってテストが失敗し、ミスの可能性が高まるためです。 (大規模な分散型チームで作業している場合、これは非常に重要です)
まず最初に、Gameをリファクタリングして、CLIに注入します。 テストを最小限に変更してそれを容易にし、次に、テストをユーザー入力の解析とゲーム管理のテーマに分割する方法を確認します。
今必要なのは、NewCLIを変更することだけです。
1
func NewCLI(in io.Reader, out io.Writer, game *Game) *CLI {
2
return &CLI{
3
in: bufio.NewScanner(in),
4
out: out,
5
game: game,
6
}
7
}
Copied!
これはすでに改善のように感じられます。 依存関係は少なく、私たちの依存関係リストは、CLIが入出力に関係し、ゲーム固有のアクションをGameに委任するという、全体的な設計目標を反映しています。
コンパイルしてみると問題があります。これらの問題は自分で修正できるはずです。 今すぐGameのモックを作成する必要はありません。すべてをコンパイルしてテストするために「実際の」Gameを初期化するだけです。
これを行うには、コンストラクタを作成する必要があります。
1
func NewGame(alerter BlindAlerter, store PlayerStore) *Game {
2
return &Game{
3
alerter:alerter,
4
store:store,
5
}
6
}
Copied!
これは、修正されたテストの設定の1つの例です。
1
stdout := &bytes.Buffer{}
2
in := strings.NewReader("7\n")
3
blindAlerter := &SpyBlindAlerter{}
4
game := poker.NewGame(blindAlerter, dummyPlayerStore)
5
6
cli := poker.NewCLI(in, stdout, game)
7
cli.PlayPoker()
Copied!
テストを修正して再び緑に戻るのにそれほどの労力は必要ありませんが、それがポイントです! 次の段階の前にmain.goも修正するようにしてください。
1
// main.go
2
game := poker.NewGame(poker.BlindAlerterFunc(poker.StdOutAlerter), store)
3
cli := poker.NewCLI(os.Stdin, os.Stdout, game)
4
cli.PlayPoker()
Copied!
「ゲームGame」を抽出したので、ゲーム固有のアサーションをCLIとは別のテストに移動する必要があります。
これは、CLIテストをコピーするための単なる練習ですが、依存関係は少なくなっています。
1
func TestGame_Start(t *testing.T) {
2
t.Run("schedules alerts on game start for 5 players", func(t *testing.T) {
3
blindAlerter := &poker.SpyBlindAlerter{}
4
game := poker.NewGame(blindAlerter, dummyPlayerStore)
5
6
game.Start(5)
7
8
cases := []poker.ScheduledAlert{
9
{At: 0 * time.Second, Amount: 100},
10
{At: 10 * time.Minute, Amount: 200},
11
{At: 20 * time.Minute, Amount: 300},
12
{At: 30 * time.Minute, Amount: 400},
13
{At: 40 * time.Minute, Amount: 500},
14
{At: 50 * time.Minute, Amount: 600},
15
{At: 60 * time.Minute, Amount: 800},
16
{At: 70 * time.Minute, Amount: 1000},
17
{At: 80 * time.Minute, Amount: 2000},
18
{At: 90 * time.Minute, Amount: 4000},
19
{At: 100 * time.Minute, Amount: 8000},
20
}
21
22
checkSchedulingCases(cases, t, blindAlerter)
23
})
24
25
t.Run("schedules alerts on game start for 7 players", func(t *testing.T) {
26
blindAlerter := &poker.SpyBlindAlerter{}
27
game := poker.NewGame(blindAlerter, dummyPlayerStore)
28
29
game.Start(7)
30
31
cases := []poker.ScheduledAlert{
32
{At: 0 * time.Second, Amount: 100},
33
{At: 12 * time.Minute, Amount: 200},
34
{At: 24 * time.Minute, Amount: 300},
35
{At: 36 * time.Minute, Amount: 400},
36
}
37
38
checkSchedulingCases(cases, t, blindAlerter)
39
})
40
41
}
42
43
func TestGame_Finish(t *testing.T) {
44
store := &poker.StubPlayerStore{}
45
game := poker.NewGame(dummyBlindAlerter, store)
46
winner := "Ruth"
47
48
game.Finish(winner)
49
poker.AssertPlayerWin(t, store, winner)
50
}
Copied!
ポーカーのゲームが始まったときに何が起きるかの背後にある意図が今やはるかに明確になりました。
ゲームがいつ終了するかについても、テストの上を移動してください。
満足したら、ゲームロジックのテストを移行しました。 CLIテストを簡略化して、意図した責任をより明確に反映できます。
  • ユーザー入力を処理し、必要に応じてGameのメソッドを呼び出します
  • 出力を送信
  • 重要なのは、ゲームがどのように機能するかについての実際の仕組みについて知らない
これを行うには、CLIが具体的なGameタイプに依存しないようにする必要がありますが、代わりにStart(numberOfPlayers)およびFinish(winner)のインターフェースを受け入れます。次に、そのタイプのスパイを作成し、正しい呼び出しが行われたことを確認できます。
ここに、ネーミングが時々厄介であることがわかります。Gameの名前をTexasHoldemに変更します(これが現在プレイしているゲームの 種類 であるため)。新しいインターフェイスはGameと呼ばれます。これは、CLIが私たちがプレイしている実際のゲームに気づいていないという概念と、「Start」および「Finish」したときに何が起こるかを忠実に保ちます。
1
type Game interface {
2
Start(numberOfPlayers int)
3
Finish(winner string)
4
}
Copied!
CLI内の*Gameへのすべての参照を置き換え、それらをGame(our new interface)に置き換えます。 いつものように、リファクタリングしている間は、テストを再実行してすべてが緑色であることを確認します。
TexasHoldemからCLIを分離したので、スパイを使用して、正しい引数で、期待どおりにStartFinishが呼び出されることを確認できます。
Gameを実装するスパイを作成する
1
type GameSpy struct {
2
StartedWith int
3
FinishedWith string
4
}
5
6
func (g *GameSpy) Start(numberOfPlayers int) {
7
g.StartedWith = numberOfPlayers
8
}
9
10
func (g *GameSpy) Finish(winner string) {
11
g.FinishedWith = winner
12
}
Copied!
ゲーム固有のロジックをテストするすべてのCLIテストを、GameSpyの呼び出し方法のチェックに置き換えます。これは、テストにおけるCLIの責任を明確に反映します。
これは、修正されたテストの1つの例です。 残りを自分で試して、行き詰まった場合はソースコードを確認してください。
1
t.Run("it prompts the user to enter the number of players and starts the game", func(t *testing.T) {
2
stdout := &bytes.Buffer{}
3
in := strings.NewReader("7\n")
4
game := &GameSpy{}
5
6
cli := poker.NewCLI(in, stdout, game)
7
cli.PlayPoker()
8
9
gotPrompt := stdout.String()
10
wantPrompt := poker.PlayerPrompt
11
12
if gotPrompt != wantPrompt {
13
t.Errorf("got %q, want %q", gotPrompt, wantPrompt)
14
}
15
16
if game.StartedWith != 7 {
17
t.Errorf("wanted Start called with 7 but got %d", game.StartedWith)
18
}
19
})
Copied!
懸念事項を明確に分離したので、CLIでIOの周辺のケースをチェックするのが簡単になります。
プレイヤー数の入力を求められたときにユーザーが非数値を入力するシナリオに対処する必要があります。
私たちのコードはゲームを開始してはならず、ユーザーに便利なエラーを出力して終了します。

最初にテストを書く

ゲームが開始しないことを確認することから始めます
1
t.Run("it prints an error when a non numeric value is entered and does not start the game", func(t *testing.T) {
2
stdout := &bytes.Buffer{}
3
in := strings.NewReader("Pies\n")
4
game := &GameSpy{}
5
6
cli := poker.NewCLI(in, stdout, game)
7
cli.PlayPoker()
8
9
if game.StartCalled {
10
t.Errorf("game should not have started")
11
}
12
})
Copied!
GameSpy」にフィールド「StartCalled」を追加する必要があります。これは「Start」が呼び出された場合にのみ設定されます

テストを実行してみます

1
=== RUN TestCLI/it_prints_an_error_when_a_non_numeric_value_is_entered_and_does_not_start_the_game
2
--- FAIL: TestCLI/it_prints_an_error_when_a_non_numeric_value_is_entered_and_does_not_start_the_game (0.00s)
3
CLI_test.go:62: game should not have started
Copied!

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

Atoiと呼ぶところは、エラーをチェックするだけです
1
numberOfPlayers, err := strconv.Atoi(cli.readLine())
2
3
if err != nil {
4
return
5
}
Copied!
次に、ユーザーが間違ったことをユーザーに通知する必要があるため、stdoutに出力される内容を評価します。

最初にテストを書く

前に「stdout」に出力されたものをアサートしたので、今のところそのコードをコピーできます
1
gotPrompt := stdout.String()
2
3
wantPrompt := poker.PlayerPrompt + "you're so silly"
4
5
if gotPrompt != wantPrompt {