"设计、测试、开发、测试" 替代 "测试驱动开发" (DTDT 优于 TDD)






4.69/5 (18投票s)
测试驱动开发 (Test Driven Development) 唯一的好处是它确保了最高的测试覆盖率,但 TDD 会让你冒着设计薄弱的风险。DTDT 试图在确保最高测试覆盖率的同时解决这个问题。
引言
在现代软件行业,测试驱动开发 (Test Driven Development) 或 TDD 是一个热门词汇。在大多数关于软件开发的招聘广告中,你都会看到 TDD 知识或经验是必备的或加分项。但是,现实中有多少应用程序是按照所谓的 TDD 模型开发的呢?我绝不是反对编写测试。我毫无疑问地认为,拥有尽可能多的测试可以提高应用程序的质量,降低 bug 的数量,减少 bug 重复出现的次数,并缩短开发和维护应用程序的最终时间。但是,如果你按照 TDD 的定义来实践测试驱动开发,它有多现实呢?让我们先来看看 TDD 在理论上到底意味着什么。
什么是 TDD?
[1] 测试驱动开发 (Test-Driven Development, TDD) 是一种构建软件的技术,它通过编写测试来指导软件开发。它是由 Kent Beck 在 20 世纪 90 年代末作为极限编程 (Extreme Programming) 的一部分开发的。本质上,你只需反复遵循三个简单的步骤:
- 为你想要添加的下一部分功能编写一个测试。
- 编写功能代码,直到测试通过。
- 重构新旧代码,使其结构良好。
让我们尝试用一张图来展示这个过程:
在这里,你可以看到一张传统的测试驱动开发图(图 1)。
图 1:TDD 的传统图
你可以看到,在测试驱动开发中,你基本上有一个 3 步循环。首先,你为你想要开发的功能编写一个测试。由于你还没有开发该功能,测试会失败。然后,你编写最少的代码来使测试通过。然后,你重构你的代码,使其以结构良好的格式实现所需的功能。当你重构代码时,你必须确保在重构结束时,测试仍然通过。此外,你还必须确保项目中可能存在的其他所有测试也仍然通过。
让我们尝试用流程图的形式展示一个更详细的图:
图 2:TDD 详细图
在图 2 中,你可以看到在 TDD 循环中,你总是检查你期望的单元功能实现是否已完成。那么,你如何定义“完成”呢?“完成”的定义是什么?这个定义因开发者而异。一些懒惰的开发者会以完成任务为目标,而不太关注可扩展性、可维护性或可伸缩性。一些普通的开发者会关注结构化开发,但可能缺乏技巧。一些开发者可能会过分迷恋设计。因此,每个开发者都会根据自己的决策模式来定义“完成”这个词。是的,在某个时刻,每个开发者都会处于同一个位置。也就是说,单元功能将被开发出来并且可以工作。但是,当涉及到“结构化”代码的问题时,就会出现差异。如果你不够幸运,你可能会得到一个大泥球 (BBOM)。
是的,理论上,在 TDD 循环中进行重构时,你会关注设计和架构,并完美地重构一切,使你的代码可扩展且可维护。但现实中有多少你能做到?当你只关注单元功能并死守“保持简单愚蠢 (KISS 原则)”时,很可能你没有获得应用程序的领域知识,并且从不为未来的扩展做任何事情。在许多情况下,你甚至在不知道谁将使用该函数以及如何使用该函数的情况下就开发了一个函数。另一方面,由于你喜欢 YAGNI (你不需要它) 原则,所以你不会添加任何你此时此刻不需要的东西。因此,KISS 和 YAGNI 在那一刻让你生活轻松,但随着项目的不断发展,重构的量会增加,变得困难,并且也变得有风险。在许多情况下,在重构阶段,你还需要重构你之前编写的、已经通过的测试。这是因为你正在尝试通过重构来实现良好的设计!如果是这样,那么在编写代码之前编写测试有什么意义呢?一个论点是,先编写测试可以确保每一行代码都被测试覆盖。好吧,也许这是真的,但我们应该接受为了提高测试覆盖率而牺牲应用程序良好架构的权衡吗?我说是的!我不想牺牲软件的架构,因为这可能会过早地扼杀我的软件。那么,测试覆盖率呢?是的,我也想实现最高的测试覆盖率。在本文的后面,我将尝试阐述我的思考方式来解决这个问题。
为什么 TDD 可能无法产生良好的设计?
让我们总结一下为什么在现实中,无论理论如何说,TDD 都不是实现应用程序卓越设计或架构的好方法:
- 在 TDD 团队中,我看不到架构师这个角色。也许是我孤陋寡闻,但你看到了吗?
- 通过重构实现结构化代码并不太现实。因为它很大程度上取决于开发者,甚至对同一个开发者来说,也因时间而异。
- TDD 对开发者没有任何设计目标。因此,最有可能发生违反标准设计原则的情况。
- 如果你想通过 TDD 实现良好的设计,你可能需要不时地重构你的应用程序的基础设施,这总是存在风险。
- 当你的项目增长时,重构的成本会很高,你的客户可能不同意为此付费。
一场足球队的类比
想象一支足球队。足球队只有两个目标:进球和阻止对手进球。因此,根据这个目标,每个球员都可以开始比赛,追逐球。因为拿到球意味着你可以尝试进球,对手也无法进球。但是,当对手突然拿到球并传给前锋时,你会发现没有人去防守。因为每个人都在追逐球。此时,一些球员开始奔跑,等到他们到达对手前锋附近时,球已经进门了。这就是为什么足球队总是有比赛计划,并且他们设计战术时会以计划为目标。最成功的足球队总是有伟大的教练来设计比赛,也有伟大的球员来执行设计。当你有一个伟大的教练时,所有球员都会受到激励,他们也能在场上立即做出决定,从而使最终的计划成功。
图 3:足球的 4-4-2 阵型
足球的类比只是一个说服你进行预先设计的例子。你看,如果你在比赛中没有计划,突然丢球了,即使像尤塞恩·博尔特那样奔跑也救不了你。同样,我相信如果你没有项目的计划架构,无论你付出多少重构的努力,在某个时候你都会面临失败的风险。
软件开发的类比
现在我将尝试从软件开发世界中提供一个类比。假设你要开发一个 HR 管理应用程序。规格已经写好,整个应用程序被分解成一些用户故事。你开始项目,并被分配开发一个需要保存员工的用户故事。所以,根据 TDD 的极端主义者,你首先开始编写测试。请注意,你一开始在项目中没有任何东西。你只有一个空白的解决方案。那么你现在该怎么做呢?既然你需要先编写测试。你必须创建一个测试项目。现在的问题是你要测试什么?你会测试“Employee”对象的创建,以检查是否有业务规则或验证被违反?你会检查是否可以从业务层保存一个有效的员工?好吧,你的项目还没有任何业务层基础设施。在你的第一个测试中,你将检查对象的创建。所以,编写测试,使其失败,然后创建 Employee 类,最终使测试通过。同样,你将为 Employee 的 save 方法创建另一个测试,从而创建 EmployeeManager 类,实现 save 方法,这将使你的测试通过。在创建 Save 方法的测试时,你还决定需要一个数据访问方法将对象保存到数据库。此时,你发现你需要模拟 (Fake) DataAccess 方法。所以,你创建了一个继承自名为 IEmployeeDataAccess 接口的 EmployeeDataAccess 类。
现在我有一些问题:
- 你的测试写在哪里?你是在同一个类中编写两个测试吗?
- Employee 类在哪里创建的?
- EmployeeManager 类在哪里创建的?
- IEmployeeDataAccess 接口和 EmployeeDataAccess 类在哪里创建的?
如果你的答案显示了以下项目结构(图 4),我没有理由责怪你。因为你保持了简单(KISS),并且你没有做任何你此刻不需要的事情(YAGNI)。
图 4:HR 管理项目结构
但是,也许你不会这样做,因为你已经有了多层项目架构的概念,并且你知道应该为你的 Models、DataAccess 和 Business 设置不同的层。因此,当你开始按照极端的 TDD 进行开发时,也就是说,在没有任何预先架构的情况下,你的代码结构取决于你的技能和思维能力。如果你对分层架构一无所知,你可能会得到上述项目结构。如果你对软件设计原则一无所知,无论你重构多少,你都不会得到一个最优的设计。你明白原因吗?原因是你没有被强制遵循一种开发模式,因为没有创建预先架构。
由于团队中必然会有各种各样的开发者。新手或有经验的,有才华的或不聪明的,专家或新手,勤奋的或懒惰的等等。你真的愿意冒着允许每个开发者按照 TDD 的建议进行重构的风险吗?
看,如果 5 个人从不同的地方开始跑步,并且他们都知道同一个目的地,无论他们以什么速度跑,选择什么路径,他们都会到达目的地。如果目的地之前定义了里程碑,他们必须经过,那么他们都会从一开始就沿着正确的路径跑。但是,如果他们没有定义目的地,没有定义里程碑,他们会到达哪里?哪里也去不了。所以,先设定你的目的地和里程碑不是更好吗?
让我们找一个解决方案。
我将要谈论的解决方案并不新鲜。无论人们如何赞扬 TDD,我认为没有任何项目能够成功地进行极端的测试驱动开发实践。因此,我们必须做一些事情,既能为项目构建一个优秀的架构,又能保持大量的单元测试覆盖率。
总而言之,你需要遵循以下步骤:
- 你来做设计!
- 你写最少的测试代码!你强行让它失败!
- 你来开发!
- 你完全实现测试。现在,如果你已经正确开发,你的测试就会通过。
所以,我们可以简称为 DTDT
(Design àTestàDevelopmentàTest)。它应该是一个简单的循环,如下面的图所示。
现在,我将尝试详细阐述这个过程。
设计、测试、开发、测试 (DTDT)
在我看来,你的第一个冲刺 (sprint) 永远不应该实现任何一个用户故事。它应该专注于设计项目的架构。是的,我同意架构是不断发展的。我同意你不应该基于没有证据支持的假设来创建任何东西。我也同意在一个敏捷框架中,你不能在第一个冲刺中为你的项目创建庞大的基础设施。但是,你必须同意,项目绝对不是从第一个冲刺的开发开始的。它早就开始了。它经历了信息分析、业务分析、需求分析等。因此,在第一个冲刺开始设计并开始质疑项目时,你肯定会从参与阶段获得很多知识。所以,你当然可以为应用程序创建一个最小的 领域模型,也可以创建一个最小的应用程序基础设施。是的,你会保持简单,但我不赞同保持愚蠢。所以,最好从 KISS 原则中去掉一个“S”。是的,你遵循 YAGNI,但不要过于僵化。因为,你认为几天后必须用到的接口,当然可以创建。没有理由把它抛在后面,因为它“现在”不需要。
因此,在项目开始时,你创建最小的基础设施并创建域的最小模型。这意味着,第一个冲刺用于架构,并且你已经有了一个最小的设计来开始开发用户故事。
现在,你将按照以下方式使用 DTDT
开始处理第一个用户故事:
步骤 1 - 设计
- 你确保你完美地理解了用户故事。
- 你确保你了解该功能将如何以及为何被使用。
- 你找到实现该功能所需的所有对象。
- 你查看你的解决方案,看看是否有任何对象已经创建。
- 你尝试找出你将要处理的对象是否与其他系统对象有任何关系。
- 你决定需要创建哪些接口。以及这些接口应该有哪些方法和属性。
- 当你创建接口、类和方法定义时,你确保遵循 SOLID 设计原则。
- 请记住,在此步骤中,你不会实现任何方法。你只是查找或创建所需的接口、类,并定义方法的签名。
所以,你已经完成了设计步骤。现在你更有信心了,你知道:
- 你到底要做什么。
- 你需要处理哪些类和接口。
- 应该在什么地方编写什么代码。
- 你为未来保留了哪些扩展点。
- 该功能如何与整个系统契合。
由于你已经在第一个迭代中创建了基础设施,你现在将被迫在最小的范围内行事,你不能再按照自己的模式进行开发。
例如,如果你现在想构建“Employee Save”用户故事,你将这样做:
- 你检查是否已经为 Employee 创建了实体。
- 如果没有,你考虑是否还可能存在其他类型的人力资源。
- 也许你会提出另一种类型的人力资源,称为“Consultant”。
- 所以,你意识到你需要一个名为“
IHumanResource
”的接口。 - 由于你已经拥有最小的预设基础设施,你将被迫从像
IEntity
接口这样的东西派生这个IHumanResource
接口。 - 现在,你创建从
IHumanResource
派生的IEmployee
接口。 - 将来当你需要
IConsultant
接口时,你也可以从IHumanResource
派生它。 - 所以,当你创建从
IEmployee
接口派生的Employee
类时,它的层次结构将是正确的。 - 稍后,你创建
IEmployeeManager
(Business Layer) 接口来处理 Employee。 - 但是当你创建
IEmployeeManager
接口时,你的基础设施将强制你从IManager
接口派生它。 - 同样,当你创建
IEmployeeRepository
接口时,你必须从IRepository
派生它。 - 你无法违反设计。因为你的预设基础设施将确保只有派生自
IManager
的 Manager 接口才能与派生自IRepository
的存储库一起工作。 - 同样,派生自
IRepository
的 Repository 也只能与派生自IEntity
的实体一起工作。 - 因此,预设设计迫使你以标准的方式设计和开发你的工作。
接下来,DTDT
有两个编写测试的步骤。现在,我将谈论第一个测试编写步骤。
步骤 2 - 测试
- 在设计阶段,你已经对用户故事进行了大量的分析。所以你已经知道了工作的最大细节。
- 在此步骤中,你找出构成完整用户故事的原子工作单元。
- 你为所有原子单元编写单元测试。
- 不,我并不是说你必须在这个阶段编写完整的函数测试。你至少创建测试的定义,并可能通过创建虚假的断言来强制所有测试方法失败。
所以,现在你的设计已经准备好了。你还定义了完成工作后必须通过的测试。目前所有测试都失败了。这意味着,你现在可以进行开发了,因为你不会冒着错过任何测试的风险。
步骤 3 - 开发
- 在此步骤中,你开始实现你的用户故事。你已经有了设计,并且也定义了原子单元测试。
- 你已经定义的设计和单元测试将指导你以正确的方式开发你的功能。
- 完成开发后,你将进入第二个测试编写步骤。
步骤 4 - 测试
- 在此步骤中,你将逐个实现所有测试函数。由于你现在已经实现了用户故事的所有方法,一旦你完美地完成了测试方法的实现,它就会通过,除非你的实现有误。
- 完成所有测试函数的编码并且它们都通过后,你就完成了实现。
因此,在这里我试图找到一个解决方案,你可以从中获得以下好处:
- 你进行分析以得出良好的设计。因此,你很可能能够对你的工作提出重要问题,而这些问题在你为了编写测试而分析工作单元时可能会被忽略。
- 当你从更广泛的角度进行分析,然后再去寻找工作单元时,遗漏的可能性更小。
- 在此方法中,你首先编写所有最初会失败的测试。因此,没有遗漏任何测试的可能性。
- 当你进行开发时,你更加关注设计和需求,因此你的代码看起来会更好。
- 最后,当你实现所有测试方法并让它们通过后,你就实现了 100% 的测试覆盖率。
这意味着,在 DTDT
中,你实现了以下所有目标:
- 良好的设计!
- 出色的代码!!
- 测试覆盖率!!!
结论
正如我之前所说,在这篇文章中,我实际上并没有说任何新东西。我所做的,是形式化大多数软件项目在目标是包含单元测试的情况下所经历的实践。单元测试实际上是对抗 bug 和重复 bug 的有力武器。因此,毫无疑问,你的项目中的单元测试覆盖率越高,产出的质量就越高。但极端 TDD 可能不是最佳的进行方式。因为你的开发必须是设计驱动的,而不是其他任何驱动的。单元测试会存在于你的代码中,以确认你的工作永远不会中断。我们不应该是极端的 TDD 实践者。我们应该找到一个折衷点,这样设计就不会受到损害。它可以是 DTDT,也可以是任何你认为正确的方法。