Time

未匹配的标注

Time

你可以在这里找到本章的所有代码

产品负责人希望我们来扩展命令行应用程序的功能来帮助一群人玩德州扑克。

需要了解的扑克的知识

您不需要了解很多关于扑克的知识,只需要在特定的时间间隔告诉所有玩家一个稳步增长的「盲注」值。

我们的应用程序将帮助跟踪何时应该设置盲注,以及应该设置多少。

  • 当一场扑克游戏开始时,它会询问有多少玩家在玩。这决定了「盲注」的时间。

    • 基本时间是 5 分钟。

    • 每多一名玩家就增加 1 分钟。

    • 例如 6 名玩家相当于有 11 分钟的盲注时间。

  • 在盲注时间结束后,游戏会提醒每个玩家盲注的新筹码。

  • 盲注从100个筹码开始,然后是200、400、600、1000、2000,然后继续翻倍,直到游戏结束(我们之前的「Ruth wins」功能仍然可以完成游戏)

提醒代码

在前一章中,我们开始了命令行应用程序,它已经接受了 {name} wins。下面是当前 CLI 代码的样子,但是在开始之前一定要熟悉其他代码。

type CLI struct {
    playerStore PlayerStore
    in          *bufio.Scanner
}

func NewCLI(store PlayerStore, in io.Reader) *CLI {
    return &CLI{
        playerStore: store,
        in:          bufio.NewScanner(in),
    }
}

func (cli *CLI) PlayPoker() {
    userInput := cli.readLine()
    cli.playerStore.RecordWin(extractWinner(userInput))
}

func extractWinner(userInput string) string {
    return strings.Replace(userInput, " wins", "", 1)
}

func (cli *CLI) readLine() string {
    cli.in.Scan()
    return cli.in.Text()
}

time.AfterFunc

我们希望能够让我们的程序依赖于玩家的数量打印盲注值。

为了限制我们需要做的范围,我们暂时忘记玩家的数量,假设有 5 个玩家,所以我们将测试 每10分钟打印一次盲注的新值

标准库为我们提供了 func AfterFunc(d Duration, f func()) <...

AfterFunc 等待时间过去,然后在自己的协程中中调用 f。它返回一个 Timer,可以使用它的 Stop 方法来取消调用。

time.Duration

持续时间表示两个瞬间之间经过的时间,以 int64 纳秒计数表示。

时间库有许多常量,可以让您将这些纳秒相乘,这样对于我们将要处理的场景来说,它们的可读性更好

5  * time.Second

当我们调用 PlayPoker 时,我们会安排所有的盲注通知。

测试这个可能有点棘手。我们想要验证每个时间段都被安排了正确的盲注,但是如果你看一下 time.AfterFunc 的签名。它的第二个参数是它要运行的函数。你不能在 Go 中比较函数,所以我们无法测试发送了什么函数。所以我们需要封装一些关于 time.AfterFunc 的函数,这将需要时间来运行和打印的数量,所以我们可以窥探。

首先写测试

添加一个新的测试

t.Run("it schedules printing of blind values", func(t *testing.T) {
    in := strings.NewReader("Chris wins\n")
    playerStore := &poker.StubPlayerStore{}
    blindAlerter := &SpyBlindAlerter{}

    cli := poker.NewCLI(playerStore, in, blindAlerter)
    cli.PlayPoker()

    if len(blindAlerter.alerts) != 1 {
        t.Fatal("expected a blind alert to be scheduled")
    }
})

你会注意到,我们写了一个 SpyBlindAlerter,我们试图将其注入到我们的 CLI 中,然后在调用 PlayerPoker 后检查是否安排了通知。

(请记住,我们只是从最简单的场景开始,然后进行迭代。)

下面是 SpyBlindAlerter 的定义

type SpyBlindAlerter struct {
    alerts []struct{
        scheduledAt time.Duration
        amount int
    }
}

func (s *SpyBlindAlerter) ScheduleAlertAt(duration time.Duration, amount int) {
    s.alerts = append(s.alerts, struct {
        scheduledAt time.Duration
        amount int
    }{duration,  amount})
}

尝试运行测试

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

写最少量的代码让测试跑起来并检查失败的输出信息

编译器抱怨我们新添加的参数。严格地说 最少的代码是使 NewCLI 接受 *SpyBlindAlerter,但让我们作弊一点,只是把依赖定义为一个接口。

type BlindAlerter interface {
    ScheduleAlertAt(duration time.Duration, amount int)
}

然后将其添加到构造函数中

func  NewCLI(store PlayerStore, in io.Reader, alerter BlindAlerter)  *CLI

你的其他测试现在将失败,因为他们没有将一个 BlindAlerter 传入 NewCLI

监视 BlindAlerter 与其他测试无关,因此在测试文件中添加它

var dummySpyAlerter =  &SpyBlindAlerter{}

然后在其他测试中使用它来修复编译问题。通过给它贴上「假的」标签,测试的读者就会知道它并不重要了。

> Dummy objects are passed around but n...

现在应该编译测试了,我们的新测试失败了。

=== RUN   TestCLI
=== RUN   TestCLI/it_schedules_printing_of_blind_values
--- FAIL: TestCLI (0.00s)
    --- FAIL: TestCLI/it_schedules_printing_of_blind_values (0.00s)
        CLI_test.go:38: expected a blind alert to be scheduled

编写足够的代码让测试通过

我们需要添加 BlindAlerter 作为我们的 CLI 字段,这样我们就可以在 PlayPoker 方法中引用它。

type CLI struct {
    playerStore PlayerStore
    in          *bufio.Reader
    alerter     BlindAlerter
}

func NewCLI(store PlayerStore, in io.Reader, alerter BlindAlerter) *CLI {
    return &CLI{
        playerStore: store,
        in:          bufio.NewReader(in),
        alerter:     alerter,
    }
}

为了通过测试,我们可以用任何我们喜欢的东西来呼叫我们的 BlindAlerter

func (cli *CLI) PlayPoker() {
    cli.alerter.ScheduleAlertAt(5 * time.Second, 100)
    userInput := cli.readLine()
    cli.playerStore.RecordWin(extractWinner(userInput))
}

接下来我们要检查它是否为 5 个玩家安排了我们希望的提醒

首先写测试

    t.Run("it schedules printing of blind values", func(t *testing.T) {
        in := strings.NewReader("Chris wins\n")
        playerStore := &poker.StubPlayerStore{}
        blindAlerter := &SpyBlindAlerter{}

        cli := poker.NewCLI(playerStore, in, blindAlerter)
        cli.PlayPoker()

        cases := []struct{
            expectedScheduleTime time.Duration
            expectedAmount       int
        } {
            {0 * time.Second, 100},
            {10 * time.Minute, 200},
            {20 * time.Minute, 300},
            {30 * time.Minute, 400},
            {40 * time.Minute, 500},
            {50 * time.Minute, 600},
            {60 * time.Minute, 800},
            {70 * time.Minute, 1000},
            {80 * time.Minute, 2000},
            {90 * time.Minute, 4000},
            {100 * time.Minute, 8000},
        }

        for i, c := range cases {
            t.Run(fmt.Sprintf("%d scheduled for %v", c.expectedAmount, c.expectedScheduleTime), func(t *testing.T) {

                if len(blindAlerter.alerts) <= i {
                    t.Fatalf("alert %d was not scheduled %v", i, blindAlerter.alerts)
                }

                alert := blindAlerter.alerts[i]

                amountGot := alert.amount
                if amountGot != c.expectedAmount {
                    t.Errorf("got amount %d, want %d", amountGot, c.expectedAmount)
                }

                gotScheduledTime := alert.scheduledAt
                if gotScheduledTime != c.expectedScheduleTime {
                    t.Errorf("got scheduled time of %v, want %v", gotScheduledTime, c.expectedScheduleTime)
                }
            })
        }
    })

基于表的测试在这里工作得很好,并且清楚地说明了我们的需求是什么。我们遍历表并检查 SpyBlindAlerter,以查看提醒是否已使用正确的值调度。

尝试运行测试

你应该会看到下面的报错信息

=== RUN   TestCLI
--- FAIL: TestCLI (0.00s)
=== RUN   TestCLI/it_schedules_printing_of_blind_values
    --- FAIL: TestCLI/it_schedules_printing_of_blind_values (0.00s)
=== RUN   TestCLI/it_schedules_printing_of_blind_values/100_scheduled_for_0s
        --- FAIL: TestCLI/it_schedules_printing_of_blind_values/100_scheduled_for_0s (0.00s)
            CLI_test.go:71: got scheduled time of 5s, want 0s
=== RUN   TestCLI/it_schedules_printing_of_blind_values/200_scheduled_for_10m0s
        --- FAIL: TestCLI/it_schedules_printing_of_blind_values/200_scheduled_for_10m0s (0.00s)
            CLI_test.go:59: alert 1 was not scheduled [{5000000000 100}]

编写足够的代码让测试通过

func (cli *CLI) PlayPoker() {

    blinds := []int{100, 200, 300, 400, 500, 600, 800, 1000, 2000, 4000, 8000}
    blindTime := 0 * time.Second
    for _, blind := range blinds {
        cli.alerter.ScheduleAlertAt(blindTime, blind)
        blindTime = blindTime + 10 * time.Minute
    }

    userInput := cli.readLine()
    cli.playerStore.RecordWin(extractWinner(userInput))
}

它并不比我们已有的复杂多少。我们现在只是在一个 blinds 数组迭代并在不断增加的 blindTime 时间中调用调度程序。

重构

我们可以将我们调度的警报封装到一个方法中,使 PlayPoker 读起来更清晰一些。

func (cli *CLI) PlayPoker() {
    cli.scheduleBlindAlerts()
    userInput := cli.readLine()
    cli.playerStore.RecordWin(extractWinner(userInput))
}

func (cli *CLI) scheduleBlindAlerts() {
    blinds := []int{100, 200, 300, 400, 500, 600, 800, 1000, 2000, 4000, 8000}
    blindTime := 0 * time.Second
    for _, blind := range blinds {
        cli.alerter.ScheduleAlertAt(blindTime, blind)
        blindTime = blindTime + 10*time.Minute
    }
}

最后我们的测试看起来有点笨拙。我们有两个匿名结构表示相同的东西,一个是 ScheduledAlertWe。让我们将其重构为一个新类型,然后让一些助手来比较它们。

type scheduledAlert struct {
    at time.Duration
    amount int
}

func (s scheduledAlert) String() string {
    return fmt.Sprintf("%d chips at %v", s.amount, s.at)
}

type SpyBlindAlerter struct {
    alerts []scheduledAlert
}

func (s *SpyBlindAlerter) ScheduleAlertAt(at time.Duration, amount int) {
    s.alerts = append(s.alerts, scheduledAlert{at, amount})
}

我们在类型中添加了一个 String() 方法,因此如果测试失败,它会很好地打印出来

更新测试来使用我们的新类型

t.Run("it schedules printing of blind values", func(t *testing.T) {
    in := strings.NewReader("Chris wins\n")
    playerStore := &poker.StubPlayerStore{}
    blindAlerter := &SpyBlindAlerter{}

    cli := poker.NewCLI(playerStore, in, blindAlerter)
    cli.PlayPoker()

    cases := []scheduledAlert {
        {0 * time.Second, 100},
        {10 * time.Minute, 200},
        {20 * time.Minute, 300},
        {30 * time.Minute, 400},
        {40 * time.Minute, 500},
        {50 * time.Minute, 600},
        {60 * time.Minute, 800},
        {70 * time.Minute, 1000},
        {80 * time.Minute, 2000},
        {90 * time.Minute, 4000},
        {100 * time.Minute, 8000},
    }

    for i, want := range cases {
        t.Run(fmt.Sprint(want), func(t *testing.T) {

            if len(blindAlerter.alerts) <= i {
                t.Fatalf("alert %d was not scheduled %v", i, blindAlerter.alerts)
            }

            got := blindAlerter.alerts[i]
            assertScheduledAlert(t, got, want)
        })
    }
})

实现 assertScheduledAlert 自己。

我们已经花费了相当多的时间来编写测试,并且没有与我们的应用程序集成,这有点不太好。在我们提出更多的要求之前,让我们先解决这个问题。

尝试运行应用程序,它不会编译,抱怨没有足够的参数传到 NewCLI

让我们创建一个可以在应用程序中使用的 BlindAlerter 实现。

创建 BlindAlerter.go 文件并移动 BlindAlerter 接口,并在下面添加新内容

package poker

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

type BlindAlerter interface {
    ScheduleAlertAt(duration time.Duration, amount int)
}

type BlindAlerterFunc func(duration time.Duration, amount int)

func (a BlindAlerterFunc) ScheduleAlertAt(duration time.Duration, amount int) {
    a(duration, amount)
}

func StdOutAlerter(duration time.Duration, amount int) {
    time.AfterFunc(duration, func() {
        fmt.Fprintf(os.Stdout, "Blind is now %d\n", amount)
    })
}

请记住,任何 type 都可以实现接口,而不仅仅是 structs。如果您正在创建一个库来公开一个定义了一个函数的接口,那么通常习惯做法是同时公开一个 MyInterfaceFunc 类型。

这个类型将是一个 func,它也将实现你的接口。 这样,你的用户可以选择用一个函数来实现你的接口;而不是必须创建一个空的 struct 类型。

然后我们创建函数 StdOutAlerter,它具有与函数相同的签名,并只使用 time.AfterFunc。调度它打印到 os.Stdout

更新 main

poker.NewCLI(store, os.Stdin, poker.BlindAlerterFunc(poker.StdOutAlerter)).PlayPoker()

在执行之前,你可能想要改变 CLI 中的 blindTime 增量为 10 秒,而不是 10 分钟,这样你就可以看到它的实际效果。

您应该看到它每 10 秒打印一次盲注值。请注意,您仍然可以在 CLI 中输入Shaun wins,它将按我们期望的方式停止程序。

游戏不会总是有 5 个人玩,所以我们需要在游戏开始前提示用户输入一些玩家。

首先写测试

我们正在提示要记录写入标准输出的玩家数量。我们已经做过几次了,我们知道。os.Stdout 是一个 io.Writer。因此,如果使用依赖项注入传入一个 bytes.Buffer,我们可以检查写入了什么。在我们的测试中,看看我们的代码会写什么。

在这个测试中,我们还不关心其他合作者,所以我们在测试文件中做了一些假人。

我们应该小心一点,我们现在有 4 个 CLI 依赖项,感觉它可能开始有太多的责任。现在让我们来看看在添加这个新功能时是否可以重构。

var dummyBlindAlerter = &SpyBlindAlerter{}
var dummyPlayerStore = &poker.StubPlayerStore{}
var dummyStdIn = &bytes.Buffer{}
var dummyStdOut = &bytes.Buffer{}

这是我们新的测试

t.Run("it prompts the user to enter the number of players", func(t *testing.T) {
    stdout := &bytes.Buffer{}
    cli := poker.NewCLI(dummyPlayerStore, dummyStdIn, stdout, dummyBlindAlerter)
    cli.PlayPoker()

    got := stdout.String()
    want := "Please enter the number of players: "

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

尝试运行测试

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

编写最少量的代码让测试跑起来并查看失败信息

我们有一个新的依赖项所以我们必须更新 NewCLI

func  NewCLI(store PlayerStore, in io.Reader, out io.Writer, alerter BlindAlerter)  *CLI

现在 其他 测试将无法编译,因为它们没有 io.Writer 写入到 NewCLI

在其他测试中添加 dummyStdout

新的测试应该会像下面一样失败

=== RUN   TestCLI
--- FAIL: TestCLI (0.00s)
=== RUN   TestCLI/it_prompts_the_user_to_enter_the_number_of_players
    --- FAIL: TestCLI/it_prompts_the_user_to_enter_the_number_of_players (0.00s)
        CLI_test.go:46: got '', want 'Please enter the number of players: '
FAIL

编写足够的代码让测试通过

我们需要将新的依赖项添加到 CLI 中这样我们就可以在 PlayPoker 中引用它

type CLI struct {
    playerStore PlayerStore
    in          *bufio.Reader
    out         io.Writer
    alerter     BlindAlerter
}

func NewCLI(store PlayerStore, in io.Reader, out io.Writer, alerter BlindAlerter) *CLI {
    return &CLI{
        playerStore: store,
        in:          bufio.NewReader(in),
        out:         out,
        alerter:     alerter,
    }
}

最后,我们可以在游戏开始时编写提示

func (cli *CLI) PlayPoker() {
    fmt.Fprint(cli.out, "Please enter the number of players: ")
    cli.scheduleBlindAlerts()
    userInput := cli.readLine()
    cli.playerStore.RecordWin(extractWinner(userInput))
}

重构

我们有一个重复的字符串作为提示符,我们应该将其提取到一个常量中

const PlayerPrompt =  "Please enter the number of players: "

在测试代码和 CLI 中使用它。

现在我们需要输入一个数字并提取出来。我们知道它是否达到预期效果的唯一方法是查看安排了哪些盲注通知。

首先写测试

t.Run("it prompts the user to enter the number of players", func(t *testing.T) {
    stdout := &bytes.Buffer{}
    in := strings.NewReader("7\n")
    blindAlerter := &SpyBlindAlerter{}

    cli := poker.NewCLI(dummyPlayerStore, in, stdout, blindAlerter)
    cli.PlayPoker()

    got := stdout.String()
    want := poker.PlayerPrompt

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

    cases := []scheduledAlert{
        {0 * time.Second, 100},
        {12 * time.Minute, 200},
        {24 * time.Minute, 300},
        {36 * time.Minute, 400},
    }

    for i, want := range cases {
        t.Run(fmt.Sprint(want), func(t *testing.T) {

            if len(blindAlerter.alerts) <= i {
                t.Fatalf("alert %d was not scheduled %v", i, blindAlerter.alerts)
            }

            got := blindAlerter.alerts[i]
            assertScheduledAlert(t, got, want)
        })
    }
})

哎呦!有一些变化

  • 我们删除了 StdIn 的虚拟,取而代之的是发送一个模拟版本,代表我们的用户输入 7

  • 我们也移走了盲注上的假人,这样我们就可以看到玩家的数量对调度产生的影响

  • 我们测试调度了哪些通知

尝试运行测试

测试仍然应该编译和失败,报告调度的时间是错误的,因为我们硬编码的游戏是基于 5 个玩家

=== RUN   TestCLI
--- FAIL: TestCLI (0.00s)
=== RUN   TestCLI/it_prompts_the_user_to_enter_the_number_of_players
    --- FAIL: TestCLI/it_prompts_the_user_to_enter_the_number_of_players (0.00s)
=== RUN   TestCLI/it_prompts_the_user_to_enter_the_number_of_players/100_chips_at_0s
        --- PASS: TestCLI/it_prompts_the_user_to_enter_the_number_of_players/100_chips_at_0s (0.00s)
=== RUN   TestCLI/it_prompts_the_user_to_enter_the_number_of_players/200_chips_at_12m0s

编写足够的代码让测试通过

记住,我们可以自由地犯任何我们需要犯的错。一旦我们有了可以工作的软件,我们就可以重构我们将要制造的混乱!

func (cli *CLI) PlayPoker() {
    fmt.Fprint(cli.out, PlayerPrompt)

    numberOfPlayers, _ := strconv.Atoi(cli.readLine())

    cli.scheduleBlindAlerts(numberOfPlayers)

    userInput := cli.readLine()
    cli.playerStore.RecordWin(extractWinner(userInput))
}

func (cli *CLI) scheduleBlindAlerts(numberOfPlayers int) {
    blindIncrement := time.Duration(5 + numberOfPlayers) * time.Minute

    blinds := []int{100, 200, 300, 400, 500, 600, 800, 1000, 2000, 4000, 8000}
    blindTime := 0 * time.Second
    for _, blind := range blinds {
        cli.alerter.ScheduleAlertAt(blindTime, blind)
        blindTime = blindTime + blindIncrement
    }
}
  • 我们把 numberOfPlayersInput 读入字符串

  • 我们使用 clip . readline() 从用户获取输入,然后调用 Atoi 将其转换成整数—忽略任何错误场景。稍后我们需要为该场景编写一个测试。

  • 从这里我们改变 scheduleBlindAlerts 以接受一些玩家。然后,我们计算一个 blindIncrement 时间,用它来增加盲注的筹码

虽然我们的新测试已经修复,但其他很多测试都失败了,因为现在我们的系统只能在游戏开始时用户输入一个数字时才能工作。您需要通过更改用户输入来修复测试,以便添加一个数字和一个换行符(这将突出显示我们的方法中更多的缺陷)。

重构

这感觉有点可怕,对吧?让我们听听我们的测试

  • 为了测试我们正在调度的一些通知,我们设置了 4 个不同的依赖项。当您的系统中有很多依赖项时,就意味着它做得太多了。从视觉上我们可以看出我们的测试有多混乱。

  • 在我看来,我们需要在读取用户输入和我们想要执行的业务逻辑之间进行更清晰的抽象

  • 更好的测试是 给这个用户输入,然后我们调用新类型 Game 伴随着正确的玩家数量

  • 然后,我们将调度的测试提取到我们的新 Game 的测试中。

我们可以先重构我们的 Game,然后我们的测试应该继续通过。一旦我们进行了我们想要的结构更改,我们就可以考虑如何重构测试来反映我们新的关注点分离。

记住,在重构中进行更改时,尽量使更改尽可能小,并保持重新运行测试。

你自己先试试。想想 Game 能提供什么,我们的 CLI 应该做什么。

现在不要改变 NewCLI 的外部接口,因为我们不想同时改变测试代码和客户端代码,因为这太复杂了,我们可能会破坏一些东西。

这是我想到的:

// game.go
type Game struct {
    alerter BlindAlerter
    store   PlayerStore
}

func (p *Game) Start(numberOfPlayers int) {
    blindIncrement := time.Duration(5+numberOfPlayers) * time.Minute

    blinds := []int{100, 200, 300, 400, 500, 600, 800, 1000, 2000, 4000, 8000}
    blindTime := 0 * time.Second
    for _, blind := range blinds {
        p.alerter.ScheduleAlertAt(blindTime, blind)
        blindTime = blindTime + blindIncrement
    }
}

func (p *Game) Finish(winner string) {
    p.store.RecordWin(winner)
}

// cli.go
type CLI struct {
    in          *bufio.Reader
    out         io.Writer
    game        *Game
}

func NewCLI(store PlayerStore, in io.Reader, out io.Writer, alerter BlindAlerter) *CLI {
    return &CLI{
        in:  bufio.NewReader(in),
        out: out,
        game: &Game{
            alerter: alerter,
            store:   store,
        },
    }
}

const PlayerPrompt = "Please enter the number of players: "

func (cli *CLI) PlayPoker() {
    fmt.Fprint(cli.out, PlayerPrompt)

    numberOfPlayersInput := cli.readLine()
    numberOfPlayers, _ := strconv.Atoi(strings.Trim(numberOfPlayersInput, "\n"))

    cli.game.Start(numberOfPlayers)

    winnerInput := cli.readLine()
    winner := extractWinner(winnerInput)

    cli.game.Finish(winner)
}

func extractWinner(userInput string) string {
    return strings.Replace(userInput, " wins\n", "", 1)
}

func (cli *CLI) readLine() string {
    cli.in.Scan()
    return cli.in.Text()
}

从「领域」的角度来看:

  • 我们想要 Start 一个 Game,表明有多少人在玩

  • 我们想要 Finish 一个 Game,宣布获胜者

新的 Game 类型为我们封装了这一点。

有了这个改变,我们把 BlindAlerterPlayerStore 改为 Game,因为它现在负责通知和存储结果。

我们的 CLI 现在只关心:

  • 用它现有的依赖项来构建 Game (我们接下来将重构它)

  • 将用户输入解释为 Game 的方法调用

我们希望尽量避免进行「大型」重构,这将使我们在很长一段时间内处于测试失败的状态,因为这会增加出错的几率。(如果你在一个大型/分布式团队中工作,这是非常重要的)

我们要做的第一件事是重构 Game 以便将它注入到 CLI 中。我们将在我们的测试中进行最小的更改来促进这一点,然后我们将看到如何将测试分解为解析用户输入和游戏管理的主题。

我们现在需要做的就是改变 NewCLI

func NewCLI(in io.Reader, out io.Writer, game *Game) *CLI {
    return &CLI{
        in:  bufio.NewReader(in),
        out: out,
        game: game,
    }
}

感觉已经有进步了。我们有更少的依赖项,我们的依赖项列表反映了我们的总体设计目标。CLI 关注输入/输出并将游戏特定的操作委托给 Game

如果尝试编译,就会出现问题。你应该能够自己解决这些问题。现在不要担心做任何模拟的 Game,只是初始化真实Game 只是让一切编译和测试通过。

为此,你需要创建一个构造函数

func NewGame(alerter BlindAlerter, store PlayerStore) *Game {
    return &Game{
        alerter:alerter,
        store:store,
    }
}

下面是一个正在修复的测试设置示例

stdout := &bytes.Buffer{}
in := strings.NewReader("7\n")
blindAlerter := &SpyBlindAlerter{}
game := poker.NewGame(blindAlerter, dummyPlayerStore)

cli := poker.NewCLI(in, stdout, game)
cli.PlayPoker()

修复测试并再次回到绿色(这是关键!)应该不需要花费太多的精力,但是要确保修复了 main.go

// main.go
game := poker.NewGame(poker.BlindAlerterFunc(poker.StdOutAlerter), store)
cli := poker.NewCLI(os.Stdin, os.Stdout, game)
cli.PlayPoker()

现在我们已经提取了 Game,我们应该将游戏特定的断言移到独立于 CLI 的测试中。

这只是一个复制我们的 CLI 测试的练习,但是有较少的依赖性

func TestGame_Start(t *testing.T) {
    t.Run("schedules alerts on game start for 5 players", func(t *testing.T) {
        blindAlerter := &poker.SpyBlindAlerter{}
        game := poker.NewGame(blindAlerter, dummyPlayerStore)

        game.Start(5)

        cases := []poker.ScheduledAlert{
            {At: 0 * time.Second, Amount: 100},
            {At: 10 * time.Minute, Amount: 200},
            {At: 20 * time.Minute, Amount: 300},
            {At: 30 * time.Minute, Amount: 400},
            {At: 40 * time.Minute, Amount: 500},
            {At: 50 * time.Minute, Amount: 600},
            {At: 60 * time.Minute, Amount: 800},
            {At: 70 * time.Minute, Amount: 1000},
            {At: 80 * time.Minute, Amount: 2000},
            {At: 90 * time.Minute, Amount: 4000},
            {At: 100 * time.Minute, Amount: 8000},
        }

        checkSchedulingCases(cases, t, blindAlerter)
    })

    t.Run("schedules alerts on game start for 7 players", func(t *testing.T) {
        blindAlerter := &poker.SpyBlindAlerter{}
        game := poker.NewGame(blindAlerter, dummyPlayerStore)

        game.Start(7)

        cases := []poker.ScheduledAlert{
            {At: 0 * time.Second, Amount: 100},
            {At: 12 * time.Minute, Amount: 200},
            {At: 24 * time.Minute, Amount: 300},
            {At: 36 * time.Minute, Amount: 400},
        }

        checkSchedulingCases(cases, t, blindAlerter)
    })

}

func TestGame_Finish(t *testing.T) {
    store := &poker.StubPlayerStore{}
    game := poker.NewGame(dummyBlindAlerter, store)
    winner := "Ruth"

    game.Finish(winner)
    poker.AssertPlayerWin(t, store, winner)
}

当一场扑克游戏开始时,其背后的意图现在清楚多了。

在游戏结束的时候,一定要跳过测试。

一旦我们对游戏逻辑的测试感到满意,我们就可以简化我们的CLI测试,这样它们就可以更清楚地反映我们的预期职责

  • 处理用户输入,并在适当的时候调用 Game 的方法

  • 发送输出

  • 重要的是,它并不知道游戏是如何运作的

为了做到这一点,我们必须让 CLI 不再依赖于具体的 Game 类型,而是接受一个带有 Start(numberOfPlayers)Finish(winner) 的接口。然后,我们可以创建该类型的侦查,并验证调用是否正确。

在这里,我们意识到有时命名很尴尬。将 Game 重命名为 TexasHoldem (因为这是我们正在玩的 类型 游戏),新界面将命名为 Game。始终坚持这样的观念: CLI 对我们正在进行的实际游戏以及当您 StartFinish 时会发生什么毫不在意。

type Game interface {
    Start(numberOfPlayers int)
    Finish(winner string)
}

将 CLI 中的所有 *Game 引用替换为 Game (我们的新接口)。当我们进行重构时,总是保持重新运行测试以检查一切都是绿色的。

现在我们已经将 CLITexasHoldem 中解耦出来了,我们可以使用 spy 来检查 StartFinish 是否在我们期望它们调用的时候被调用,并带有正确的参数。

创建一个实现 Game 的 spy

type GameSpy struct {
    StartedWith  int
    FinishedWith string
}

func (g *GameSpy) Start(numberOfPlayers int) {
    g.StartedWith = numberOfPlayers
}

func (g *GameSpy) Finish(winner string) {
    g.FinishedWith = winner
}

将任何测试游戏特定逻辑的 CLI 测试替换为检查我们如何调用 GameSpy。这将在我们的测试中清楚地反映出 CLI 的职责。

下面是一个正在修复的测试示例; 尝试自己完成剩下的工作,如果遇到问题,请检查源代码。

    t.Run("it prompts the user to enter the number of players and starts the game", func(t *testing.T) {
        stdout := &bytes.Buffer{}
        in := strings.NewReader("7\n")
        game := &GameSpy{}

        cli := poker.NewCLI(in, stdout, game)
        cli.PlayPoker()

        gotPrompt := stdout.String()
        wantPrompt := poker.PlayerPrompt

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

        if game.StartedWith != 7 {
            t.Errorf("wanted Start called with 7 but got %d", game.StartedWith)
        }
    })

既然我们已经清楚地分离了关注点,那么在 CLI 中检查 IO 的边界情况应该会更容易。

我们需要解决的情况,用户把一个非数字值时,提示球员的数量:

我们的代码不应该启动游戏,它应该打印一个方便的错误给用户,然后退出。

首先编写测试

我们首先确保游戏没有开始

t.Run("it prints an error when a non numeric value is entered and does not start the game", func(t *testing.T) {
        stdout := &bytes.Buffer{}
        in := strings.NewReader("Pies\n")
        game := &GameSpy{}

        cli := poker.NewCLI(in, stdout, game)
        cli.PlayPoker()

        if game.StartCalled {
            t.Errorf("game should not have started")
        }
    })

你需要添加一个名为 StartCalled 的字段到我们的 GameSpy 中,这个字段只有在 Start 被调用时才会被设置

尝试运行测试

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

编写足够的代码让测试通过

在我们调用 Atoi 的地方,我们只需要检查错误

numberOfPlayers, err := strconv.Atoi(cli.readLine())

if err != nil {
    return
}

接下来,我们需要告知用户他们做错了什么,这样我们就可以断言哪些内容被打印为 stdout

首先编写测试

我们之前已经声明了输出到 stdout 的内容,所以现在我们可以复制代码

gotPrompt := stdout.String()

wantPrompt := poker.PlayerPrompt + "you're so silly"

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

我们将写入 stdout 的所有内容都存储起来,因此我们仍然期望 poker.PlayerPrompt。然后我们只需要检查另外打印的东西。我们现在不太关心确切的措辞,我们将在重构时解决它。

尝试运行测试

=== RUN   TestCLI/it_prints_an_error_when_a_non_numeric_value_is_entered_and_does_not_start_the_game
    --- FAIL: TestCLI/it_prints_an_error_when_a_non_numeric_value_is_entered_and_does_not_start_the_game (0.00s)
        CLI_test.go:70: got 'Please enter the number of players: ', want 'Please enter the number of players: you're so silly'

编写足够的代码让测试通过

更改错误处理代码

if err != nil {
    fmt.Fprint(cli.out, "you're so silly")
    return
}

重构

现在将消息重构为一个常量,如 PlayerPrompt

wantPrompt := poker.PlayerPrompt + poker.BadPlayerInputErrMsg

然后输入更合适的信息

const BadPlayerInputErrMsg =  "Bad value received for number of players, please try again with a number"

最后,我们对发送给 stdout 的内容的测试非常冗长,让我们编写一个 assert 函数来清理它。

func assertMessagesSentToUser(t *testing.T, stdout *bytes.Buffer, messages ...string) {
    t.Helper()
    want := strings.Join(messages, "")
    got := stdout.String()
    if got != want {
        t.Errorf("got %q sent to stdout but expected %+v", got, messages)
    }
}

使用 vararg 语法(…string)在这里很方便,因为我们需要断言不同数量的消息。

在我们对发送给用户的消息进行断言的两个测试中使用这个助手。

assertX 函数可以帮助一些测试,所以通过清理我们的测试来练习您的重构,使它们读起来很好。

花点时间,想想我们排除的一些测试的价值。请记住,我们不想要更多不必要的测试,您可以重构/删除其中的一些测试 并且仍然相信它可以正常工作

这是我想到的

func TestCLI(t *testing.T) {

    t.Run("start game with 3 players and finish game with 'Chris' as winner", func(t *testing.T) {
        game := &GameSpy{}
        stdout := &bytes.Buffer{}

        in := userSends("3", "Chris wins")
        cli := poker.NewCLI(in, stdout, game)

        cli.PlayPoker()

        assertMessagesSentToUser(t, stdout, poker.PlayerPrompt)
        assertGameStartedWith(t, game, 3)
        assertFinishCalledWith(t, game, "Chris")
    })

    t.Run("start game with 8 players and record 'Cleo' as winner", func(t *testing.T) {
        game := &GameSpy{}

        in := userSends("8", "Cleo wins")
        cli := poker.NewCLI(in, dummyStdOut, game)

        cli.PlayPoker()

        assertGameStartedWith(t, game, 8)
        assertFinishCalledWith(t, game, "Cleo")
    })

    t.Run("it prints an error when a non numeric value is entered and does not start the game", func(t *testing.T) {
        game := &GameSpy{}

        stdout := &bytes.Buffer{}
        in := userSends("pies")

        cli := poker.NewCLI(in, stdout, game)
        cli.PlayPoker()

        assertGameNotStarted(t, game)
        assertMessagesSentToUser(t, stdout, poker.PlayerPrompt, poker.BadPlayerInputErrMsg)
    })
}

现在的测试反映了 CLI 的主要功能,它能够读取用户输入的信息,包括有多少人在玩游戏,当玩家输入了错误的值时,谁赢了,谁处理了错误。通过这样做,读者可以清楚地知道 CLI 做了什么,但也知道它没有做什么。

如果用户输入 Lloyd is a killer 而不是 Ruth wins呢?

通过为这个场景编写测试并使其通过来结束本章。

结束

快速项目概述

在过去的 5 章中,我们已经慢慢地测试了大量的代码

  • 我们有两个应用程序,一个命令行应用程序和一个 web 服务器。

  • 这两款应用程序都依赖 PlayerStore 来记录获胜者

  • web 服务器还可以显示谁赢得最多比赛的名次表

  • 命令行应用程序通过跟踪当前的盲注来帮助玩家玩扑克游戏。

time.Afterfunc

这是一种非常方便的方法,可以在特定的持续时间之后调度函数调用。值得花时间 查看 time 文档,因为它有很多节省时间的函数和方法供您使用。

我喜欢的一些有

  • Time . after (duration) 返回时间已过期的 chan Time。所以如果你想在某个特定的时间之后做某事,这个可以帮助你。

  • time.NewTicker(duration) 返回一个 Ticker,它与上面类似,因为它返回一个通道,但是这个通道在持续时间能不停的「ticks」,而不是只有一次。

更多的关注分离的例子

通常,将处理用户输入和响应的职责从域代码中分离出来是一个很好的实践。您可以在命令行应用程序和 web 服务器中看到这一点。

我们的测试变得一团糟。我们有太多的断言(检查这个输入,调度这些通知,等等)和太多的依赖。我们可以直观的看到代码是混乱的;所以倾听测试是重要的

  • 如果您的测试看起来很混乱,请尝试重构它们。

  • 如果你这样做了,他们还是一团糟,这很可能是由于你的设计有缺陷。

  • 这是测试的真正优势之一。

即使测试和产品代码有点混乱,我们也可以自由地重构测试支持的代码。

记住,当您遇到这些情况时,总是要采取一些小步骤,并在每次更改之后重新运行测试。

同时重构测试代码 生产代码是很危险的,所以我们首先重构了生产代码(在当前状态下,我们不能对测试进行太多的改进),而不改变它的接口,这样我们就可以在改变东西的同时尽可能多地依赖我们的测试。然后 我们在设计改进后重构测试。

重构之后,依赖项列表反映了我们的设计目标。这是依赖注入的另一个好处,因为它经常记录代码的目的。当您依赖全局变量时,职责将变得非常不清晰。

实现接口的函数示例

当你用一个方法定义一个接口时,你可能想要考虑定义一个 MyInterfaceFunc 类型来补充它,这样用户就可以用一个函数来实现你的接口

type BlindAlerter interface {
    ScheduleAlertAt(duration time.Duration, amount int)
}

// BlindAlerterFunc 允许你使用一个函数来实现 BlindAlerter
type BlindAlerterFunc func(duration time.Duration, amount int)

// ScheduleAlertAt 是 BlindAlerterFunc 的实现
func (a BlindAlerterFunc) ScheduleAlertAt(duration time.Duration, amount int) {
    a(duration, amount)
}

原文地址 Learn Go with Tests

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 查看所有版本


暂无话题~