TDD - 初学者遇到的问题与障碍

23 投票
7 回答
2040 浏览
提问于 2025-04-15 18:02

虽然我为自己写的大部分代码都做了单元测试,但我最近才拿到Kent Beck的《通过实例学习测试驱动开发》这本书。我一直对自己做的一些设计决定感到后悔,因为这些决定让应用程序变得“不可测试”。我读了这本书,虽然有些内容让我感到陌生,但我觉得我能理解,于是决定在我当前的项目上试一试。这个项目基本上是一个客户端/服务器系统,两个部分通过USB进行通信,一个在设备上,另一个在主机上。这个应用程序是用Python写的。

我开始的时候,很快就陷入了一堆重写和小测试的麻烦中,后来我发现这些测试根本没有真正测试任何东西。我扔掉了大部分测试,现在有一个能正常工作的应用程序,所有的测试合并成了仅仅两个。

根据我的经验,我有几个问题想问。我从新手TDD:有没有示例应用程序和测试来展示如何做TDD?中获得了一些信息,但我还有一些具体的问题想要讨论。

  1. Kent Beck使用一个列表来指导开发过程,他会在上面添加和划掉内容。你是怎么做这样的列表的?我最开始有一些项目,比如“服务器应该启动”、“如果通道不可用,服务器应该中止”等等,但这些内容混在一起,最后现在变成了“客户端应该能够连接到服务器”(这已经包含了服务器启动等内容)。
  2. 你是怎么处理重写的?我最开始选择了一个基于命名管道的半双工系统,这样我可以在自己的机器上开发应用逻辑,然后再添加USB通信部分。后来它变成了基于套接字的东西,然后又从使用原始套接字转变为使用Python的SocketServer模块。每次事情变化时,我发现我不得不重写相当大一部分的测试,这让我很烦恼。我原以为测试在开发过程中会是一个相对不变的指导,但它们感觉就像是更多的代码需要处理。
  3. 我需要一个客户端和一个服务器通过通道进行通信,以测试任一方。我可以模拟一方来测试另一方,但那样整个通道就不会被测试,我担心会漏掉。这影响了整个红/绿/重构的节奏。这是缺乏经验,还是我做错了什么?
  4. “假装做到”让我留下了很多混乱的代码,后来我花了很多时间去重构和清理。这就是事情的运作方式吗?
  5. 在这次开发结束时,我的客户端和服务器运行正常,大约有3到4个单元测试。我花了大约一周的时间来完成它。我觉得如果我采用代码后再写单元测试的方法,可能一天就能搞定。我看不出有什么好处。

我希望能从那些完全(或几乎完全)使用这种方法实施大型非平凡项目的人那里获得评论和建议。在我已经有东西运行并想添加新功能之后,遵循这种方式对我来说是有意义的,但从头开始似乎太累人了,不值得付出这样的努力。

附言:如果这应该是社区维基,请告诉我,我会标记成那样。

更新0:所有的回答都同样有帮助。我选择了这个回答,因为它与我的经历最为契合。

更新1:多练习,多练习,再多练习!

7 个回答

3

问:Kent Beck使用一个列表来指导开发过程,他会在上面添加和划掉内容。你是怎么制作这样的列表的?我最开始有一些项目,比如“服务器应该启动”,“如果通道不可用,服务器应该中止”等等,但这些内容混在一起,最后现在只剩下“客户端应该能够连接到服务器”这样的内容(这已经包含了服务器启动等)。

我开始时会选择任何我可能会检查的内容。在你的例子中,你选择了“服务器启动”。

Server starts

接下来,我会寻找任何更简单的测试。我会选择一些变化少、部件少的内容。例如,我可能会考虑“服务器配置正确”。

Configured server correctly
Server starts

不过,实际上“服务器启动”是依赖于“服务器配置正确”的,所以我会把这个关系弄清楚。

Configured server correctly
Server starts if configured correctly

然后我会考虑可能出现的变化。我会问:“可能会出什么问题?”我可能会错误地配置服务器。那有多少种不同的错误方式呢?每一种都会成为一个测试。即使我配置正确,服务器也可能因为其他原因而无法启动。每种情况都会成为一个测试。

问:你是怎么处理重写的?我最开始选择了一个基于命名管道的半双工系统,这样我可以在自己的机器上开发应用逻辑,然后再添加USB通信部分。后来它变成了基于套接字的东西,然后又从使用原始套接字转变为使用Python的SocketServer模块。每次变化时,我发现必须重写相当大一部分的测试,这让我很烦恼。我原以为测试在开发过程中会是一个相对不变的指导,但它们感觉只是更多的代码需要处理。

当我改变行为时,我觉得改变测试是合理的,甚至可以先改变测试!不过,如果我必须修改那些并不直接检查我正在改变的行为的测试,那就说明我的测试依赖于太多不同的行为。这些就是集成测试,我认为它们是个骗局。(可以去谷歌搜索“集成测试是个骗局”)

问:我需要一个客户端和一个服务器通过通道进行通信,以测试任一方。我可以模拟一方来测试另一方,但那样整个通道就无法测试,我担心会漏掉。这影响了整个红/绿/重构的节奏。这是缺乏经验,还是我做错了什么?

如果我构建一个客户端、一个服务器和一个通道,那么我会尝试分别检查每一个。我从客户端开始,当我进行测试驱动开发时,我决定服务器和通道需要怎样的行为。然后我实现通道和服务器,使其符合我需要的行为。在检查客户端时,我会模拟通道;在检查服务器时,我会模拟通道;在检查通道时,我会同时模拟客户端和服务器。我希望这对你有帮助,因为我必须对这个客户端、服务器和通道的性质做出一些重要的假设。

问:“假装直到成功”让我留下了很多混乱的代码,后来我花了很多时间去重构和清理。这就是事情的运作方式吗?

如果你让你的“假装”代码变得非常混乱再去清理,那可能是你假装的时间太长了。不过,我发现即使我在测试驱动开发中最终清理了更多的代码,整体的节奏感觉要好得多。这是通过练习得来的。

问:在这次会议结束时,我的客户端和服务器运行了大约3到4个单元测试。花了我大约一周的时间。我觉得如果我使用代码驱动的方式,可能一天就能完成。我看不到有什么收获。

我必须说,除非你的客户端和服务器非常非常简单,否则你需要超过3或4个测试来彻底检查它们。我猜你的测试检查(或者至少执行)了许多不同的行为,这可能解释了你花了那么多时间来编写它们。

另外,不要去衡量学习曲线。我的第一次真正的测试驱动开发经历是将3个月的工作在9天内重写,每天14小时。我有125个测试,运行需要12分钟。我当时不知道自己在做什么,感觉很慢,但也很稳定,结果非常好。我基本上在3周内重写了原本需要3个月才能搞错的东西。如果我现在再写,可能只需要3-5天。区别是什么?我的测试套件会有500个测试,运行只需1-2秒。这是通过练习得来的。

8
  1. Kent Beck提到一个列表……最后变成了“客户端应该能连接到服务器”这样的简单描述(这也包含了服务器启动等内容)。

这通常是一种不好的做法。

为每个架构层单独测试是好的做法。

把测试合并在一起往往会掩盖架构上的问题。

不过,只需要测试公开的功能,不用测试每一个功能。

而且,不要花太多时间去优化你的测试。测试中的冗余并不会像在实际应用中那样造成太大伤害。如果有变化,某个测试通过了,但另一个测试失败了,那时候再考虑重构你的测试,而不是之前。

2. 你是怎么处理重写的?……我发现我不得不重写测试的很多部分。

你测试的细节层次太低了。应该测试最外层的、公开的、可见的接口。也就是那些应该保持不变的部分。

而且

是的,重大的架构变化意味着测试也要有重大变化。

还有

测试代码是你证明功能正常的方式。它几乎和应用本身一样重要。是的,这会增加代码量。是的,你必须管理它。

3. 我需要一个客户端和一个服务器通过通道进行通信来测试任一方。我可以模拟一方来测试另一方,但那样整个通道就无法测试……

这里有单元测试,使用模拟对象。

还有集成测试,测试整个系统。

不要混淆这两者。

你可以用单元测试工具来做集成测试,但它们是不同的东西。

而且你需要同时进行这两种测试。

4. “假装直到成功”让我留下了很多混乱的代码,后来我花了很多时间去重构和清理。这就是事情的运作方式吗?

是的,这正是事情的运作方式。从长远来看,有些人发现这种方法比一开始就拼命设计更有效。有些人不喜欢这种方式,想要一开始就做好所有设计;如果你愿意,可以提前做很多设计。

我发现重构是件好事,而提前设计太难了。也许是因为我编程快40年了,脑袋有点疲惫。

5. 我看不出有什么好处。

所有真正的天才都发现测试会拖慢他们的速度。

而我们其他人则无法确定我们的代码是否有效,直到我们有一整套测试来证明它有效。

如果你不需要证明你的代码有效,那你就不需要测试。

10

首先,想说的是,测试驱动开发(TDD)需要练习。当我回顾自己刚开始做TDD时写的测试,发现里面有很多问题,就像我回头看几年前写的代码一样。继续坚持下去,就像你开始能分辨好代码和坏代码一样,测试也会慢慢变得更好,只要有耐心。

你是怎么列出这样的清单的?我最开始有几个项目,比如“服务器应该启动”、“如果通道不可用,服务器应该中止”等等,但后来这些内容混在一起,现在变成了“客户端应该能够连接到服务器”这样的简单描述。

这个“清单”可以比较随意(在Beck的书中就是这样),但当你把这些项目变成测试时,尽量用“[当发生某事时],那么[这个条件应该成立]”的格式来写。这会迫使你更深入地思考你要验证的内容、如何验证它,并直接转化为测试。如果不能转化,说明你可能漏掉了某些功能。想想使用场景。例如,“服务器应该启动”这个说法就不够清晰,因为没有人发起这个动作。

每次事情发生变化时,我发现我不得不重写大量的测试,这让我很烦。原以为测试会在开发过程中是个相对不变的指南,但它们感觉就像是更多需要处理的代码。

没错,测试确实是更多的代码,并且需要维护——写出可维护的测试需要练习。我同意S. Lott的看法,如果你需要频繁修改测试,可能是因为你测试得“太深入”了。理想情况下,你应该在公共接口的层面进行测试,这个层面不太可能变化,而不是在实现细节的层面,这些可能会变化。但这个过程的一部分就是设计,所以你应该预期会有一些错误,并需要调整或重构你的测试。

我可以模拟一方来测试另一方,但这样整个通道就不会被测试,我担心会漏掉。

对此我不太确定。从你的描述来看,使用模拟是个好主意:模拟一方,测试另一方,确保每一方都能正常工作,前提是另一方的实现是正确的。测试整个系统的过程叫做集成测试,这也是你需要做的,但通常不属于TDD的过程。

“假装做到再说”让我留下了很多混乱的代码,后来我花了很多时间去重构和清理。这就是事情的运作方式吗?

在进行TDD时,你应该花很多时间去重构。另一方面,当你在“假装”时,那是暂时的,你接下来的步骤应该是“取消假装”。通常情况下,你不应该因为假装而让多个测试通过——你应该专注于一个部分,尽快进行重构。

我觉得如果我用单元测试的方式来写代码,可能一天就能完成。我看不出有什么好处。

再次强调,这需要练习,随着时间的推移你会变得更快。此外,有时候TDD的效果比其他时候更好。我发现,在某些情况下,当我确切知道想写什么代码时,直接写出一部分代码再写测试会更快。
除了Beck,我还喜欢的一本书是《单元测试的艺术》,作者是Roy Osherove。这本书不是关于TDD的,主要是针对.Net的,但你可以看看:书中有很多关于如何编写可维护测试、测试质量和相关问题的内容。我发现这本书与我写测试的经历产生了共鸣,有时我也会在这方面挣扎……
所以我的建议是,不要太快放弃,给自己一些时间。你也可以尝试一些简单的项目——测试服务器通信相关的内容听起来并不是最容易的入门项目!

撰写回答