当生产功能可能有数百万个测试用例时,TDD 是如何工作的?

Posted

技术标签:

【中文标题】当生产功能可能有数百万个测试用例时,TDD 是如何工作的?【英文标题】:How TDD works when there can be millions of test cases for a production functionality? 【发布时间】:2011-12-23 04:51:43 【问题描述】:

在 TDD 中,您选择一个测试用例并实施该测试用例,然后编写足够的生产代码以使测试通过,重构代码,然后再次选择一个新的测试用例,然后循环继续。

我在这个过程中遇到的问题是,TDD 说您编写的代码只是为了通过您刚刚编写的测试。我确切地指的是,如果一个方法可以有例如100万个测试用例,你能做什么?!明明不是写100万个测试用例?!

让我通过下面的例子更清楚地解释我的意思:

 internal static List<long> GetPrimeFactors(ulong number)
        
            var result = new List<ulong>();

            while (number % 2 == 0)
            
                result.Add(2);
                number = number / 2;
            

            var divisor = 3;

            while (divisor <= number)
            
                if (number % divisor == 0)
                
                    result.Add(divisor);
                    number = number / divisor;
                
                else
                
                    divisor += 2;
                
            

            return result;
        

上面的代码返回给定数字的所有质因数。 ulong 有 64 位,这意味着它可以接受 0 到 18,446,744,073,709,551,615 之间的值!

那么,!

我的意思是写多少测试用例就足够了,我可以说我使用 TDD 来实现这个生产代码?

TDD 中的这个概念说您应该只编写足够的代码来通过测试,这对我来说似乎是错误的,如上面的示例所示?

什么时候够了?

我自己的想法是我只选择一些测试用例,例如用于上带、下带和更多,例如5 个测试用例,但这不是 TDD,是吗?

非常感谢您对本示例的 TDD 的看法。

【问题讨论】:

我不敢相信你有 1,000,000 个显着不同的测试用例。 为了扩展@JohnSaunders 的观​​点,只需要几个不同的测试用例来确保每一行代码都被访问并执行其所需的功能。 呵呵,当然就像我在实践中提到的那样,你会写例如5 个测试用例,但我的问题主要是关于我在“只写足够的代码以通过测试”中提到的那句话。 你在哪里找到那句话? 对了,这个问题之前就在这里:***.com/questions/135789/tdd-when-you-can-move-on 【参考方案1】:

这是一个有趣的问题,与认识论中的falsifiability 的概念有关。使用单元测试,您并没有真正试图证明系统有效;您正在构建实验,如果它们失败,将证明该系统无法以符合您的期望/信念的方式运行。如果您的测试通过了,您不知道您的系统是否正常工作,因为您可能忘记了一些未经测试的边缘情况;你所知道的是,到目前为止,你没有理由相信你的系统有问题。

科学史上的经典例子是“天鹅都是白色的吗?”这个问题。无论你找到多少不同的白天鹅,你都不能说“所有天鹅都是白色的”假设是正确的。另一方面,给我一只黑天鹅,我知道这个假设是不正确的。

良好的 TDD 单元测试遵循这些原则;如果它通过了,它不会告诉你一切都是正确的,但如果它失败了,它会告诉你你的假设在哪里不正确。在这种情况下,对每个数字进行测试并不是那么有价值:一个案例应该就足够了,因为如果它不适用于那个案例,你就知道有问题了。

问题的有趣之处在于,与天鹅不同,您无法真正枚举世界上的每只天鹅,以及它们所有未来的孩子和父母,您可以枚举每个整数,这是一个有限集,并验证每一种可能的情况。此外,程序在很多方面更接近数学而不是物理,并且在某些情况下,您还可以真正验证一个陈述是否正确——但在我看来,这种验证并不是 TDD 所追求的。 TDD 追求的是好的实验,旨在捕捉可能的失败案例,而不是证明某事是真实的。

【讨论】:

+1 虽然 Domenic 确实一针见血(赞成 btw),但这给了我一个“啊哈”的时刻。【参考方案2】:

TDD 不是一种检查函数/程序是否在每个可能的输入排列上都能正常工作的方法。我的看法是,我编写特定测试用例的概率与我在这种情况下我的代码是否正确的不确定程度成正比。

这基本上意味着我在两种情况下编写测试:1)我编写的一些代码很复杂或复杂和/或有太多假设,2)生产中发生错误。

一旦您了解了导致错误的原因,通常很容易在测试用例中编写代码。从长远来看,这样做会产生一个强大的测试套件。

【讨论】:

【参考方案3】:

我从来没有做过任何 TDD,但你问的不是 TDD:它是关于如何编写一个好的测试套件。

我喜欢为每段代码可能处于的所有状态设计模型(在纸上或在我的脑海中)。我认为每一行都好像是状态机的一部分。对于每一行,我确定可以进行的所有转换(执行下一行、分支或不分支、抛出异常、溢出表达式中的任何子计算等)。

从那里我得到了我的测试用例的基本矩阵。然后我确定每个状态转换的每个边界条件,以及每个边界之间的任何有趣的中点。然后我得到了我的测试用例的变体。

从这里我尝试提出有趣且不同的流程或逻辑组合 - “这个 if 语句,加上那个 - 列表中有多个项目”等。

由于代码是一个流,你通常不能在中间打断它,除非为不相关的类插入一个 mock 有意义。在这些情况下,我经常会大大减少我的矩阵,因为有些条件是你无法达到的,或者是因为被另一条逻辑掩盖了变化变得不那么有趣了。

在那之后,我一天都累了,然后回家 :) 我可能有大约 10 到 20 个测试用例,每个经过精心设计且相当短的方法,或者每个算法/类 50 到 100 个。不是 10,000,000。

我可能想出了太多无趣的测试用例,但至少我通常会过度测试而不是测试不足。我通过尝试很好地考虑我的测试用例以避免代码重复来缓解这种情况。

这里的关键部分:

至少在您的头脑中为您的算法/对象/代码建模。您的代码更像是一棵树而不是脚本 详尽地确定该模型中的所有状态转换(可以独立执行的每个操作,以及每个表达式的每个被评估部分) 利用边界测试,您不必想出无限的变化 尽可能模拟

不,你不必写FSM 图纸,除非你喜欢做那种事情。我没有:)

【讨论】:

Flying Spaghetti Monster 图纸?所有人都为他的 Noodiness 欢呼! @Edmund:哈哈。我谈到了模型、状态和状态转换,它们构成了有限状态机。我应该链接它,寿。将修复【参考方案4】:

没有数百万个测试用例。只有一点。您不妨试试PEX,它可以让您找出算法中不同的真实测试用例。当然,您只需要测试这些即可。

【讨论】:

【参考方案5】:

您似乎将 TDD 视为 black-box testing。它不是。如果是黑盒测试,那么只有完整的(数百万个测试用例)测试集才能满足您的要求,因为任何给定的用例都可能未经测试,因此黑盒中的恶魔可以作弊逃脱。

但它不是代码中黑匣子中的恶魔。是你,在一个白色的盒子里。你知道你是否在作弊。 Fake It Til You Make It 的做法与 TDD 密切相关,有时会与它混淆。是的,您编写假实现来满足早期测试用例 - 但您知道您是在伪造它。你也知道什么时候你已经停止伪装了。你知道什么时候你有一个真正的实现,并且你已经通过渐进式迭代和测试驱动实现了。

所以你的问题真的放错地方了。对于 TDD,您需要编写足够多的测试用例来驱动您的解决方案完成和正确;你不需要为每一个可以想象的输入集测试用例。

【讨论】:

【参考方案6】:

如果您愿意,TDD 确实允许您使用常识。没有必要将您的 TDD 版本定义为愚蠢,这样您就可以说“我们没有做 TDD,我们正在做一些不那么愚蠢的事情”。

您可以编写一个测试用例,多次调用被测函数,并传入不同的参数。这可以防止“编写代码以分解 1”、“编写代码以分解 2”、“编写代码以分解 3”成为单独的开发任务。

要测试多少不同的值实际上取决于运行测试的时间。你想测试任何可能是极端情况的东西(所以在分解的情况下至少 0、1、2、3、LONG_MAX+1,因为它具有最多的因子,无论哪个值最不同 因子、一个卡迈克尔数和几个具有不同数量的素因子的完全平方)加上尽可能大的值范围,以期覆盖一些你没有意识到的极端情况,但实际上是。这可能意味着编写测试,然后编写函数,然后根据观察到的性能调整范围的大小。

您还可以阅读函数规范,并实现函数就好像测试了比实际更多的值。这并不真正与“仅实现已测试的内容”相矛盾,它只是承认在发货日期之前没有足够的时间来运行所有 2^64 个可能的输入,因此实际测试是“逻辑”测试的代表性样本如果你有时间,你会跑。您仍然可以编写您想要测试的代码,而不是您实际有时间测试的代码。

您甚至可以测试随机选择的输入(通常作为安全分析师“模糊测试”的一部分),如果您发现您的程序员(即您自己)被确定为反常,并继续编写仅 解决了测试的输入,没有其他的。显然,随机测试的可重复性存在问题,因此请使用 PRNG 并记录种子。您会在竞赛节目、在线裁判节目等方面看到类似的情况,以防止作弊。程序员并不确切知道将测试哪些输入,因此必须尝试编写解决所有可能输入的代码。由于您无法对自己保密,因此随机输入可以完成同样的工作。在现实生活中,使用 TDD 的程序员不会故意作弊,但可能会因为同一个人编写测试和代码而意外作弊。有趣的是,测试会错过与代码相同的困难极端情况。

对于一个接受字符串输入的函数,问题更加明显,可能的测试值远不止2^64。选择最好的,也就是说程序员最容易出错的,充其量是一门不精确的科学。

您还可以让测试人员作弊,超越 TDD。首先编写测试,然后编写代码以通过测试,然后返回并编写更多白盒测试,其中 (a) 包含看起来可能是实际编写的实现中的边缘情况的值; (b) 包括足够的值来获得 100% 的代码覆盖率,无论你有时间和意志力去研究什么代码覆盖率指标。该过程的 TDD 部分仍然有用,它有助于编写代码,但随后您进行迭代。如果这些新测试中的任何一个失败,您可以将其称为“添加新要求”,在这种情况下,我想您正在做的仍然是纯 TDD。但这只是你如何称呼它的问题,实际上你并没有添加新需求,而是比编写代码之前更彻底地测试原始需求。

【讨论】:

【参考方案7】:

这是您任何测试时遇到的第一个问题。 TDD 在这里并不重要。

是的,有很多很多案例;此外,如果您开始构建系统,还有案例组合和组合。它确实会引导你进入组合爆炸。

如何解决这个问题是个好问题。通常,您选择等价类,您的算法可能会在这些类中发挥相同作用,并为每个类测试一个值。

下一步是测试边界条件(请记住,CS 中最常见的两个错误是差一个错误)。

接下来...好吧,出于所有实际原因,可以在这里停下来。不过,看看这些讲义:http://www.scs.stanford.edu/11au-cs240h/notes/testing.html

PS。顺便说一句,“按书”使用 TDD 来解决数学问题并不是一个好主意。 Kent Beck 在他的 TDD 书中证明了这一点,实现了计算斐波那契数的函数的最坏可能实现。如果你知道一个封闭的形式——或者有一篇文章描述了一个经过验证的算法,那么只需如上所述进行健全性检查,并且不要对整个重构周期进行 TDD——它会节省你的时间。

PPS。实际上,a nice article (惊喜!)提到了 bot 斐波那契问题和您在 TDD 中遇到的问题。

【讨论】:

“阶乘最糟糕的实现” - 我希望重复递增得到加法,然后重复加法得到乘法。大概的重点是,如果规范没有说明函数运行需要多长时间,那么“按规定”,测试人员不允许在此基础上失败。 糟糕,我的错。当然,这是斐波那契数的函数。 仅作记录:在进行 TDD 时,您不应该忘记 refactor 阶段,这是您应该采用“执行不佳的功能”(例如 Fibonacci)的地方,并且更改实现而不更改功能。这意味着,一旦您有了一个简单的解决方案,您就可以尽可能多地改进它,使其具有生产价值。这是 TDD 的一个经常被忽视的方面,这往往会给它带来不应有的坏名声。【参考方案8】:

从我的 POV 来看,重构 步骤似乎并没有发生在这段代码上......

在我的书中,TDD 并不意味着为每个可能的输入/输出参数的每个可能的排列编写测试用例......

但是要编写所有需要的测试用例,以确保它完成指定要做的事情,即对于这种方法,所有边界用例加上一个测试,该测试从包含已知正确结果的数字的列表中随机选择一个数字。如果需要,您可以随时扩展此列表以使测试更彻底...

TDD 仅适用于现实世界,前提是您不抛开常识......

至于

只写足够的代码来通过你的测试

在 TDD 中,这是指“非作弊程序员”...如果您有一个或多个“作弊程序员”,例如,他们只是将测试用例的“正确结果”硬编码到方法中,我怀疑您的方法要大得多你手上的问题比 TDD...

顺便说一句,“测试用例构建”是你越练习越擅长的东西 - 没有任何书籍/指南可以预先告诉你哪些测试用例最适合任何给定的情况......经验会带来很大的回报构建测试用例...

【讨论】:

“从包含已知正确结果的数字的列表中随机选择一个数字的测试” 您不应编写使用随机数的测试。这可能很容易产生不确定的闪烁测试。 @Andre 我一般同意,但如果您专门检查这种情况,恕我直言,因为我们从“已知列表”中选择随机数 - 即使从该列表中选择所有数字也可以。跨度> 测试列表中的所有输入/输出对是完全不同的事情——在我看来这是正确的事情。我很好奇在这种情况下只执行一个/一些随机选择的测试是什么原因?我能想到的唯一原因是测试可能需要很长时间才能运行,在这种情况下我会将它们放入另一组测试中(不经常运行)。 @Andre 我们所说的函数是一个数的因式分解......这不能测试到所有可能值的全部范围......所以在充分测试了极端情况之后" 恕我直言,可以从已知答案的数字列表中测试随机选择...这与仅缩小此列表并从较小的列表中测试所有内容没有什么不同。 我想我们这里可能有误会。从我的角度来看,有两种方法可以解释“从已知答案的数字列表中测试随机选择”。 1. 确定一个随机数(例如通过掷骰子)选择相应的测试并在您的测试中将其固定。这意味着您总是在运行相同的测试。 2.有一个列表,在你的代码中调用rand()或类似的东西,根据结果选择测试。这意味着您每次运行测试套件时都会运行不同的测试。选项1可以,选项2不行。【参考方案9】:

您通常会根据“测试边界条件”和一些随机条件进行测试。

例如:ulong.min、ulong.max 和一些值。你为什么还要制作 GetPrimeFactors?你喜欢一般地计算它们,还是你这样做是为了做一些具体的事情?测试你为什么成功。

你也可以为 result.Count 做断言,而不是所有单独的项目。如果您知道要获得多少项目以及一些特定情况,您仍然可以重构您的代码,如果这些情况和总数相同,则假设该功能仍然有效。

如果你真的想测试那么多,你也可以考虑白盒测试。比如Pex and Moles就不错了。

【讨论】:

【参考方案10】:

当您编写测试时,您应该采用有意义的个案例,而不是每个案例。有意义的案例包括一般案例、特殊案例……

你不能为每一个案例编写一个测试(否则你可以把值放在一个表上并回答它们,这样你就可以 100% 确定你的程序可以工作:P)。

希望对您有所帮助。

【讨论】:

【参考方案11】:

你忘记了第三步:

    红色 绿色 重构

编写测试用例会让你红起来。

编写足够的代码以使这些测试用例通过,让您获得绿色。

将您的代码概括为不仅仅适用于您编写的测试用例,同时仍然不会破坏其中任何一个,这就是重构。

【讨论】:

感谢您的评论。 “将您的代码泛化为不仅仅适用于您编写的测试用例,同时仍然不会破坏其中任何一个,这就是重构。”这不完全是我对重构的定义,因为我通常指的是重构模式,例如sourcemaking.com/refactoring。你所说的打破了 TDD 的概念,即编写足够的代码只是为了通过测试,因为你编写的生产代码比你测试的要多,对吧! 重构意味着对代码进行不改变其外部输出的更改。在 TDD 的上下文中,这意味着对代码进行更改,而不会改变它是否通过/失败测试。再说一次,编写足够的代码以通过测试的 TDD 概念是 TDD 的第 1-2 步;您完全忽略了第 3 步。 从另一个角度来看:应用于 TDD 的“代码覆盖率”概念不是覆盖所有可能的输入值,而是覆盖所有可能的分支逻辑路径。如果您的测试用例涵盖了所有可能的分支逻辑路径,那么即使您没有针对所有可能的输入进行测试,您也可以对所有代码进行测试。

以上是关于当生产功能可能有数百万个测试用例时,TDD 是如何工作的?的主要内容,如果未能解决你的问题,请参考以下文章

在编写rspec测试用例时如何覆盖ruby方法的局部变量?

如何在 bitrise 上运行测试用例时查看模拟器

逻辑覆盖

如何使用纯 Redis 原子地删除数百万个匹配模式的键?

测试驱动开发-TDD

哪种语言可以将数百万个脏地址稳定地处理为标准格式?