开始 Wordz

让我们将这些想法应用到我们的 Wordz 应用程序中。我们将从一个包含我们应用程序核心逻辑的类开始,这个类表示一个待猜的单词,并能够计算猜测的得分。

我们首先创建一个单元测试类,这会立即让我们进入软件设计模式:我们应该如何命名这个测试?我们将其命名为 WordTest,因为它概括了我们要覆盖的领域——待猜的单词。

典型的 Java 项目结构是按包组织的。生产代码位于 src/main/java 下,而测试代码位于 src/test/java 下。这个结构描述了生产代码和测试代码作为源代码的同等重要性,同时为我们提供了一种只编译和部署生产代码的方法。当我们处理源代码时,我们总是将测试代码与生产代码一起发布,但对于已部署的可执行文件,我们只会省略测试代码。我们还将遵循 Java 基本的包规范,在顶级使用我们公司或项目的唯一名称。这有助于避免与库代码发生冲突。我们将其命名为 com.wordz,命名源自应用程序。

下一步设计是决定首先驱动和测试哪些行为。我们总是希望先处理一个简单版本的正常路径,某些最常执行的正常逻辑。边界情况和错误条件可以稍后再处理。首先,让我们编写一个测试,用于返回单个错误字母的得分:

  1. 编写以下代码开始我们的测试:

    public class WordTest {
    
        @Test
        public void oneIncorrectLetter() {
        }
    }

    测试的名称为我们提供了测试内容的概述。

  2. 为了开始我们的设计,我们决定使用一个名为 Word 的类来表示我们的单词。我们还决定将要猜测的单词作为构造函数参数提供给我们要创建的 Word 类的对象实例。我们将这些设计决策编码到测试中:

    @Test
    public void oneIncorrectLetter() {
        new Word("A");
    }
  3. 我们此时使用自动完成功能在 src/main 文件夹树中创建一个新的 Word 类文件,而不是在 src/test 中:

    image 2025 01 12 00 12 40 097
  4. 点击 “OK” 在正确的包中创建文件。

  5. 现在,我们重命名 Word 构造函数参数:

    public class Word {
        public Word(String correctWord) {
            // No Action
        }
    }
  6. 接下来,我们回到测试。我们将新对象捕获为局部变量,以便我们可以测试它:

    @Test
    public void oneIncorrectLetter() {
        var word = new Word("A");
    }

    下一个设计步骤是考虑一种将猜测传递给 Word 类并返回分数的方法。

  7. 传递猜测是一个简单的决定——我们将使用一个名为 guess() 的方法。我们可以将这些决策编码到测试中:

    @Test
    public void oneIncorrectLetter() {
        var word = new Word("A");
        word.guess("Z");
    }
  8. 使用自动完成功能将 guess() 方法添加到 Word 类中:

    image 2025 01 12 00 14 39 185
    Figure 1. Figure 5.5 – Creating the Word class
  9. 点击 “Enter” 添加方法,然后将参数名称更改为描述性名称:

    public void guess(String attempt) {
    }
  10. 接下来,让我们添加一种从该猜测中获取结果分数的方法。从测试开始:

    @Test
    public void oneIncorrectLetter() {
        var word = new Word("A");
        var score = word.guess("Z");
    }

    然后,我们需要稍微思考一下从生产代码中返回什么。

我们可能想要某种对象。这个对象必须代表该猜测的分数。因为我们当前的用户故事是关于五个字母单词的分数和每个字母的详细信息,所以我们必须返回完全正确、正确字母但位置错误或字母不存在的其中之一。

有几种方法可以做到这一点,现在是时候停下来思考一下。以下是一些可行的方法:

  • 一个带有五个 getter 的类,每个 getter 返回一个枚举。

  • 一个 Java 17 record 类型,具有相同的 getter。

  • 一个带有 iterator 方法的类,它迭代五个枚举常量。

  • 一个带有 iterator 方法的类,为每个字母分数返回一个接口。评分代码将为每种类型的分数实现一个具体类。这将是一种纯粹面向对象的方式,为每个可能的结果添加回调。

  • 一个迭代每个字母结果的类,并为每个结果传入一个 Java 8 lambda 函数。正确的函数将作为每个字母的回调调用。

这已经有很多设计选项了。TDD 的关键部分是我们现在在编写任何生产代码之前就考虑这些问题。为了帮助我们决定,让我们勾画一下调用代码的样子。我们需要考虑代码可能的扩展——我们需要的单词字母数是五个更多还是更少?得分规则是否会发生变化?我们现在需要关心这些问题吗?将来阅读这段代码的人是否能比其他设计更容易理解其中的某个想法?TDD 给我们提供了对设计决策的快速反馈,这迫使我们现在进行设计上的锻炼。

有一个主导的决策是,我们不会返回每个字母应该具有的颜色。这将是 UI 代码的决策。对于这个核心领域逻辑,我们只返回字母是否正确,位置是否错误,或者根本不存在。

通过 TDD 来勾画调用代码是相当容易的,因为它本身就是测试代码。在思考了大约 15 分钟之后,这里是我们在这段代码中使用的三个设计决策:

  • 支持单词中字母数量的可变性

  • 使用简单的枚举(INCORRECTPART_CORRECTCORRECT)表示得分

  • 按单词中每个字母的位置访问每个得分,位置从零开始

这些决策支持了 KISS 原则,通常称为 "保持简单,愚蠢"。支持字母数量可变的决定让我有点怀疑是否违反了另一个原则——YAGNI(You Aren’t Gonna Need It,意思是你不会需要它)。在这种情况下,我说服自己,这不是过多的推测性设计,而且 score 对象的可读性将弥补这一点。让我们继续设计:

  1. 在测试中捕获这些决策:

    @Test
    public void oneIncorrectLetter() {
        var word = new Word("A");
    
        var score = word.guess("Z");
    
        var result = score.letter(0);
        assertThat(result).isEqualTo(Letter.INCORRECT);
    }

    我们可以看到这个测试如何锁定了关于我们如何使用对象的设计决策。它完全没有说明我们如何在内部实现这些方法。这对有效的 TDD 至关重要。我们还在这个测试中捕获并记录了所有的设计决策。创建这样的可执行规范是 TDD 的一个重要好处。

  2. 现在运行这个测试。观察它失败。这是一个令人惊讶的重要步骤。 我们可能最初认为我们只希望看到通过的测试。这并不完全正确。TDD 中的部分工作是对测试正在工作有信心。当我们知道我们还没有编写代码使其通过时,看到测试失败,这让我们有信心认为我们的测试可能正在检查正确的事情。

  3. 让我们通过向 Word 类添加代码来使测试通过:

    public class Word {
        public Word(String correctWord) {
            // Not Implemented
        }
    
        public Score guess(String attempt) {
            var score = new Score();
            return score;
        }
    }
  4. 接下来,创建 Score 类:

    public class Score {
        public Letter letter(int position) {
            return Letter.INCORRECT;
        }
    }

    再次,我们使用 IDE 快捷方式为我们编写了大部分代码。测试通过:

image 2025 01 12 00 26 07 015
Figure 2. Figure 5.6 – A test passing in IntelliJ

我们可以看到测试通过了,并且运行时间为 0.139 秒。这肯定比任何手动测试都要快。

我们还拥有一个可重复的测试,可以在项目生命周期的其余时间里运行。与手动测试相比,每次运行测试套件时节省的时间会累计起来。

你会注意到,虽然测试通过了,但代码似乎在作弊。测试只期望 Letter.INCORRECT,而代码硬编码为始终返回该值。显然,它永远不可能适用于其他值!这一点在这个阶段是可以预见的。我们的第一个测试为代码接口的设计制定了一个粗略的框架。它尚未开始推动完整实现的开发。我们将通过后续的测试来完成这一点。这个过程称为三角测量(triangulation),我们依赖于添加测试来推动缺失的实现细节。通过这样做,所有代码都会被测试覆盖。我们可以免费获得 100% 的有效代码覆盖率。更重要的是,它将我们的工作分解为更小的块,通过频繁的交付创造进展,并可能带来一些有趣的解决方案。

另一个需要注意的点是,我们的一个测试促使我们创建了两个类,并且这两个类都被那个测试覆盖。这是非常推荐的。请记住,我们的单元测试覆盖的是行为,而不是该行为的具体实现。