玩游戏
在这一部分,我们将构建玩游戏的逻辑。游戏玩法包括对选定的单词进行多次猜测,查看该猜测的分数,并进行另一次猜测。游戏在单词被正确猜出或达到允许的最大尝试次数时结束。
我们将从假设我们处于典型游戏的开始阶段,即将进行第一次猜测开始。我们还将假设这个猜测并不完全正确。这使我们能够推迟关于游戏结束行为的决策,这是一件好事,因为我们已经有很多需要决定的事情了。
设计计分接口
我们首先需要做出的设计决策是在猜测单词后返回什么信息。我们需要向用户返回以下信息:
-
当前猜测的得分
-
游戏是否仍在进行中或已结束
-
可能返回每次猜测的得分历史记录
-
可能返回用户输入错误的报告
显然,对玩家来说最重要的信息是当前猜测的得分。没有这个信息,游戏无法进行。由于游戏的长度是可变的——当单词被猜中或达到最大猜测次数时结束——我们需要一个指示器来表明是否允许再次猜测。
返回之前猜测得分历史记录的想法是,它可能有助于我们的领域模型的消费者——最终是某种用户界面。如果我们只返回当前猜测的得分,用户界面很可能需要保留自己的得分历史记录,以便正确展示。如果我们返回整个游戏的得分历史记录,这些信息就很容易获得。软件设计中的一个好原则是遵循 “你不需要它”(YAGNI)原则。由于没有对得分历史记录的需求,我们现阶段不会构建这个功能。
我们需要做出的最后一个决策是为这个功能设计编程接口。我们将选择在`Wordz`类中创建一个 assess()
方法。它将接受一个 String
类型的参数,表示玩家的当前猜测。它将返回一个 record
,这是现代 Java(自 Java 14 起)表示纯数据结构的方式。
现在我们已经有了足够的信息来编写测试。我们将为所有与猜测相关的行为创建一个新的测试类 GuessTest
。测试代码如下:
@ExtendWith(MockitoExtension.class)
public class GuessTest {
private static final Player PLAYER = new Player();
private static final String CORRECT_WORD = "ARISE";
private static final String WRONG_WORD = "RXXXX";
@Mock
private GameRepository gameRepository;
@InjectMocks
private Wordz wordz;
@Test
void returnsScoreForGuess() {
givenGameInRepository(
Game.create(PLAYER, CORRECT_WORD));
GuessResult result = wordz.assess(PLAYER, WRONG_WORD);
Letter firstLetter = result.score().letter(0);
assertThat(firstLetter)
.isEqualTo(Letter.PART_CORRECT);
}
private void givenGameInRepository(Game game) {
when(gameRepository
.fetchForPlayer(eq(PLAYER)))
.thenReturn(Optional.of(game));
}
}
这个测试中没有新的 TDD 技术。它驱动出了我们新的 assess()
方法的调用接口。我们使用了静态构造函数模式来创建 Game
对象,使用了 Game.create()
方法。这个方法已经被添加到 Game
类中:
static Game create(Player player, String correctWord) {
return new Game(player, correctWord, 0, false);
}
这明确了创建一个新游戏所需的信息。为了让测试通过,我们创建了 GuessResult
记录:
package com.wordz.domain;
import java.util.List;
public record GuessResult(
Score score,
boolean isGameOver
) { }
我们可以通过在 Wordz
类中编写 assess()
方法的生产代码来使测试通过。为此,我们将重用已经编写的 Word
类:
public GuessResult assess(Player player, String guess) {
var game = gameRepository.fetchForPlayer(player);
var target = new Word(game.getWord());
var score = target.guess(guess);
return new GuessResult(score, false);
}
断言只检查第一个字母的得分是否正确。这是一个故意设计得较弱的测试。详细的得分行为测试已经在之前编写的 WordTest
类中完成。这个测试被描述为弱测试,因为它没有完全测试返回的得分,只测试了第一个字母。得分逻辑的强测试发生在其他地方,即在 WordTest
类中。这里的弱测试确认了我们至少能够正确计算一个字母的得分,这足以让我们测试驱动生产代码。我们在这里避免了重复测试。
运行测试显示它通过了。我们可以审查测试代码和生产代码,看看是否可以通过重构改进设计。目前,没有什么需要紧急处理的。我们可以继续推进游戏进度的跟踪。
三角化游戏进度跟踪
我们需要跟踪已经进行的猜测次数,以便在达到最大尝试次数后结束游戏。我们的设计选择是更新 Game
对象中的 attemptNumber
字段,然后将其存储到 GameRepository
中:
-
我们添加一个测试来驱动这段代码的实现:
@Test void updatesAttemptNumber() { givenGameInRepository( Game.create(PLAYER, CORRECT_WORD)); wordz.assess(PLAYER, WRONG_WORD); var game = getUpdatedGameInRepository(); assertThat(game.getAttemptNumber()).isEqualTo(1); } private Game getUpdatedGameInRepository() { ArgumentCaptor<Game> argument = ArgumentCaptor.forClass(Game.class); verify(gameRepository).update(argument.capture()); return argument.getValue(); }
这个测试引入了一个新方法
update()
到我们的GameRepository
接口中,负责将最新的游戏信息写入存储。断言步骤使用了 Mockito 的ArgumentCaptor
来检查我们传递给gameRepository.update()
的Game
对象。我们编写了getUpdatedGameInRepository()
方法,以减少检查gameRepository.update()
方法内部逻辑的复杂性。assertThat()
在测试中验证了attemptNumber
是否已递增。由于我们创建了一个新游戏,它的初始值为 0,因此预期的新值为 1。这是跟踪猜测尝试次数的预期行为。 -
在
GameRepository
接口中添加update()
方法package com.wordz.domain; public interface GameRepository { void create(Game game); Game fetchForPlayer(Player player); void update(Game game); }
-
在
Wordz
类的assess()
方法中添加生产代码 我们更新assess()
方法,增加attemptNumber
并调用update()
:public GuessResult assess(Player player, String guess) { var game = gameRepository.fetchForPlayer(player); game.incrementAttemptNumber(); gameRepository.update(game); var target = new Word(game.getWord()); var score = target.guess(guess); return new GuessResult(score, false); }
-
在
Game
类中添加incrementAttemptNumber()
方法 我们为Game
类添加递增尝试次数的方法:
public void incrementAttemptNumber() {
attemptNumber++;
}
现在测试通过了。我们可以考虑是否需要进行重构改进。有两个地方值得注意:
-
NewGameTest
和GuessTest
类之间的重复测试设置目前我们可以接受这种重复。可能的解决方案包括将两个测试合并到同一个测试类中、扩展一个共同的测试基类,或者使用组合。但这些方法似乎都不会显著提高可读性。目前将两个不同的测试用例分开似乎更合适。
-
assess()
方法中的三行代码必须作为一个单元调用可能会忘记调用其中某一行代码,因此最好通过重构来消除这种可能的错误。我们可以这样重构:
public GuessResult assess(Player player, String guess) { var game = gameRepository.fetchForPlayer(player); Score score = game.attempt(guess); gameRepository.update(game); return new GuessResult(score, false); }
我们将原本的代码移到新创建的
Game
类的attempt()
方法中:public Score attempt(String latestGuess) { attemptNumber++; var target = new Word(targetWord); return target.guess(latestGuess); }
将方法参数从
guess
重命名为latestGuess
可以提高可读性。
至此,我们已经完成了猜测单词所需的代码。接下来,我们将继续测试驱动检测游戏何时结束的代码。