TDD和完整的测试范围,需要指数测试用例


18

我正在研究一个列表比较器,以帮助根据客户的非常特定的要求对搜索结果的无序列表进行排序。需求要求使用重要性规则按以下规则排序相关性算法:

  1. 姓名完全匹配
  2. 搜索词中所有单词的名称或结果的同义词
  3. 搜索查询中某些单词的名称或结果的同义词(%降序)
  4. 说明中搜索查询的所有单词
  5. 说明中搜索查询的某些单词(降序排列)
  6. 最后修改日期降序

该比较器的自然设计选择似乎是基于2的幂的得分排名。次要重要规则的总和永远不会与重要性较高的规则的正比匹配大。这可以通过以下得分来实现:

  1. 32
  2. 16
  3. 8(基于决断百分比下降的次决胜局得分)
  4. 4
  5. 2(基于降低百分比的次决胜局得分)
  6. 1个

本着TDD的精神,我决定首先进行单元测试。对于每个唯一方案都有一个测试用例,至少要有63个唯一测试用例,而不考虑规则3和5上的辅助抢断逻辑的其他测试用例。这似乎太过分了。

实际的测试实际上会更少。根据实际规则本身,某些规则可确保较低的规则将始终为真(例如,当“所有搜索查询词都出现在说明中”时,规则“某些搜索查询词会出现在说明中”将始终为真)。写下每个测试用例的努力水平仍然值得吗?在谈论TDD中100%的测试覆盖率时,这通常是要求的测试级别吗?如果不是,那么什么是可接受的替代测试策略?


1
这种情况和类似情况就是为什么我开发了“ TMatrixTestCase”和枚举器的原因,您可以为它编写一次测试代码,并将包含输入和预期结果的两个或多个数组提供给它。
Marjan Venema

Answers:


17

您的问题暗示TDD与“首先编写所有测试用例”有关。恕我直言,这不是“ TDD精神”,实际上是反对的。请记住,TDD代表“测试驱动的开发”,因此您只需要真正“驱动”您的实现的那些测试用例,而不是更多。并且,只要您的实现设计中的代码块数量不会随每个新需求呈指数增长,那么您也不需要成倍数量的测试用例。在您的示例中,TDD周期可能如下所示:

  • 从列表中的第一个要求开始:具有“姓名完全匹配”的单词必须获得比其他所有单词更高的分数
  • 现在,您为此编写第一个测试用例(例如:与给定查询匹配的单词)并实现使该测试通过的最少工作代码
  • 为第一个需求添加第二个测试用例(例如:与查询不匹配的单词),然后在添加新的测试用例之前,更改现有代码,直到第二个测试通过
  • 根据实现的细节,可以随意添加更多的测试用例,例如,空查询,空单词等(请记住:TDD是一种白盒方法,您可以利用以下事实:设计测试用例)。

然后,从第二个要求开始:

  • “搜索查询中所有单词的名称或结果的同义词”的得分必须低于“名称完全匹配”,但得分要高于其他所有得分。
  • 现在,像上面一样,一个接一个地为这个新需求构建测试用例,并在每次新测试之后实现代码的下一部分。不要忘记在代码和测试用例之间进行重构。

这里有一个要点:在为需求/类别编号“ n”添加测试用例时,只需添加测试以确保类别“ n-1”的得分高于类别“ n”的得分。您不必为类别1,...,n-1的所有其他组合添加任何测试用例,因为您之前编写的测试将确保该类别的分数仍保持正确的顺序。

因此,这将为您提供大量的测试用例,它们与需求的数量成线性增长,而不是呈指数增长。


1
我真的很喜欢这个答案。它提供了一种清晰简洁的单元测试策略来解决此问题,同时要牢记TDD。您可以很好地分解它。
maple_shaft

@maple_shaft:谢谢,我真的很喜欢你的问题。我想补充一点,我想即使是您首先设计所有测试用例的方法,建立用于测试的等效类的经典技术也可能足以减少指数增长(但是到目前为止,我还没有解决这个问题)。
布朗

13

考虑编写一个通过预定义条件列表并为每次成功检查将当前分数乘以2的类。

只需使用几个模拟测试,就可以非常轻松地进行测试。

然后,您可以为每种情况编写一个类,每种情况只有2个测试。

我不太了解您的用例,但希望该示例会有所帮助。

public class ScoreBuilder
{
    private ISingleScorableCondition[] _conditions;
    public ScoreBuilder (ISingleScorableCondition[] conditions)
    {
        _conditions = conditions;
    }

    public int GetScore(string toBeScored)
    {
        foreach (var condition in _conditions)
        {
            if (_conditions.Test(toBeScored))
            {
                // score this somehow
            }
        }
    }
}

public class ExactMatchOnNameCondition : ISingleScorableCondition
{
    private IDataSource _dataSource;
    public ExactMatchOnNameCondition(IDataSource dataSource)
    {
        _dataSource = dataSource;
    }

    public bool Test(string toBeTested)
    {
        return _dataSource.Contains(toBeTested);
    }
}

// etc

您会注意到,您的2 ^条件测试很快可以归结为4+(2 *条件)。20比64的霸道要少得多。如果以后再添加一个,则不必更改任何现有类(开放式-封闭式原理),因此不必编写64个新测试,您只需添加带有2个新测试的另一个类并将其注入到ScoreBuilder类中。


有趣的方法。一直以来,我一直都没有考虑过OOP方法,因为我一直停留在单个比较器组件的心中。我确实不是在寻找算法建议,但是无论如何这都是非常有用的。
maple_shaft

4
@maple_shaft:不,但是您正在寻找TDD建议,并且这类算法非常适合通过大大减少工作量来消除是否值得付出努力的问题。降低复杂度是TDD的关键。
pdr 2014年

+1,好答案。尽管我相信即使没有这种复杂的解决方案,测试用例的数量也不必成倍增长(请参阅下面的答案)。
Doc Brown

我不接受您的回答,因为我认为另一个答案可以更好地解决实际问题,但是我非常喜欢您的设计方法,因此我按照您的建议实施了该方法。从长远来看,这确实降低了复杂性并使其更具可扩展性。
maple_shaft

4

写下每个测试用例的努力水平仍然值得吗?

您需要定义“值得”。这种情况的问题是,测试的有用性收益将递减。当然,您编写的第一个测试将完全值得。它可以在优先级中发现明显的错误,甚至可以在尝试分解单词时发现诸如解析错误之类的错误。

第二个测试是值得的,因为它涵盖了代码的另一条路径,可能检查了另一个优先级关系。

第63个测试可能不值得,因为您有99.99%的信心被代码或其他测试的逻辑覆盖。

在谈论TDD中100%的测试覆盖率时,这通常是要求的测试级别吗?

我的理解是100%的覆盖率意味着所有代码路径都将被执行。这并不意味着您要完成规则的所有组合,而是代码的所有不同路径都可能失效(如您所指出的那样,代码中不存在某些组合)。但是,由于您正在执行TDD,因此没有“代码”可以检查路径。过程中的字母表示全部63+。

我个人认为100%的覆盖率是个梦dream以求的事情。除此之外,这是不务实的。存在单元测试可以为您服务,反之亦然。随着您进行更多的测试,您得到的收益回报将逐渐减少(测试预防错误的可能性+代码正确的置信度)。根据您的代码执行的操作,您将停止在该比例尺上停止进行测试。如果您的代码正在运行核反应堆,那么也许所有63+次测试都值得。如果您的代码正在组织音乐档案,那么您可能会少花很多钱。


“覆盖”通常是指代码覆盖率(执行每行代码)或分支覆盖率(每个分支在任何可能的方向上至少执行一次)。对于这两种类型的覆盖范围,都不需要64个不同的测试用例。至少,没有一个严肃的实现,对于64种情况,每种实现都不包含单独的代码部分。因此完全有可能实现100%的覆盖率。
布朗

@DocBrown-当然,在这种情况下-其他事情很难/不可能测试;考虑内存不足的异常路径。是否会在“按字母顺序排列” TDD中要求全部64位来强制执行该行为,而忽略了实现?
Telastyn 2014年

好吧,我的评论与问题有关,您的回答给人的印象是,对于OP,可能很难获得100%的覆盖率。我不信。我同意你的看法,即可以构建难以实现100%覆盖率的案例,但并没有要求这样做。
Doc Brown

4

我认为这对于TDD 是一个完美的案例。

您有一组已知的要测试的标准,并且对这些情况进行了逻辑细分。假设您现在要对它们进行单元测试,或者稍后再进行测试,那么采用已知结果并围绕它进行构建似乎很有意义,以确保您实际上独立地涵盖了每个规则。

另外,如果添加新的搜索规则破坏了现有规则,您将可以随时查找。如果您在编码结束时进行了所有这些操作,则可能要冒更大的风险,即必须更改一个来修复一个,这会破坏另一个,从而破坏另一个...而且,您在实施规则时会了解到设计是否有效或需要调整。


1

我不喜欢将100%测试覆盖率严格解释为针对每种方法编写规范或测试代码的每个排列。狂热地这样做会导致类的测试驱动设计无法正确地封装业务逻辑,并且产生的测试/规范对于描述所支持的业务逻辑通常是毫无意义的。取而代之的是,我专注于像业务规则本身一样构造测试,并努力使用测试来执行代码的每个条件分支,并明确希望测试人员可以容易地理解这些测试,因为它们通常是用例,并且实际上描述了这些用例。已实施的业务规则。

考虑到这个想法,我将对彼此独立列出的6个排名因素进行详尽的单元测试,然后再进行2或3个集成样式测试,以确保将结果汇总到预期的总体排名值。例如,在情况#1(名称上的精确匹配)下,我将至少有两个单元测试来测试何时准确,何时不正确以及两个场景返回预期分数。如果区分大小写,则还要测试“完全匹配”与“完全匹配”的情况,并且可能还会输入其他输入变量,例如标点符号,多余的空格等,也会返回预期分数。

一旦我研究了所有影响排名得分的因素,我就基本上假设这些因素在集成水平上可以正常运行,并专注于确保它们的综合因素正确地为最终的预期排名得分做出贡献。

假设#2 /#3和#4 /#5的情况被推广到相同的基础方法,但是传入不同的字段,则只需为基础方法编写一组单元测试,并编写简单的其他单元测试来测试特定的字段(标题,名称,描述等)和按指定因子进行计分,因此这进一步减少了整体测试工作的冗余性。

使用这种方法,上述方法可能会在案例#1上进行3或4个单元测试,也许占某些/全部w /同义词的10个规格-加上在案例2-#5和2正确评分上的4个规格到最终排序日期的3种规格,然后进行3至4种集成度测试,以可能的方式测量所有6种情况(除非您清楚地看到代码中需要解决的问题,否则请暂时忽略晦涩的边缘情况)该条件得到处理)或确保以后的版本不会违反/破坏。这样就产生了大约25种规格,可以执行100%编写的代码(即使您没有直接调用100%编写的方法)。


1

我从来都不是100%测试覆盖率的粉丝。以我的经验,如果某件事足够简单,只能用一个或两个测试用例进行测试,那么它就足够简单,很少会失败。当它确实失败时,通常是由于体系结构的更改而无论如何都需要进行测试更改。

话虽如此,对于像您这样的需求,我总是进行彻底的单元测试,即使在没有人参与的个人项目中也是如此,因为在这种情况下,单元测试可以节省您的时间和精力。测试某物所需的单元测试越多,单元测试将节省更多的时间。

那是因为您一次只能把很多东西放在脑海中。如果您尝试编写适用于63种不同组合的代码,通常很难在不破坏另一种组合的情况下修复其中一种组合。您最终要一遍又一遍地手动测试其他组合。手动测试要慢得多,这使您不想在每次更改时都重新运行所有可能的组合。这使您更有可能错过某些东西,并且更有可能浪费时间寻找并非在所有情况下都有效的路径。

除了与手动测试相比节省了时间外,还减少了精神压力,这使您更容易关注当前的问题,而不必担心意外引入回归。这样一来,您可以更快,更长地工作,而不会感到倦怠。我认为,仅心理健康的好处就值得对复杂代码进行单元测试,即使它并没有节省您任何时间。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.