TDD时如何设计中大型应用程序?

16

我对单元测试、DI、mock和所有设计原则都有很好的掌握,以尽可能地实现代码全覆盖(例如单一责任原则,编程时考虑“如何测试这个”等)。

在我的最近一个应用程序中,我没有真正使用TDD进行编码。我想到了单元测试并在编码后编写了我的测试、重构等等。当时我只在易于使用TDD的情况下采用了TDD,而且我对此并不像现在这样精通。那是我完全利用DI、mocking框架等的第一个项目,也是第一个具有完整代码覆盖率的项目,我在其中学到了很多。我渴望分配到我的下一个项目,这样我就可以完全从头开始使用TDD进行编码。

我知道这是一个广泛的问题,我已经订购了《通过示例学习TDD》和《XP大解密》,但我希望能简要了解如何在TDD下设计/编写大型应用程序。

您是否编写整个应用程序,仅使用存根代码?(例如,编写所有函数签名、接口、结构,并编写整个应用程序,但不编写任何实际实现)?我可以想象它在小到中等规模的应用程序上运作,但在大型应用程序上是否可行?

如果不是这样,那么您将如何为系统中最高级别的函数编写第一个单元测试?例如,在公开了名为DoSomethingComplicated(param1、...、param6)的函数的Web服务上。显然,对于像AddNumbers()这样简单的函数首先编写测试很容易,但当函数位于调用堆栈的顶部时该怎么办?

你还在做前期设计吗?

显然,你仍然想要进行“架构”设计——例如,绘制流程图,显示IE与IIS之间通过WCF连接到Windows服务,以及与SQL数据库交互的ERD(实体关系图),等等。但是类设计呢?类之间的交互等等,你会提前进行设计,还是只是不断编写存根代码,随着工作的进行重构交互,直到整个系统连接起来并且看起来可以工作为止?

非常感谢您的任何建议。

4个回答

20
  • 你们是否会先进行设计?

当然会。在编写测试和代码之前,您必须对大型应用程序的结构有一些了解。不需要详细地解决所有问题,但是应该对层、组件和接口有一些基本的了解。例如,如果您正在开发Web服务系统,则应该知道顶级服务是什么,并且对其签名有一个良好的初步估计。

  • 您是否只使用存根代码来编写整个应用程序?

不是的。只有当某些内容很难通过测试进行控制时,才会使用存根。例如,我喜欢使用存根来代替数据库和用户界面。我也会使用存根来代替第三方接口。有时,如果某个组件大大增加了测试时间,或者它强迫我创建过于复杂的测试数据,我也会使用存根来代替其中一个自己的组件。但是大多数时候,我让我的测试在一个相当完整的系统上运行。

我必须说,我真的不喜欢依赖于模拟和存根的测试方式。别误会,我认为模拟和存根非常有用,可以从难以测试的内容中分离出来。但是我不喜欢编写难以测试的代码,因此我不使用太多的模拟和存根。

  • 您如何为高级功能编写第一个单元测试?

大多数高级功能都有退化行为。例如,登录是一个相当高级的功能,可能非常复杂。但是如果您尝试使用没有用户名和密码的用户进行登录,则系统的响应将非常简单。编写这些测试也将非常简单。因此,您可以从退化情况开始。一旦您筛选出了所有退化情况,就可以逐步提升复杂度。例如,如果用户尝试使用用户名但没有密码进行登录会发生什么?一步一步地攀登复杂性的阶梯,直到较不复杂的部分全部通过为止。

这种策略的效果非常显著。你可能会认为你只能一直在边缘上爬来爬去,从未到达核心部分;但实际情况并非如此。相反,你会发现自己基于所有退化和特殊情况设计代码的内部结构。当你最终开始主要流程时,你会发现正在处理的代码结构有一个恰好适合插入主流程的漂亮空间。

  • 请不要先创建用户界面(UI)。

UI是具有误导性的东西,它使你关注系统的错误方面。相反,想象一下你的系统必须具有许多不同的UI,其中一些将是Web、一些将是厚客户端,一些将是纯文本。设计你的系统能够独立于UI正确运行。首先让所有业务规则都能正确运行,所有测试都能通过。然后再稍后添加UI。我知道这与许多传统智慧背道而驰,但我不会用其他方式做。

  • 请不要先设计数据库。

数据库是细节问题,将细节问题留到最后。相反,设计你的系统,仿佛你不知道你使用的是什么类型的数据库,将模式(schema)、表格(table)、行(row)和列(column)的任何概念都排除在系统核心之外。将你的业务规则实现得好像所有数据都一直保存在内存中。然后在成功让所有业务规则工作后再添加数据库。同样地,我知道这与一些传统智慧背道而驰,但过早地将系统与数据库耦合是许多糟糕设计的根源。


1
请不要首先创建您的UI。我非常不同意。没有(静态)UI,您可能只是编写错误的内容。另一篇好文章:http://www.codinghorror.com/blog/2008/04/ui-first-software-development.html - Łukasz Wiatrak
另一个好的答案:https://dev59.com/YEnSa4cB1Zd3GeqPQsKq - Łukasz Wiatrak

12

我需要用只有桩代码的方式编写整个应用程序吗?

绝不需要 - 那听起来是一种非常浪费的方法。我们必须始终牢记进行TDD的根本原因是为了快速反馈。自动化测试套件可以比手动测试更快地告诉我们是否出现了问题。如果我们等到最后一刻才把所有东西连在一起,我们就无法得到快速反馈 - 尽管我们可能会从单元测试中获得快速反馈,但我们不会知道应用程序能否作为一个整体工作。 单元测试只是我们需要执行的验证应用程序的一种形式。

更好的方法是从最重要的功能开始,使用外部向内的方法逐步完成。这通常意味着从一些UI开始。

我的做法是创建所需的UI。由于我们通常不能使用TDD开发UI,我只是用选择的技术创建视图。没有测试,但我将UI与某些API连接起来(最好使用声明性数据绑定),然后开始测试。

在开始时,我会用TDD编写 ViewModel / Presentation Model 和相应的控制器,并可能硬编码一些响应以确保UI正常工作。 只要拥有可以运行的东西,我就会提交代码(请记住,进行许多小的增量提交)。

我随后沿着该功能向下垂直地工作,并确保该特定UI片段可以一直到数据源(或其他任何东西),忽略所有其他特性。

完成该功能后,我可以开始下一个功能。 我将此过程描述为通过逐个垂直切片填充应用程序,直到完成所有功能。

用这种方式启动一个全新的应用程序总是需要额外 长时间来完成第一个功能,因为这是您必须连接所有东西的地方,所以选择一些简单的事情(如应用程序的初始视图)使事情尽可能简单。 一旦完成了第一个功能,接下来的功能就会变得更容易,因为现在已经有了基础。

我是否仍然需要事先设计?

不是很需要。通常在开始之前,我会有一个整体的设计想法,并且在团队合作时,我们会在白板或幻灯片上勾画这个整体架构。

这主要包括以下内容:

  • 层的数量和名称(UI、表现逻辑、领域模型、数据访问等)
  • 使用的技术(WPF、ASP.NET MVC、SQL Server、.NET 3.5 或其他技术)
  • 如何构建生产代码和测试代码,以及使用哪些测试技术
  • 代码质量要求(配对编程、静态代码分析、编码标准等)

其余的内容我们边做边想,但是我们经常在白板前进行临时设计会议。


感谢您提供如此详细的回复。听起来很像我编写上一个应用程序的方式...(除了在大多数地方我“作弊”没有先编写测试)。也许从单元测试到TDD的转变并没有像我想象的那样改变我的设计和架构方式...所以我必须问一下 - 您是否非常严格地遵循先编写测试的原则?是否发现在某些情况下,先编写代码再编写测试更容易?如果是这样,这种情况有多常见?在哪些具体的模式/情况下会出现? - dferraro
2
我在测试驱动开发方面非常严格,因为我认为“红/绿/重构”中的红色阶段非常重要。我经常写一个测试,结果立即变成绿色,尽管本意是相反的。这意味着测试并没有测试我原以为的内容,所以必须重写。这种情况我大约每天都会遇到一次,但是如果事后编写测试的话,就没有这种保障了。极少数情况下,我会进行未经测试的尝试,但当发生这种情况时,我完成后会删除掉试验代码,然后根据我的试验经验进行正确实现的测试驱动开发。 - Mark Seemann

1

+1 好问题

我确实不知道答案,但我会从构建类的基本块开始测试,然后将其构建到应用程序中,而不是从顶层开始。是的,我会有一个接口的初步设计草图,否则,当您重构时,您会发现这些接口经常更改,这将是一个真正的阻碍。

我不认为《通过示例学 TDD》会有所帮助。如果我没记错的话,它会通过一个简单的示例来演示。我正在阅读 Roy Osherove 的《单元测试之道》,虽然它似乎全面涵盖了工具和技术,如模拟和存根,但目前的示例也相当简单,我没有看到它告诉您如何处理大型项目。


1
  • 你是否只使用桩代码编写整个应用程序?

为了测试我们的系统,我们主要进行单元测试、集成测试和远程服务测试。在单元测试中,我们会将所有长时间运行、耗时和外部服务(如数据库操作、Web 服务连接或任何与外部服务的连接)都替换为桩代码。这是为了确保我们的测试快速、独立,并且不依赖于任何外部服务的响应来提供快速反馈。我们通过艰苦的实践学到了这一点,因为我们确实有一些测试需要进行数据库操作,这使得测试变得非常缓慢,违反了“单元测试必须快速运行”的原则。

在集成测试中,我们测试数据库操作,但仍然不测试 Web 服务和外部服务,因为这可能会使测试变得脆弱,取决于它们的可用性,我们使用自动测试在后台运行测试,同时进行编码。

然而,为了测试任何类型的远程服务,我们有连接到外部服务的测试,在其上执行操作并获取响应。对于测试来说,重要的是它们的响应和最终状态(如果对测试很重要)。这里重要的是,我们将这些类型的测试存储在另一个名为remote的目录中(这是我们创建并遵循的约定),并且当我们将任何代码合并到主/分支并将其推送/提交到存储库时,这些远程测试仅由我们的CI(持续集成)服务器运行,以便我们快速知道是否有任何更改会影响我们的应用程序中的这些外部服务。

  • 我仍然需要事先设计吗?

是的,但我们基本上不进行大型的事先设计,就像Robert C. Martin所说的那样。此外,我们在沉浸自己编码之前去白板,创建一些类协作图,只是为了明确并确保团队中的每个人都处于同一页面,这也可以帮助我们将工作分配给团队成员。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接