揭示设计缺陷
糟糕的设计确实是糟糕的。它是软件难以更改和难以使用的根本原因。你永远无法完全确定你的更改是否会奏效,因为你永远无法完全确定糟糕的设计到底在做什么。更改这种代码是令人恐惧的,通常会被推迟。整段代码可能会被遗弃,只留下一个 /* 这里有龙!*/
的注释作为标记。
TDD 的第一个主要好处是它迫使我们思考组件的设计。我们在考虑如何实现它之前先考虑设计。通过按这种顺序做事,我们不太可能意外地陷入糟糕的设计。
我们首先考虑设计的方式是思考组件的公共接口。我们思考该组件将如何被使用以及如何被调用。我们还不考虑如何使任何实现实际工作。这是 由外而内 的思考方式。我们在考虑内部实现之前,先考虑外部调用者对代码的使用。
这对我们中的许多人来说是一种截然不同的方法。通常,当我们需要代码做某事时,我们从编写实现开始。之后,我们会根据需要调整方法签名,而不考虑调用点。这是 由内而外 的思考方式。当然,它有效,但它通常会导致复杂的调用代码。它将我们锁定在不重要的实现细节中。
由外而内的思考意味着我们可以为使用者设计出完美的组件。然后,我们将调整实现以使其在调用点与我们的理想代码配合使用。最终,这比实现重要得多。当然,这是抽象在实际中的应用。
我们可以提出以下问题:
-
它是否易于设置?
-
是否易于要求它做某事?
-
结果是否易于处理?
-
是否难以以错误的方式使用它?
-
我们是否对它做出了错误的假设?
你可以看到,通过提出正确的问题,我们将得到正确的结果。
通过先编写测试,我们涵盖了所有这些问题。我们预先决定如何设置我们的组件,可能是为对象设计一个清晰的构造函数签名。我们决定调用代码的外观以及调用点是什么。我们决定如何处理返回的结果,或者对协作组件的影响是什么。
这是软件设计的核心。TDD 不会为我们做这些,也不会强迫我们做好工作。我们仍然可能为所有这些问题提出糟糕的答案,并简单地编写一个测试来锁定这些糟糕的答案。我在实际代码中也多次看到这种情况发生。
TDD 提供了早期反思我们决策的机会。在我们甚至考虑代码如何工作之前,我们实际上是在编写第一个可工作的、可执行的调用点示例。我们完全专注于这个新组件如何融入更大的图景。
测试本身提供了关于我们决策效果的即时反馈。它给出了三个明显的信号,表明我们可以并且应该改进。我们将在后面的章节中详细介绍细节,但测试代码本身清楚地显示了你的组件是否难以设置、难以调用或其输出难以处理。
分析在生产代码之前编写测试的好处
你可以选择在三个时间点编写测试:在编写代码之前、在编写代码之后,或者永远不编写。
显然,永远不编写任何测试会将我们带回开发的黑暗时代。我们在凭感觉行事。我们编写代码时假设它会工作,然后将其全部留到后期的手动测试阶段。如果我们幸运,我们将在客户之前在这个阶段发现功能错误。
在完成一小段代码后立即编写测试是一个更好的选择。我们获得更快的反馈。然而,我们的代码并不一定会更好,因为我们以与不编写测试时相同的心态编写代码。同样的功能错误仍然存在。好消息是,我们随后会编写测试来发现它们。
这是一个很大的改进,但它仍然不是黄金标准,因为它会导致一些微妙的问题:
-
遗漏测试
-
泄漏的抽象
遗漏测试——未检测到的错误
遗漏测试是由于人性造成的。当我们忙于编写代码时,我们同时在脑海中处理许多想法。我们专注于某些细节,而忽略了其他细节。我总是发现自己在编写一行代码后,心理上会过快地转移注意力。我只是假设它会没问题。不幸的是,当我开始编写测试时,这意味着我已经忘记了一些关键点。
假设我最终编写了如下代码:
public boolean isAllowed18PlusProducts(Integer age) {
return (age != null) && age.intValue() > 18;
}
我可能很快从 > 18
检查开始,然后在心理上转移注意力,并记住 age
可能为 null
。我会添加 And
子句来检查它是否为 null
。这很合理。我的经验告诉我,这段代码需要做的不仅仅是一个基本的、健壮的检查。
当我编写测试时,我会记得为传入 null
时的情况编写测试,因为这是我脑海中新鲜的记忆。然后,我会为较高的年龄(例如 21 岁)编写另一个测试。同样,这很好。但我很可能会忘记为年龄值为 18 的边缘情况编写测试。这在这里非常重要,但我的思维已经转移到了其他细节上。只需要同事在 Slack 上发一条关于午餐的消息,我就很可能会忘记这个测试,并开始编写下一个方法。
前面的代码中有一个微妙的错误。它应该为 18 岁及以上的任何年龄返回 true
,但它没有。它只对 19 岁及以上返回 true
。大于号应该是大于或等于号,但我错过了这个细节。
我不仅错过了代码中的细微差别,还遗漏了一个关键的测试。我编写了两个重要的测试,但我需要三个。
因为我编写了其他测试,所以我完全没有收到关于这个问题的警告。你没有编写测试,就不会得到失败的测试。
我们可以通过为每一段代码编写一个失败的测试来避免这种情况,然后只添加足够的代码使该测试通过。这种工作流程更有可能引导我们思考所需的四个测试,以处理 null
值和与年龄相关的三个边界情况。当然,它不能保证这一点,但它可以引导正确的思维方式。
泄漏的抽象——暴露无关的细节
泄漏的抽象是另一个问题。这是我们过于关注方法内部,而忘记了思考理想的调用点。我们只是简单地推出最容易编码的内容。
我们可能正在编写一个存储 UserProfile
对象的接口。我们可能会先编写代码,选择一个我们喜欢的 JDBC 库,编写方法,然后发现它需要一个数据库连接。
我们可能会简单地添加一个 Connection
参数来解决这个问题:
interface StoredUserProfiles {
UserProfile load(Connection conn, int userId);
}
乍一看,这没什么问题。然而,看看第一个参数:它是 JDBC 特定的 Connection
对象。我们已经将接口锁定为必须使用 JDBC。或者至少,必须提供一些与 JDBC 相关的东西作为第一个参数。我们甚至没有意识到这一点。我们只是没有彻底思考它。
如果我们考虑理想的抽象,它应该为给定的 userId
加载相应的 UserProfile
对象。它不应该知道它是如何存储的。JDBC 特定的 Connection
参数不应该存在。
如果我们从外向内思考,并在实现之前考虑设计,我们就不太可能走上这条路。
像这样的泄漏抽象会创建意外的复杂性。它们使代码更难理解,迫使未来的读者想知道为什么我们在从未打算使用 JDBC 的情况下坚持使用它。我们只是忘记了设计它。
先编写测试有助于防止这种情况。它引导我们首先思考理想的抽象,以便我们可以为它们编写测试。
一旦我们编写了测试,我们就锁定了关于代码如何使用的决策。然后,我们可以弄清楚如何实现它,而不会泄漏任何不必要的细节。
前面解释的技术很简单,但涵盖了良好设计的大部分基础。使用清晰的名称、使用简单的逻辑、使用抽象来隐藏实现细节,以便我们强调我们正在解决的问题,而不是我们如何解决它。在下一节中,让我们回顾 TDD 最明显的好处:防止逻辑中的缺陷。