【问题标题】:Off By One errors and Mutation TestingOff By One 错误和突变测试
【发布时间】:2012-05-10 02:53:32
【问题描述】:

在为我最喜欢的突变测试框架 (NinjaTurtles) 编写“Off By One”突变测试器的过程中,我编写了以下代码来检查我的实现的正确性:

public int SumTo(int max)
{
    int sum = 0;
    for (var i = 1; i <= max; i++)
    {
        sum += i;
    }
    return sum;
}

现在这看起来很简单,我并没有想到尝试改变 IL 中的所有文字整数常量会出现问题。毕竟,只有 3 个(01++)。

错了!

在第一次运行时很明显,它永远不会在这个特定的实例中工作。为什么?因为把代码改成

public int SumTo(int max)
{
    int sum = 0;
    for (var i = 0; i <= max; i++)
    {
        sum += i;
    }
    return sum;
}

只在总和上加0(零),这显然没有效果。如果它是多重集合,情况就不同了,但在这种情况下它不是。

现在有一个相当简单的算法来计算整数的总和

sum = max * (max + 1) / 2;

我很容易使突变失败,因为从其中的任何一个常数中加或减 1 都会导致错误。 (鉴于max &gt;= 0

所以,这个特殊情况的问题解决了。虽然它没有做我想要的突变测试,即检查当我丢失 ++ 时会发生什么 - 实际上是一个无限循环。但这是另一个问题。

所以 - 我的问题: 是否存在任何从 0 或 1 开始的循环可能导致无法重构的“突变关闭”测试失败(代码正在测试或测试)以类似的方式? (请举例)

注意:当测试套件在应用突变后通过时,突变测试失败。

更新:一个不那么琐碎的例子,但仍然可以重构测试以使其失败的例子如下

public int SumArray(int[] array)
{
    int sum = 0;
    for (var i = 0; i < array.Length; i++)
    {
        sum += array[i];
    }

    return sum;
}

如果您提供的测试输入是new[] {0,1,2,3,4,5,6,7,8,9},则将var i=0 更改为var i=1 时,针对此代码的突变测试将失败。但是将测试输入更改为new[] {9,8,7,6,5,4,3,2,1,0},变异测试将失败。因此,成功的重构证明了测试。

【问题讨论】:

    标签: c# algorithm il mutation-testing ninja-turtles


    【解决方案1】:

    我认为使用这种特殊方法,有两种选择。您要么承认由于这种数学异常而不适合突变测试,要么您尝试以一种使突变测试安全的方式编写它,要么通过重构为您提供的形式,要么以其他方式(可能是递归的? )。

    您的问题实际上归结为:在现实生活中是否存在我们关心元素 0 是否包含在循环操作中或排除在循环操作中的情况,我们无法为此编写测试具体方面?我的直觉是拒绝。

    你的小例子可能是我在my blog 中提到的缺乏测试驱动的例子,写的是NinjaTurtles。这意味着您没有尽可能地重构此方法。

    【讨论】:

    • 没错。我的直觉也不是。如果您更改 for 循环的起始常量,那么一个重要的示例应该总是失败,我怀疑我的人为示例只是少数在更改时不会失败的实际案例之一。我想即使是一个无法重构的微不足道的例子也会很有趣。我会换个问题。
    【解决方案2】:

    “突变测试失败”的一个自然案例是矩阵转置算法。为了使其更适合单个 for 循环,为此任务添加一些约束:让矩阵为非正方形并要求转置到位。这些约束使一维数组最适合存储矩阵,并且可以使用 for 循环(通常从索引“1”开始)来处理它。如果从索引 '0' 开始,则不会发生任何变化,因为矩阵的左上角元素总是转置为自身。

    有关此类代码的示例,请参阅answer to other question(不在 C# 中,抱歉)。

    这里的“mutation off by one”测试失败,重构测试并不会改变它。我不知道是否可以重构代码本身以避免这种情况。理论上是可能的,但应该太难了。


    我之前引用的代码 sn-p 并不是一个完美的例子。如果将 for 循环替换为两个嵌套循环(如行和列),然后将这些行和列重新计算回一维索引,它仍然可以重构。它仍然提供了一个想法,如何制作一些无法重构的算法(虽然不是很有意义)。

    按索引递增的顺序遍历一个正整数数组,对于每个索引,计算其对为i + i % a[i],如果它不在边界之外,则交换这些元素:

    for (var i = 1; i < a.Length; i++)
    {
        var j = i + i % a[i];
        if (j < a.Length)
            Swap(a[i], a[j]);
    }
    

    这里 a[0] 又是“不可移动的”,重构测试不会改变这一点,重构代码本身实际上是不可能的。


    另一个“有意义”的例子。让我们实现一个隐式二进制堆。它通常被放置到某个数组,从索引“1”开始(与从索引“0”开始相比,这简化了许多二进制堆计算)。现在为这个堆实现一个复制方法。此复制方法中的“逐一”问题是无法检测到的,因为未使用索引零并且 C# 对所有数组进行零初始化。这类似于OP的数组求和,但不能重构。

    严格来说,你可以重构整个类,一切从“0”开始。但是仅更改“复制”方法或测试并不能防止“突变关闭”测试失败。 Binary Heap 类可能被视为复制具有未使用的第一个元素的数组的动机。

    int[] dst = new int[src.Length];
    for (var i = 1; i < src.Length; i++)
    {
        dst[i] = src[i];
    }
    

    【讨论】:

    • 所以,一个有意义的代码任务,从索引 0 或 1 开始具有相同的效果,因为在求和的情况下,对索引 0 的操作没有效果。在这种情况下,无需借助外部库进行矩阵计算,就没有重构——假设您认为使用一维数组是理所当然的。有趣 - 思考这个。
    • @evgeny - 我刚刚完成了你的原始帖子。但它是值得的。我把 C++ 做成了 C# 并摆弄了一下。我最初完全不知道如何重构以实现可以通过突变测试证明的结果。最终,我有我的灯泡时刻,它也适用于这个例子。对方法应用一些不变性在这里可以创造奇迹。因此,不要使用void,而是返回char[],这样您就有了一些可证明的代码,并且可能还有一种更适合体验的方法。
    • @pms1969,对,不变性在这里有帮助。但它消除了对算法的“就地”约束。我认为不可能将这样修改的算法视为相同的算法。对于矩阵转置,有一个非常简单的非就地算法,但所有就地算法都非常先进。顺便说一句,我只是添加了一个示例,它已经是“不可变的”。
    • 我同意就地约束注释,但也许我们需要问自己,为什么在编写我们自己的代码时会有这种约束?更简单的代码更容易维护和重构。而突变测试无法证明就地算法的事实可能会让你重新考虑你的设计(那里的皇家“你”)。我唯一能想到约束可能有效的地方是处理大量数据集,如果复制这些数据集可能会破坏你的内存。
    • 好的。添加了“复制”本身的代码。希望,不需要编写整个类的实现。我没有使用 Array.Copy 因为在这种情况下没有什么要测试的。至于就地约束,矩阵转置唯一重要的是庞大的数据集。对于其他算法,就地约束可能具有其他优势。修改 10% 的数据集比全部复制要好。 (至少对于命令式语言是这样;函数式语言使用链接结构,其中不变性更方便)。
    【解决方案3】:

    是的,有很多,假设我已经理解了你的问题。

    与您的情况类似的是:

    public int MultiplyTo(int max)
    {
        int product = 1;
        for (var i = 1; i <= max; i++)
        {
            product *= i;
        }
        return product;
    }
    

    这里,如果从0开始,则结果为0,但如果从1开始,则结果应该是正确的。 (虽然它不会区分 1 和 2!)。

    【讨论】:

    • 我认为 OP 在一个 fails 突变测试的重要示例之后 - 这个通过了,因为 i = 0 的循环开始给出了结果 0i = 1 的循环开始给出非零结果。
    • 这更有意义......所以他的问题实际上是在说,“这是我正在寻找的一个例子,但还有另一个不那么微不足道的案例吗?”
    • 好吧,实际上,从var i = 1var i = 2 的突变将失败,因为乘以 1 对答案没有影响。仍然是一个相当做作的答案。有没有可能在这里工作的重构?
    • 是的,有一个重构可能在这里起作用。使方法递归。
    【解决方案4】:

    不太确定您到底在寻找什么,但在我看来,如果您将 sum 的初始值从 0 更改/变异为 1,您应该无法通过测试:

    public int SumTo(int max) 
    { 
      int sum = 1; // Now we are off-by-one from the beginning!
      for (var i = 0; i <= max; i++) 
      { 
        sum += i; 
      } 
      return sum; 
    }
    

    基于 cmets 的更新:

    只有在处理索引 0(或没有它)时违反循环不变量时,循环才会在突变后失败。大多数此类特殊情况都可以在循环外重构,但考虑 1/x 的总和:

    for (var i = 1; i <= max; i++) {
      sum += 1/i;
    }
    

    这可以正常工作,但如果您将初始边界从 1 更改为 0,则测试将失败,因为 1/0 是无效操作。

    【讨论】:

    • 是的,但我感兴趣的是 for 循环。
    • 据我了解您的问题,如果循环内的所有操作都有效,那么从 0 或 1 开始没有区别(结果可能明显不同,但不变量(与修改后的边界)将成立)。你能做一个i &lt;= max+0吗?如果没问题,将其更改为 i&lt;=max+1 可能无效(例如过度索引数组)
    • 所以这里的常量是变化的东西,你是对的,过度索引很可能会导致失败(但我认为我们不能用 sum 示例来保证它。我会编辑帖子以给出一个不那么微不足道并且会因某些输入而失败的示例。可以重构测试本身,以便输入随后会使测试失败,从而实现良好的覆盖/测试。
    • 另一种可能性是循环要求你从 0 开始(例如步骤 0 是某种初始化)然后突变会导致测试失败
    • 然后可以重构以在循环之外进行初始化。除非你能想出一个它不能的例子吗?
    猜你喜欢
    • 2012-11-23
    • 1970-01-01
    • 2015-08-26
    • 2014-09-13
    • 1970-01-01
    • 2019-03-27
    • 2010-12-25
    • 2012-03-29
    • 1970-01-01
    相关资源
    最近更新 更多