为 Wordz 编写我们的下一个测试

那么,我们应该为下一个测试编写什么?什么是足够小且有用的步骤,以便我们不会陷入编写超出测试支持范围的陷阱?在本节中,我们将继续使用 TDD 构建 Wordz 应用程序的评分系统。我们将讨论在每个步骤中如何选择前进。

对于下一个测试,一个好的选择是稳妥行事,只迈出一小步。我们将为单个正确字母添加一个测试。这将驱动出我们的第一段真正的应用程序逻辑:

  1. 从红色开始。为单个正确字母编写一个失败的测试:

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

    这个测试有意与前一个测试相似。不同之处在于它测试字母是否正确,而不是错误。我们有意使用了相同的单词——单个字母 “A”。这在编写测试时很重要——使用有助于讲述我们正在测试的内容和原因的测试数据。这里的故事是,相同的单词在不同的猜测下会导致不同的分数——显然是我们正在解决问题的关键。我们的两个测试用例完全覆盖了任何单字母单词猜测的两种可能结果。

    使用 IDE 的自动完成功能,我们很快对 Word 类进行了更改。

  2. 现在让我们通过添加生产代码使测试通过:

    public class Word {
        private final String word;
    
        public Word(String correctWord) {
            this.word = correctWord;
        }
    
        public Score guess(String attempt) {
            var score = new Score(word);
    
            score.assess(0, attempt);
            return score;
        }
    }

    这里的目标是使新测试通过,同时保持现有测试通过。我们不想破坏任何现有代码。我们添加了一个名为 word 的字段,它将存储我们应该猜测的单词。我们添加了一个公共构造函数来初始化此字段。我们在 guess() 方法中添加了代码以创建一个新的 Score 对象。我们决定向这个 Score 类添加一个名为 assess() 的方法。此方法负责评估我们的猜测应该得多少分。我们决定 assess() 应该有两个参数。第一个参数是我们希望评估分数的单词字母的从零开始的索引。第二个参数是我们的猜测。

    我们使用 IDE 帮助我们编写 Score 类:

    public class Score {
        private final String correct;
        private Letter result = Letter.INCORRECT;
    
        public Score(String correct) {
            this.correct = correct;
        }
    
        public Letter letter(int position) {
            return result;
        }
    
        public void assess(int position, String attempt) {
            if (correct.charAt(position) == attempt.charAt(position)) {
                result = Letter.CORRECT;
            }
        }
    }

    为了覆盖 oneCorrectLetter() 测试的新行为,我们添加了前面的代码。与之前 assess() 方法总是返回 Letter.INCORRECT 不同,新测试迫使一个新的方向。assess() 方法现在必须能够在猜测字母正确时返回正确的分数。

    为了实现这一点,我们添加了一个名为 result 的字段来保存最新分数,从 letter() 方法返回该结果的代码,以及 assess() 方法中检查猜测的第一个字母是否与单词的第一个字母匹配的代码。如果我们做对了,我们的两个测试现在都应该通过。

    运行所有测试以查看我们的进展:

    image 2025 01 12 14 28 26 277
    Figure 1. Figure 6.5 – Two tests passing

    这里有很多要回顾的地方。注意我们的两个测试都通过了。通过运行到目前为止的所有测试,我们证明了我们没有破坏任何东西。我们对代码的更改添加了新功能,并且没有破坏任何现有功能。这很强大。注意另一个明显的方面——我们知道我们的代码有效。我们不必等到手动测试阶段,等到某个集成点,或者等到用户界面准备好。我们现在就知道我们的代码有效。作为一个小点,请注意 0.103 秒的持续时间。两个测试在十分之一秒内完成,比手动测试快得多。一点也不差。

    在设计方面,我们已经取得了进展。我们已经超越了硬编码的 Letter.INCORRECT 结果,代码现在可以检测正确和错误的猜测。我们在 Score 类中添加了重要的设计概念 assess() 方法。这是有意义的。我们的代码现在揭示了一个设计;Score 对象将知道正确的单词,并能够使用 assess() 方法对猜测进行评估。这里使用的术语很好地描述了我们正在解决的问题。我们想要评估一个猜测以返回单词分数。

    现在测试通过了,我们可以继续前进——但 TDD 的一个重要部分是不断改进我们的代码,并在测试的指导下朝着更好的设计努力。我们现在进入 RGR 循环的重构阶段。再次,TDD 将控制权交还给我们。我们想重构吗?我们应该重构什么?为什么?现在值得这样做,还是我们可以推迟到以后的步骤?

    让我们回顾代码并寻找代码异味。代码异味是表明实现可能需要改进的迹象。这个名字来源于食物开始变质时的气味。

    一个代码异味是重复代码。单独来看,一点重复代码可能没问题。但这是一个早期警告,可能使用了太多的复制粘贴,并且我们没有更直接地捕捉到一个重要的概念。让我们回顾我们的代码以消除重复。我们还可以寻找另外两个常见的代码异味——不清晰的命名,以及如果提取到自己的方法中会更易于阅读的代码块。显然,这是主观的,我们都会对要更改的内容有不同的看法。

    定义代码异味

    术语 “代码异味” 最初出现在 C2 wiki 上。值得一读以查看给出的代码异味示例。它有一个有用的定义,指出代码异味是需要审查但不一定需要更改的东西: https://wiki.c2.com/?CodeSmell

    让我们反思一下 assess() 方法的内部。它似乎有太多的代码。让我们提取一个辅助方法以增加一些清晰度。如果我们觉得它没有帮助,我们总是可以恢复更改。

  3. 重构。提取 isCorrectLetter() 方法以提高清晰度:

    public void assess(int position, String attempt) {
        if (isCorrectLetter(position, attempt)) {
            result = Letter.CORRECT;
        }
    }
    
    private boolean isCorrectLetter(int position, String attempt) {
        return correct.charAt(position) == attempt.charAt(position);
    }

    再次,我们运行所有测试以证明此重构没有破坏任何东西。测试通过。在前面的代码中,我们将一个复杂的条件语句拆分到自己的私有方法中。动机是在代码中引入一个方法名称。这是一种有效的代码注释方式——编译器帮助我们保持最新。它帮助 assess() 方法中的调用代码讲述一个更好的故事。if 语句现在几乎用英语说 “如果这是一个正确的字母”。这是提高可读性的强大工具。

    可读性发生在编写时而不是阅读时

    编码初学者常问的一个问题是 “如何提高我阅读代码的能力?” 这是一个有效的问题,因为任何一行代码被人类程序员阅读的次数都比编写的次数多得多。可读性在编写代码时就已经决定。任何一行代码都可以写得易于阅读或难以阅读。作为作者,我们可以选择。如果我们始终选择易于阅读而不是其他任何东西,其他人会发现我们的代码易于阅读。写得不好的代码很难阅读。遗憾的是,写得不好很容易。

    在这个阶段,我还有两个领域想要重构。第一个是改进测试可读性的简单方法。

    让我们重构测试代码以提高其清晰度。我们将添加一个自定义断言方法:

    @Test
    public void oneCorrectLetter() {
        var word = new Word("A");
    
        var score = word.guess("A");
    
        assertScoreForLetter(score, 0, Letter.CORRECT);
    }
    
    private void assertScoreForLetter(Score score, int position, Letter expected) {
        assertThat(score.letter(position))
            .isEqualTo(expected);
    }

    前面的代码将 assertThat() 断言移动到自己的私有方法中。我们称此方法为 assertScoreForLetter(),并为其提供了一个描述所需信息的签名。此更改提供了对测试正在做什么的更直接描述,同时减少了一些重复代码。它还保护我们免受断言实现更改的影响。这似乎是朝着更全面的断言迈出的一步,一旦我们支持更多字母的猜测,我们将需要这一点。再次,我们没有在源代码中添加注释,而是使用了一个方法名称来捕捉 assertThat() 代码的意图。编写 AssertJ 自定义匹配器是另一种实现这一点的方式。

    我们可能想要做的下一个重构有点争议,因为它是一个设计更改。让我们进行重构,讨论它,然后如果我们不喜欢它,可能会恢复代码。这将节省数小时的时间来思考更改会是什么样子。

  4. 更改 assess() 方法中指定要检查字母位置的方式:

public class Score {
    private final String correct;
    private Letter result = Letter.INCORRECT;
    private int position;

    public Score(String correct) {
        this.correct = correct;
    }

    public Letter letter(int position) {
        return result;
    }

    public void assess(String attempt) {
        if (isCorrectLetter(attempt)) {
            result = Letter.CORRECT;
        }
    }

    private boolean isCorrectLetter(String attempt) {
        return correct.charAt(position) == attempt.charAt(position);
    }
}

我们从 assess() 方法中删除了 position 参数,并将其转换为一个名为 position 的字段。目的是简化 assess() 方法的使用。它不再需要明确说明正在评估哪个位置。这使得代码更易于调用。我们刚刚添加的代码仅在位置为零的情况下有效。这很好,因为这是我们现阶段测试所需的唯一内容。我们稍后将使此代码适用于非零值。

这个更改之所以有争议,是因为它要求我们更改测试代码以反映方法签名的更改。我准备接受这一点,知道我可以使用 IDE 的自动重构支持来安全地完成此操作。它还引入了一个风险:我们必须确保在调用 isCorrectLetter() 之前将 position 设置为正确的值。我们将看到这是如何发展的。这可能会使代码更难以理解,在这种情况下,简化的 assess() 方法可能不值得。如果我们发现情况如此,我们可以改变我们的方法。

我们现在处于代码对任何单字母单词都完成的阶段。我们应该接下来尝试什么?似乎我们应该继续处理两个字母的单词,看看这会如何改变我们的测试和逻辑。

通过两字母组合推进设计

我们可以继续添加测试,以使代码能够处理两个字母的组合。在使代码处理单个字母后,这是一个明显的步骤。为此,我们需要在代码中引入一个新概念:一个字母可以出现在单词中,但不在我们猜测的位置:

  1. 从编写一个测试开始,测试第二个字母在错误位置的情况:

    @Test
    void secondLetterWrongPosition() {
        var word = new Word("AR");
        var score = word.guess("ZA");
        assertScoreForLetter(score, 1, Letter.PART_CORRECT);
    }

    让我们更改 assess() 方法中的代码以使其通过,并保持现有测试通过。

  2. 添加初始代码以检查猜测中的所有字母:

    public void assess(String attempt) {
        for (char current : attempt.toCharArray()) {
            if (isCorrectLetter(current)) {
                result = Letter.CORRECT;
            }
        }
    }
    
    private boolean isCorrectLetter(char currentLetter) {
        return correct.charAt(position) == currentLetter;
    }

    这里的主要变化是评估 attempt 中的所有字母,而不是假设它只有一个字母。当然,这是此测试的目的——驱动出这种行为。通过选择将 attempt 字符串转换为 char 数组,代码似乎读起来相当不错。这个简单的算法遍历每个 char,使用 current 变量表示当前要评估的字母。这要求 isCorrectLetter() 方法进行重构,以接受并使用 char 输入——或者将 char 转换为 String,但这看起来不太美观。

    原始的单字母行为测试仍然通过,这是必须的。我们知道循环内部的逻辑不可能正确——我们只是覆盖了 result 字段,它最多只能存储一个字母的结果。我们需要改进该逻辑,但在我们为此添加测试之前,我们不会这样做。这种工作方式被称为三角测量(triangulation)——随着我们添加更多特定测试,代码变得更加通用。对于我们的下一步,我们将添加代码以检测尝试的字母是否出现在单词的其他位置。

  3. 添加代码以检测正确字母出现在错误位置的情况:

    public void assess(String attempt) {
        for (char current : attempt.toCharArray()) {
            if (isCorrectLetter(current)) {
                result = Letter.CORRECT;
            } else if (occursInWord(current)) {
                result = Letter.PART_CORRECT;
            }
        }
    }
    
    private boolean occursInWord(char current) {
        return correct.contains(String.valueOf(current));
    }

    我们添加了对新私有方法 occursInWord() 的调用,如果当前字母出现在单词中的任何位置,它将返回 true。我们已经确定这个当前字母不在正确的位置。这应该为我们提供一个清晰的结果,表明正确字母不在正确位置。

    此代码使所有三个测试通过。这立即引起了怀疑,因为它不应该发生。我们已经知道我们的逻辑覆盖了单一的 result 字段,这意味着许多组合将失败。发生的情况是我们的最新测试相当弱。我们可以回去加强该测试,添加额外的断言。或者,我们可以保持原样并编写另一个测试。这样的困境在开发中很常见,通常不值得花太多时间思考它们。无论哪种方式都会推动我们前进。

    让我们添加另一个测试以完全测试第二个字母在错误位置的行为。

  4. 添加一个新测试以测试所有三种评分可能性:

    @Test
    void allScoreCombinations() {
        var word = new Word("ARI");
        var score = word.guess("ZAI");
        assertScoreForLetter(score, 0, Letter.INCORRECT);
        assertScoreForLetter(score, 1, Letter.PART_CORRECT);
        assertScoreForLetter(score, 2, Letter.CORRECT);
    }

    正如预期的那样,此测试失败。检查生产代码后,原因显而易见。这是因为我们将结果存储在同一个单值字段中。现在我们有一个失败的测试,我们可以纠正评分逻辑。

  5. 添加一个 List 来分别存储每个字母位置的结果:

    public class Score {
        private final String correct;
        private final List<Letter> results = new ArrayList<>();
        private int position;
    
        public Score(String correct) {
            this.correct = correct;
        }
    
        public Letter letter(int position) {
            return results.get(position);
        }
    
        public void assess(String attempt) {
            for (char current : attempt.toCharArray()) {
                if (isCorrectLetter(current)) {
                    results.add(Letter.CORRECT);
                } else if (occursInWord(current)) {
                    results.add(Letter.PART_CORRECT);
                } else {
                    results.add(Letter.INCORRECT);
                }
                position++;
            }
        }
    
        private boolean occursInWord(char current) {
            return correct.contains(String.valueOf(current));
        }
    
        private boolean isCorrectLetter(char currentLetter) {
            return correct.charAt(position) == currentLetter;
        }
    }

    这花了两次尝试才正确,由我们刚刚添加的测试失败驱动。前面的最终结果通过了所有四个测试,证明它可以正确评分三个字母单词中的所有组合。主要的变化是用 ArrayList 替换了单值的 result 字段,并更改了 letter(position) 实现方法以使用这个新的结果集合。运行此更改导致失败,因为代码无法再检测到错误的字母。以前,这是由 result 字段的默认值处理的。现在,我们必须为每个字母明确地执行此操作。然后我们需要在循环中更新 position 以跟踪我们正在评估的字母位置。

    我们添加了一个测试,看到它变红并失败,然后添加代码使测试变绿并通过,所以现在是时候重构了。测试代码和生产代码中都有一些看起来不太对的地方。

    在生产代码类 Score 中,assess() 方法的循环体似乎有些笨拙。它有一个长的循环体,其中包含逻辑和一组 if-else-if 块。感觉代码可以更清晰。我们可以将循环体提取到一个方法中。方法名称为我们提供了一个描述每个字母发生了什么的地方。然后循环变得更短且更易于理解。我们还可以用更简单的结构替换 if-else-if 阶梯。

  6. 将循环体内部的逻辑提取到 scoreFor() 方法中:

    public void assess(String attempt) {
        for (char current : attempt.toCharArray()) {
            results.add(scoreFor(current));
            position++;
        }
    }
    
    private Letter scoreFor(char current) {
        if (isCorrectLetter(current)) {
            return Letter.CORRECT;
        }
        if (occursInWord(current)) {
            return Letter.PART_CORRECT;
        }
        return Letter.INCORRECT;
    }

    这读起来更清晰。scoreFor() 方法的主体现在是对每个字母评分规则的简明描述。我们用更简单的 if-return 结构替换了 if-else-if 结构。我们计算出分数,然后立即退出方法。

    接下来的工作是清理测试代码。在 TDD 中,测试代码与生产代码具有同等优先级。它构成了系统文档的一部分。它需要与生产代码一起维护和扩展。我们以与生产代码相同的重要性对待测试代码的可读性。

    测试代码的代码异味在于断言。有两件事可以改进。代码中存在明显的重复,我们可以消除它。还有一个问题是一个测试中应该进行多少次断言。

  7. 通过提取方法去除重复的断言代码:

    @Test
    void allScoreCombinations() {
        var word = new Word("ARI");
        var score = word.guess("ZAI");
        assertScoreForGuess(score, INCORRECT, PART_CORRECT, CORRECT);
    }
    
    private void assertScoreForGuess(Score score, Letter... expectedScores) {
        for (int position = 0; position < expectedScores.length; position++) {
            Letter expected = expectedScores[position];
    
            assertThat(score.letter(position)).isEqualTo(expected);
        }
    }

    通过提取 assertScoreForGuess() 方法,我们创建了一种检查可变数量字母分数的方法。这消除了我们之前复制粘贴的断言行,并提高了抽象级别。测试代码现在更清晰地描述了我们对分数的期望顺序——INCORRECTPART_CORRECTCORRECT。通过添加对这些枚举的静态导入,语法混乱也得到了有益的减少。

    早期的测试现在可以手动修改以使用这个新的断言助手。这使我们能够内联原始的 assertScoreForLetter() 方法,因为它不再增加价值。

  8. 现在,让我们看看重构后的最终测试集:

    package com.wordz.domain;
    
    import org.junit.jupiter.api.Test;
    import static com.wordz.domain.Letter.*;
    import static org.assertj.core.api.Assertions.assertThat;
    
    public class WordTest {
        @Test
        public void oneIncorrectLetter() {
            var word = new Word("A");
            var score = word.guess("Z");
            assertScoreForGuess(score, INCORRECT);
        }
    
        @Test
        public void oneCorrectLetter() {
            var word = new Word("A");
            var score = word.guess("A");
            assertScoreForGuess(score, CORRECT);
        }
    
        @Test
        public void secondLetterWrongPosition() {
            var word = new Word("AR");
            var score = word.guess("ZA");
            assertScoreForGuess(score, INCORRECT, PART_CORRECT);
        }
    
        @Test
        public void allScoreCombinations() {
            var word = new Word("ARI");
            var score = word.guess("ZAI");
            assertScoreForGuess(score, INCORRECT, PART_CORRECT, CORRECT);
        }
    
        private void assertScoreForGuess(Score score, Letter... expectedScores) {
            for (int position = 0; position < expectedScores.length; position++) {
                Letter expected = expectedScores[position];
                assertThat(score.letter(position)).isEqualTo(expected);
            }
        }
    }

    这似乎是一组全面的测试用例。每一行生产代码都是通过添加新测试以探索新行为方面直接驱动的结果。测试代码似乎易于阅读,生产代码也似乎清晰实现且易于调用。测试形成了对单词猜测评分规则的可执行规范。

    这实现了我们在编码会话开始时设定的所有目标。我们使用 TDD 扩展了 Score 类的能力。我们遵循 RGR 循环,使我们的测试代码和生产代码都遵循良好的工程实践。我们拥有由单元测试验证的健壮代码,以及使此代码易于从更广泛的应用程序中调用的设计。