代码测试意味着完全消灭了 Bug?
日前,一位名为 Jens Neuse 的开发者在改进其 graphql 解析库的过程中,发现词法分析器和解析器中存在很多的低效率,因此不得不重构完整的代码库(https://medium.com/@jens.neuse/want-to-write-good-unit-tests-in-go-dont-panic-or-should-you-ba3eb5bf4f51)。在重构的过程中,Jens Neuse 认为测试至关重要。然而,本文作者却并不这么想,他认为测试并不意味着一切,接下本文将以 Go 语言为例,分析其原因。
作者 | martin
译者 | 梁蕊
责编 | 屠敏
出品 | CSDN(ID:CSDNNews)
我使用过的一些最难用的代码是“易于测试”的代码。代码将所有内容抽象到开发者难以想象发生了什么的程度,只是为了向原本非常简单的函数中添加“单元测试”。DHH 称这种为测试引起的设计损坏。
测试只是确保用户的程序正常运行的工具之一。另外一种非常重要的工具是以一种易于理解和推理(简单)的方式编写代码。
在此,推荐开发者可以查阅一本使用广泛的测试书籍,Robert C.Martin 编写的《Clean Code》,其中部分内容是为了响应更复杂的代码而写的,在这些程序中,你阅读了 1000 行代码,但仍然不知道发生了什么。我最近不得不将一个简单的 Java “表情符号替代品”(:joy:→😂)移植到 Go。为了确保兼容性,我查看了它的实现类。这包含了一大堆类、工厂、以及所有这些只会导致在字符串上调用 regexp 的东西。
在像 Ruby 和 Python 这样的动态语言中,测试对于不同的前提很重要,就像下面这段代码将会正常工作:
if condition:
print('w00t')
else:
nonexistent_function()
当然,除了如果进入 else 分支,很容易会拼写错误的东西或者混合东西。
在 Go 语言中,这些问题都不那么令人担忧。Go 有一个静态类型系统,重点是可以编写简单直接的代码,易于理解。即使对于许多动态语言,也有可选的输入系统(Python 中的函数注释,JavaScript 的 TypeSript)。
有时你可以做一个简单的实现,而不牺牲任何可测试性;太棒了!但是有时你必须找到一个平衡点。对于某些代码,不添加单元测试是可以的。
对“单元测试”的过分关注可能会对代码库造成难以置信的损害。有些代码库有大量的单元测试,这使得任何更改都非常耗时,因为你要为哪怕是很小的更改而修复一大堆测试。很多时候,这些测试都是重复的;像简单的 CRUD,HTTP 端点的每一层添加一个测试是一个常见的示例。在许多应用程序中,只依赖一个集成测试就可以了。
像 SQL 模拟这样的东西是另一个很好的例子。它使代码更复杂,更难更改,所以可以说我们添加了一个“单元测试” select * from foo where x = ?。最糟糕的是,除了验证你没有错误的查询 SQL 查询之外,它甚至不测试任何其他内容。一旦测试开始做任何有用的事情,例如验证它实际上从数据库中返回正确的行,单元测试纯粹主义者开始抱怨它并不是真正的单元测试,你做错了。
对于大多数查询,集成测试和/或手动测试都是很好的,并且广泛的 SQL 模拟充其量是多余的,并且在最坏的情况下是有害的。
当然也有例外:如果你有很多的 if cond {q += “more sql”} 那么添加 SQL 模拟来验证逻辑的正确性可能是一个好主意。即使在那些情况下,”非单元的单元测试(例如,仅访问数据库的那个)仍然是可行的选择。集成测试也是一种选择。很多应用程序无论如何都没有那种复杂的查询。
关注单元测试的一个重要原因是确保测试代码能够快速运行。这是对需要一天运行的大规模测试工具的响应。这在 Go 中也不是一个真正的问题。我编写的所有集成测试都在合理的时间内运行(最多几秒,通常更快)。GO 1.10 中引入的测试缓存使它不再受关注。
去年,一位同事重构了我们基于 ETag 的缓存库。旧代码非常直接且易于理解,虽然我没有声称它一定没有 Bug,但它确实在很长一段时间内都运行良好。
它应该已经在适当的地方写了一些测试,但它没有(我没有写原始版本)。请注意,代码并非完全没有经过测试,因为我们确实进行了集成测试。
重构的版本要复杂得多。除了花了两周时间将一段工作代码重构成另一段工作代码(另一篇文章的主题)之外,我并不相信它实际上要好得多。我认为自己是一位有一定造诣且经验丰富的程序员,在 Go 中拥有合理的知识和经验。总的来说,根据同行和绩效评估的反馈,我至少是“平均”技能水平的程序员,如果不是更多的话。
如果一个普通的程序员因为有很多层的抽象而难以理解一些简单的函数的本质,那么一定是出现了问题。重构提供了一个工具用另一个测试用例来验证正确性(简单性)。简单性很难保证正确性,但单元测试也不是。理想情况下,我们应该两点都做到。
后记:重构引入了一个 Bug 并删除了一个有用的功能,但现在更难添加,至少因为代码要复杂得多。
所有单元正常工作都不能保证程序正常工作。很多逻辑错误都不会被捕获,因为逻辑由几个单元一起工作组成。所以你需要集成测试,如果集成测试重复了一半的单元测试,那么为什么还要为这些单元测试烦恼呢?
测试驱动开发(TDD)也只是一种工具。它可以很好的解决一些问题; 对其他人而言并非如此。特别是,我认为“被迫在小单元编写代码” 在某些情况下会非常有害。有些代码只是一个串行脚本,上面写着“执行此操作,然后执行此操作,然后执行此操作”。在一大堆“小单元”中拆分它可以大大减少代码理解的容易程度,因此更难以验证它是否正确。
我必须修复一些 Ruby 代码,其中所有东西都是小单元。在 Ruby 社区中有一种强大的 TDD 文化,尽管单元很容易理解,但我发现理解应用程序逻辑非常困难。如果所有内容都以“小单位”分割,那么理解所有内容如何组合在一起以创建一个有用的实际程序将会更加困难。
你可以看到旧微内核与单片内核争论相同的摩擦,或者更近期的微服务与单片应用程序之间的摩擦。在原则上把所有东西分成一个个小的部分听起来像一个伟大的想法,但在实践中事实证明,使所有的小零件一起工作是一个非常困难的问题。混合方法似乎最适合内核和应用程序设计,平衡两种方法的优点和缺点。我认为这同样适用于代码。
需要澄清的是,我并不是反对单元测试或 TDD,并且声称我们所有人都应该按照生活中的方式编写代码。我编写单元测试并在有意义的时候实践 TDD。我的观点是,单元测试和 TDD 不是最后一个问题的解决方案,他们不应该不加区别的使用。这就是为什么我频繁的使用诸如“some”和“often”之类的单词。
这让我想到了测试框架的主题。我从来没有理解像 goblin 这样的库正在解决什么问题。这怎么样:
Expect(err).To(nil)
Expect(out).To(test.wantOut)
对此有所改进?
if err != nil {
t.Fatal(err)
}
if out != tt.want {
t.Errorf("out: %q\nwant: %q", out, tt.want)
}
if 和==怎么了?为什么我们需要抽象呢?请注意,对于表驱动的测试中,您只需键入一次这些检查,因此您只需在此处保存几行。
Ginkgo 更糟糕。它变成了一个非常简单,直接且易于理解的代码片段,并且不仅仅是抽象的if,它还可以在几个不同的函数中完成执行(BeforeEach()和 DescribeTable())。
这称为行为驱动开发(BDD)。我不完全确定如何看待 BDD。我持怀疑态度,但我从来没有在一个大型项目中正确使用它,所以我犹豫不决是否放弃他。请注意,我说“正确”:大多数项目并不真正使用 BDD,他们只是使用带有 BDD 语法的库,并将其测试代码插入其中。那是特别的 BDD,或者说是伪 BDD。
无论 BDD 有什么优点,由于你的测试代码类似于 BDD 风格的语法,所以这些优点都不会显现出来。这本身就证明了 BDD 对许多项目来说可能不是一个好主意。
我认为这些 BDD(-ish)测试工具存在实际问题,因为它们混淆了你实际做的事情。无论如何,测试仍然是获取函数的输出并检查它是否符合你的预期。没有任何测试方法会改变这种基本原理。你添加的层越多,调试就越困难。
在确定某样东西是否“容易”时,我最关心的不是编写该东西是多么容易,而是当事情失败时调试是多么容易。如果这样可以让事情变得更容易调试,那么我很乐意花更多的精力写一些东西。
所有代码(包括测试代码)都可能以令人困惑,令人惊讶和意外的方式(“错误”)失败,然后你需要调试该代码。代码越复杂,调试起来就越困难。
程序员应该期望所有代码(包括测试代码)都要经历几个调试周期。请注意,对于调试周期,我并不是说“你需要修复的代码中存在错误”,而是“我需要查看此代码来修复错误”。
一般来说,我已经发现测试代码比常规代码更难调试,因为“代码表面”往往更大。开发者需要考虑测试代码和实际实现代码。而不仅仅是考虑实现代码。
添加这些抽象意味着你现在也必须考虑这一点!如果抽象会减少你必须考虑的范围,这可能是可以的,这是在常规代码中添加抽象的常见原因,但事实并非如此。它只是增加了更多需要考虑的东西。
所以这些都是错误的抽象:它们包装和混淆,而不是分离关注点并缩小范围。
如果你有兴趣在开源项目中请求其他人来贡献,那么测试可以理解是一个非常重要的问题。
看到 PRs 上写着“这是代码,它可以工作,但我无法弄清楚测试,请暂停!”这并不罕见; 而且我很确定至少有几个人甚至从不打算提交 PR 只是因为他们被困在测试中。我知道我有。
有一个开源项目是我贡献的,我也想为之贡献更多,但是我没有,因为编写和运行测试太难了。每一个变化都是“在 15 分钟内编写工作代码,花 45 分钟处理测试”。这一点儿也不好玩。
编写好的软件真的很难。当前我有一些关于如何实现好的软件的想法,但没有完整的实施方案。我知道“总是添加单元测试”和“总是使用 TDD”不是答案,尽管它们是有用的概念。打个比方:大多数人会同意自由市场是一个好主意,但与此同时,即使大多数自由主义者同意,但这并不是解决所有问题的完整方案。
原文:https://arp242.net/weblog/testing.html
本文为 CSDN 翻译,如需转载,请注明来源出处。
热 文 推 荐
☞ 史上最强春节档来袭!Python 解读哪部影片值得一看?
☞ 打破区块链不可能三角!2 华人专家论文将登 NSDI 2019 计算机顶会
print_r('点个好看吧!');
var_dump('点个好看吧!');
NSLog(@"点个好看吧!");
System.out.println("点个好看吧!");
console.log("点个好看吧!");
print("点个好看吧!");
printf("点个好看吧!\n");
cout << "点个好看吧!" << endl;
Console.WriteLine("点个好看吧!");
fmt.Println("点个好看吧!");
Response.Write("点个好看吧!");
alert("点个好看吧!")
echo "点个好看吧!"