开始新游戏

在这一部分,我们将通过编写游戏代码来开始。像每个项目一样,开始通常相当困难,第一个决定就是从哪里开始。一个合理的方法是找到一个用户故事,这将开始充实代码的结构。一旦我们为应用程序建立了一个合理的结构,弄清楚新代码应该添加到哪里就会变得容易得多。

鉴于此,我们可以通过考虑开始新游戏时需要发生的事情来做一个好的开始。这必须设置好准备玩的内容,因此将迫使做出一些关键决策。

第一个要处理的用户故事是开始新游戏:

  • 作为一个玩家,我想开始一个新游戏,以便我有一个新单词可以猜

当我们开始一个新游戏时,我们必须做以下事情:

  1. 从可用的单词中随机选择一个单词来猜

  2. 存储选定的单词,以便可以计算猜测的分数

  3. 记录玩家现在可以进行初始猜测

在编写这个故事时,我们将假设使用六边形架构,这意味着任何外部系统都将由领域模型中的一个端口表示。考虑到这一点,我们可以创建我们的第一个测试并从中开始。

测试驱动开始新游戏

在总体方向上,使用六边形架构意味着我们可以自由地使用自外而内的 TDD 方法。无论我们为领域模型设计出什么,都不会涉及难以测试的外部系统。我们的单元测试保证是 FIRST 的——快速、独立、可重复、自检和及时。

重要的是,我们可以编写覆盖用户故事所需全部逻辑的单元测试。如果我们编写的代码绑定到外部系统——例如,它包含 SQL 语句并连接到数据库——我们将需要一个集成测试来覆盖用户故事。我们选择的六边形架构使我们免于这种情况。

在战术上,我们将重用我们已经测试驱动的类,例如 WordSelection 类、Word 类和 Score 类。我们将尽可能重用现有代码和第三方库。

我们的起点是编写一个测试来捕获与开始新游戏相关的设计决策:

  1. 我们将从一个名为 NewGameTest 的测试开始。这个测试将跨越领域模型,驱动我们处理开始新游戏所需的一切:

    package com.wordz.domain;
    
    public class NewGameTest {
    }
  2. 对于这个测试,我们将首先从 Act 步骤开始。我们假设使用六边形架构,因此 Act 步骤的设计目标是设计处理开始新游戏请求的端口。在六边形架构中,端口是允许某些外部系统与领域模型连接的代码片段。我们首先为我们的端口创建一个类:

    package com.wordz.domain;
    public class NewGameTest {
        void startsNewGame() {
            var game = new Game();
        }
    }

    这里的关键设计决策是创建一个控制器类来处理开始游戏的请求。它是原始《设计模式》书中意义上的控制器——一个将协调其他领域模型对象的领域模型对象。我们将让 IntelliJ IDE 创建空的 Game 类:

    package com.wordz.domain;
    public class Game {
    }

    这是 TDD 的另一个优势。当我们先编写测试时,我们为 IDE 提供了足够的信息,使其能够为我们生成样板代码。我们启用 IDE 的自动完成功能来真正帮助我们。如果你的 IDE 在编写测试后无法自动生成代码,请考虑升级你的 IDE

  3. 下一步是在控制器类上添加一个 start() 方法来开始新游戏。我们需要知道我们为哪个玩家开始游戏,因此我们传入一个 Player 对象。我们编写测试的 Act 步骤:

    public class NewGameTest {
        @Test
        void startsNewGame() {
            var game = new Game();
            var player = new Player();
            game.start(player);
        }
    }

    我们允许 IDE 在控制器中生成方法:

    public class Game {
        public void start(Player player) {
        }
    }

跟踪游戏进展

接下来的设计决策涉及为玩家开始新游戏的预期结果。有两件事需要记录:

  • 玩家尝试猜测的选定单词

  • 我们期望他们的第一次猜测

选定的单词和当前尝试次数需要持久化存储。我们将使用仓库模式来抽象这一点。我们的仓库需要管理一些领域对象。这些对象的唯一职责是跟踪我们在游戏中的进度。

我们已经看到了 TDD 在快速设计反馈方面的好处。我们还没有编写太多代码,但似乎新的类需要跟踪游戏进度,最好称为 Game 类。然而,我们已经有一个 Game 类,负责开始新游戏。TDD 正在提供关于我们设计的反馈——我们的名称和职责不匹配。

我们必须选择以下选项之一来继续:

  • 保持现有的 Game 类不变。将这个新类称为 ProgressAttempt

  • start() 方法更改为静态方法——适用于类所有实例的方法。

  • Game 类重命名为更好地描述其职责的名称。然后,我们可以创建一个新的 Game 类来保存当前玩家的进度。

静态方法选项并不吸引人。在 Java 中使用面向对象编程时,静态方法似乎不如简单地创建另一个管理所有相关实例的对象那样合适。静态方法成为这个新对象上的普通方法。使用 Game 类来表示游戏进度似乎会导致更具描述性的代码。让我们采用这种方法。

  1. 使用 IntelliJ IDEA IDE 将 Game 类重命名为 Wordz,它代表我们领域模型的入口点。我们还将局部变量 game 重命名以匹配:

    public class NewGameTest {
        @Test
        void startsNewGame() {
            var wordz = new Wordz();
            var player = new Player();
    
            wordz.start(player);
        }
    }

    NewGameTest 测试的名称仍然很好。它代表我们正在测试的用户故事,与任何类名称无关。生产代码也被 IDE 重构:

    public class Wordz {
        public void start(Player player) {
        }
    }
  2. 使用 IDEstart() 方法重命名为 newGame()。在名为 Wordz 的类的上下文中,这似乎更好地描述了方法的职责:

    public class NewGameTest {
        @Test
        void startsNewGame() {
            var wordz = new Wordz();
            var player = new Player();
            wordz.newGame(player);
        }
    }

    Wordz 类的生产代码也有方法重命名。

  3. 当我们开始一个新游戏时,我们需要选择一个单词来猜测并开始玩家的尝试序列。这些事实需要存储在仓库中。让我们首先创建仓库。我们将其称为 GameRepository 接口,并在测试中添加 Mockito 的 @Mock 支持:

    package com.wordz.domain;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.Mock;
    import org.mockito.junit.jupiter.MockitoExtension;
    @ExtendWith(MockitoExtension.class)
    public class NewGameTest {
        @Mock
        private GameRepository gameRepository;
        @InjectMocks
        private Wordz wordz;
        @Test
        void startsNewGame() {
            var player = new Player();
            wordz.newGame(player);
        }
    }

    我们向类添加 @ExtendWith 注解,以启用 Mockito 库自动为我们创建测试替身。我们添加一个 gameRepository 字段,并用 @Mock 注解标记。我们使用 Mockito 内置的 @InjectMocks 便利注解自动将此依赖注入 Wordz 构造函数。

  4. 我们允许 IDE 为我们创建一个空接口:

    package com.wordz.domain;
    public interface GameRepository {
    }
  5. 下一步,我们将确认 gameRepository 被使用。我们决定在接口上添加一个 create() 方法,该方法将 Game 类对象实例作为其唯一参数。我们想要检查 Game 类的对象实例,因此我们添加一个参数捕获器。这允许我们对该对象中包含的游戏数据进行断言:

    public class NewGameTest {
        @Mock
        private GameRepository gameRepository;
        @Test
        void startsNewGame() {
            var player = new Player();
            wordz.newGame(player);
            var gameArgument =
                ArgumentCaptor.forClass(Game.class)
            verify(gameRepository)
                .create(gameArgument.capture());
            var game = gameArgument.getValue();
            assertThat(game.getWord()).isEqualTo("ARISE");
            assertThat(game.getAttemptNumber()).isZero();
            assertThat(game.getPlayer()).isSameAs(player);
        }
    }

    一个很好的问题是为什么我们要断言这些特定值。原因是我们在添加生产代码时会作弊,先硬编码这些值作为第一步。然后我们可以逐步改进。一旦作弊版本使测试通过,我们可以改进测试并测试驱动代码以实际获取单词。较小的步骤提供更快的反馈。快速反馈使决策更好。

    关于在领域模型中使用 getter 的说明

    Game 类有 getXxx() 方法,在 Java 术语中称为 getter,用于其每个私有字段。这些方法破坏了数据的封装。

    通常不推荐这样做。它可能导致重要逻辑被放置到其他类中——一种称为 外来方法 的代码异味。面向对象编程的核心是将逻辑和数据放在一起,封装两者。Getter 应该很少使用。但这并不意味着我们永远不应该使用它们。

    在这种情况下,Game 类的唯一职责是将正在进行的游戏的当前状态传输到 GameRepository。实现这一点的最直接方法是向类添加 getter。编写简单、清晰的代码比教条地遵循规则更重要。

    另一种合理的方法是添加一个包级可见性的 getXxx() 诊断方法,仅用于测试。确保这不是公共 API 的一部分,并且不在生产代码中使用它。使代码正确比纠结于设计细节更重要。

  6. 我们使用 IDE 为这些新的 getter 创建空方法。下一步是运行 NewGameTest 并确认它失败:

image 2025 01 12 18 59 45 525
Figure 1. Figure 13.1 – Our failing test
  1. 这足以让我们编写更多的生产代码:

    package com.wordz.domain;
    public class Wordz {
        private final GameRepository gameRepository;
        public Wordz(GameRepository gr) {
            this.gameRepository = gr;
        }
        public void newGame(Player player) {
            var game = new Game(player, "ARISE", 0);
            gameRepository.create(game);
        }
    }

    我们可以重新运行 NewGameTest 并观察它通过:

    image 2025 01 12 19 00 27 794
    Figure 2. Figure 13.2 – The test passes

    测试现在通过了。我们可以从红绿阶段转向思考重构。立即跳出来的是 ArgumentCaptor 代码在测试中的可读性有多差。它包含了太多关于模拟机制的细节,而没有足够关于为什么我们使用该技术的细节。我们可以通过提取一个命名良好的方法来澄清这一点。

  2. 提取 getGameInRepository() 方法以提高清晰度:

    @Test
    void startsNewGame() {
        var player = new Player();
        wordz.newGame(player);
        Game game = getGameInRepository();
        assertThat(game.getWord()).isEqualTo("ARISE");
        assertThat(game.getAttemptNumber()).isZero();
        assertThat(game.getPlayer()).isSameAs(player);
    }
    private Game getGameInRepository() {
        var gameArgument
            = ArgumentCaptor.forClass(Game.class)
        verify(gameRepository)
            .create(gameArgument.capture());
        return gameArgument.getValue();
    }

    这使得测试更易于阅读,并看到其中的 ArrangeActAssert 模式。它本质上是一个简单的测试,应该这样阅读。我们现在可以重新运行测试并确认它仍然通过。它确实通过了,我们满意我们的重构没有破坏任何东西。

这完成了我们的第一个测试——干得好!我们在这里取得了良好的进展。看到测试通过总是让我感觉良好,这种感觉永远不会过时。这个测试本质上是一个用户故事的端到端测试,仅作用于领域模型。使用六边形架构使我们能够编写覆盖应用程序逻辑细节的测试,同时避免需要测试环境。我们因此获得了运行更快、更稳定的测试。

在我们的下一个测试中还有更多工作要做,因为我们需要删除 Game 对象的硬编码创建。在下一部分中,我们将通过三角化单词选择逻辑来解决这个问题。我们设计下一个测试以驱动随机选择单词的正确行为。

三角化单词选择

接下来的任务是删除我们为使上一个测试通过而使用的作弊手段。我们在创建 Game 对象时硬编码了一些数据。我们需要用正确的代码替换它。这段代码必须从我们已知的五字母单词库中随机选择一个单词。

  1. 添加一个新测试以驱动随机选择单词的行为:

    @Test
    void selectsRandomWord() {
    }
  2. 随机单词选择依赖于两个外部系统——保存单词的数据库和随机数源。由于我们使用六边形架构,领域层不能直接访问它们。我们将用两个接口——这些系统的端口——来表示它们。对于这个测试,我们将使用 Mockito 为这些接口创建存根:

    @ExtendWith(MockitoExtension.class)
    public class NewGameTest {
        @Mock
        private GameRepository gameRepository;
        @Mock
        private WordRepository wordRepository;
        @Mock
        private RandomNumbers randomNumbers;
        @InjectMocks
        private Wordz wordz;
    }

    这个测试向 Wordz 类引入了两个新的协作对象。这些是 WordRepository 接口和 RandomNumbers 接口的任何有效实现的实例。我们需要将这些对象注入 Wordz 对象以使用它们。

  3. 使用依赖注入将两个新接口对象注入 Wordz 类的构造函数:

    public class Wordz {
        private final GameRepository gameRepository;
        private final WordSelection wordSelection;
        public Wordz(GameRepository gr,
                     WordRepository wr,
                     RandomNumbers rn) {
            this.gameRepository = gr;
            this.wordSelection = new WordSelection(wr, rn);
        }
    }

    我们向构造函数添加了两个参数。我们不需要直接将它们存储为字段。相反,我们使用之前创建的 WordSelection 类。我们创建一个 WordSelection 对象并将其存储在名为 wordSelection 的字段中。请注意,我们之前使用 @InjectMocks 意味着我们的测试代码将自动将模拟对象传递给此构造函数,而无需进一步的代码更改。这非常方便。

  4. 我们设置模拟对象。我们希望它们在调用 WordRepository 接口的 fetchWordByNumber() 方法和 RandomNumbers 接口的 next() 方法时模拟我们期望的行为:

    @Test
    void selectsRandomWord() {
        when(randomNumbers.next(anyInt())).thenReturn(2);
        when(wordRepository.fetchWordByNumber(2))
            .thenReturn("ABCDE");
    }

    这将设置我们的模拟对象,以便在调用 next() 时,它将始终返回单词编号 2,作为完整应用程序中将生成的随机数的测试替身。当使用 2 作为参数调用 fetchWordByNumber() 时,它将返回单词编号 2 的单词,在我们的测试中将是 "ABCDE"。查看该代码,我们可以通过使用局部变量而不是魔术数字 2 来增加清晰度。对于代码的未来读者,随机数生成器输出和单词库之间的联系将更加明显:

    @Test
    void selectsRandomWord() {
        int wordNumber = 2;
        when(randomNumbers.next(anyInt()))
            .thenReturn(wordNumber);
        when(wordRepository
            .fetchWordByNumber(wordNumber))
            .thenReturn("ABCDE");
    }
  5. 这看起来仍然过于详细。再次强调,太多关注模拟机制,而太少关注模拟代表的内容。让我们提取一个方法来解释为什么我们要设置这个存根。我们还将传入我们想要选择的单词。这将帮助我们更容易理解测试代码的目的:

    @Test
    void selectsRandomWord() {
        givenWordToSelect("ABCDE");
    }
    private void givenWordToSelect(String wordToSelect){
        int wordNumber = 2;
        when(randomNumbers.next(anyInt()))
            .thenReturn(wordNumber);
        when(wordRepository
            .fetchWordByNumber(wordNumber))
            .thenReturn(wordToSelect);
    }
  6. 现在,我们可以编写断言以确认该单词被传递到 gameRepositorycreate() 方法——我们可以重用我们的 getGameInRepository() 断言辅助方法:

    @Test
    void selectsRandomWord() {
        givenWordToSelect("ABCDE");
        var player = new Player();
        wordz.newGame(player);
        Game game = getGameInRepository();
        assertThat(game.getWord()).isEqualTo("ABCDE");
    }

    这遵循与之前的测试 startsNewGame 相同的方法。

  7. 观察测试失败。编写生产代码以使测试通过:

    public void newGame(Player player) {
        var word = wordSelection.chooseRandomWord();
        Game game = new Game(player, word, 0);
        gameRepository.create(game);
    }
  8. 观察新测试通过,然后运行所有测试:

    image 2025 01 12 19 19 55 336
    Figure 3. Figure 13.3 – Original test failing

    我们的初始测试现在失败了。我们在最新的代码更改中破坏了某些东西。TDD 通过为我们提供回归测试来保证我们的安全。发生的情况是,在删除原始测试依赖的硬编码单词 "ARISE" 后,它失败了。正确的解决方案是将所需的模拟设置添加到我们的原始测试中。我们可以重用 givenWordToSelect() 辅助方法来实现这一点。

  9. 将模拟设置添加到原始测试:

    @Test
    void startsNewGame() {
        var player = new Player();
        givenWordToSelect("ARISE");
        wordz.newGame(player);
        Game game = getGameInRepository();
        assertThat(game.getWord()).isEqualTo("ARISE");
        assertThat(game.getAttemptNumber()).isZero();
        assertThat(game.getPlayer()).isSameAs(player);
    }
  10. 重新运行所有测试并确认它们都通过:

    image 2025 01 12 19 20 51 880
    Figure 4. Figure 13.4 – All tests passing

我们已经测试驱动了我们的第一段代码来开始一个新游戏,随机选择一个单词来猜测,并使测试通过。在我们继续之前,是时候考虑是否应该进行重构了。我们在编写代码时一直在整理代码,但有一个明显的特征。看看这两个测试。它们现在看起来非常相似。原始测试已成为我们用于测试驱动添加单词选择的测试的超集。selectsRandomWord() 测试是一个不再有用途的脚手架测试。对于这样的代码,只有一件事要做——删除它。作为一个小的可读性改进,我们还可以为 Player 变量提取一个常量:

  1. Player 变量提取一个常量:

    private static final Player PLAYER = new Player();
    @Test
    void startsNewGame() {
        givenWordToSelect("ARISE");
        wordz.newGame(PLAYER);
        Game game = getGameInRepository();
        assertThat(game.getWord()).isEqualTo("ARISE");
        assertThat(game.getAttemptNumber()).isZero();
        assertThat(game.getPlayer()).isSameAs(PLAYER);
    }
  2. 我们将在之后运行所有测试以确保它们仍然通过,并且 selectsRandomWord() 已被删除。

    image 2025 01 12 19 22 03 131
    Figure 5. Figure 13.5 – All tests passing

就是这样!我们已经测试驱动了开始游戏所需的所有行为。这是一个重要的成就,因为该测试覆盖了一个完整的用户故事。所有领域逻辑都已测试并已知正常工作。设计看起来很简单。测试代码清楚地指定了我们期望代码执行的操作。这是很大的进展。

在此重构之后,我们可以继续进行下一个开发任务——支持玩游戏的代码。