增强单元测试的设计技术

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / Java 学习路线 / 一对一提问 / 学习打卡/ 赠书活动

目前,正在 星球 内带小伙伴们做第一个项目:全栈前后端分离博客项目,采用技术栈 Spring Boot + Mybatis Plus + Vue 3.x + Vite 4手把手,前端 + 后端全栈开发,从 0 到 1 讲解每个功能点开发步骤,1v1 答疑,陪伴式直到项目上线,目前已更新了 204 小节,累计 32w+ 字,讲解图:1416 张,还在持续爆肝中,后续还会上新更多项目,目标是将 Java 领域典型的项目都整上,如秒杀系统、在线商城、IM 即时通讯、权限管理等等,已有 870+ 小伙伴加入,欢迎点击围观

大多数开发人员的主要目标通常是在编写任何单元测试时实现 100% 的代码覆盖率。在这篇测试设计指南文章中,我将向您展示如何使用 基于规范的测试设计技术 通过单元测试覆盖更多需求。

我见过很多 单元测试 ,在程序员开发的单元测试中,大多数都没有完全覆盖需求。考虑一下您如何编写测试。您是否从应用程序的规范文档中提取测试输入?如果不是,你应该是!在本文结束时,您将知道如何通过两种特定技术提供一种基于规范设计测试用例的方法: 等价划分 边界值分析

非基于规范的测试

我写了一个简单的类来解释文章的思想。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age <= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age > 0 && age <= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age > 5 && age <= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age > 18 && age < 65)
    {
        subscriptionPrice = 40;
    }
    else if (age >= 65 && age <= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}


此静态实用程序的主要目标是返回索非亚交通线路一个月的订阅价格。在实用程序中,客户应提交他们的年龄。结果价格随年龄而变化。

0 < 年龄 <= 5 – 价格= 0 lv

5 < 年龄 <= 18 – 价格= 20 lv

18 < 年龄 < 65 – 价格= 40 lv

65 <= 年龄 <= 122 – 价格= 5 lv

在我看来,大多数开发人员倾向于根据他们的代码编写测试。他们首先阅读规范,编写代码,然后根据代码本身设计测试。他们的目标是实现 100% 的代码覆盖率 ,而不是 100% 的规范 覆盖率 。当我想到这种趋势时,我问自己: “如果基于可能已经包含错误的代码,你为什么要编写会失败的测试?”

为了达到 100% 的代码覆盖率,只需要七次测试。对于测试示例,我将使用 NUnit, 因为它具有方便的属性(如果您想更多地使用 NUnit,可以查看 John 对 Telerik 的 Devcraft 的 生产力工具的评论 )。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age &lt;= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age &gt; 0 &amp;&amp; age &lt;= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age &gt; 5 &amp;&amp; age &lt;= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age &gt; 18 &amp;&amp; age &lt; 65)
    {
        subscriptionPrice = 40;
    }
    else if (age &gt;= 65 &amp;&amp; age &lt;= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}


随机的?真的吗? 您可能会感到震惊,但许多开发人员倾向于在他们的测试中使用这种技术。第一次看到类似上面的代码时,我 至少 用手掌捂了 5 分钟。在测试中使用随机数据会导致不可靠的测试结果。有可能生成的某些值的测试结果为绿色,而其他值则变为红色。

基于代码的测试用例

[Random(min: 1, max: 5, count: 1)] then Price = 0 ,首先覆盖 else if。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age &lt;= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age &gt; 0 &amp;&amp; age &lt;= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age &gt; 5 &amp;&amp; age &lt;= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age &gt; 18 &amp;&amp; age &lt; 65)
    {
        subscriptionPrice = 40;
    }
    else if (age &gt;= 65 &amp;&amp; age &lt;= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}


[Random(min: 6, max: 18, count: 1)] then Price= 20 ,覆盖第二个 else if。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age &lt;= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age &gt; 0 &amp;&amp; age &lt;= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age &gt; 5 &amp;&amp; age &lt;= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age &gt; 18 &amp;&amp; age &lt; 65)
    {
        subscriptionPrice = 40;
    }
    else if (age &gt;= 65 &amp;&amp; age &lt;= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}


[Random(min: 19, max: 64, count: 1)] 然后 Price= 40 ,覆盖第三个。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age &lt;= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age &gt; 0 &amp;&amp; age &lt;= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age &gt; 5 &amp;&amp; age &lt;= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age &gt; 18 &amp;&amp; age &lt; 65)
    {
        subscriptionPrice = 40;
    }
    else if (age &gt;= 65 &amp;&amp; age &lt;= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}


[Random(min: 65, max: 122, count: 1)] 然后 Price= 5 ,覆盖高级价格。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age &lt;= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age &gt; 0 &amp;&amp; age &lt;= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age &gt; 5 &amp;&amp; age &lt;= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age &gt; 18 &amp;&amp; age &lt; 65)
    {
        subscriptionPrice = 40;
    }
    else if (age &gt;= 65 &amp;&amp; age &lt;= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}


AgeInput= “invalid” ,验证用户传递非整数值时的第一个异常场景。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age &lt;= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age &gt; 0 &amp;&amp; age &lt;= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age &gt; 5 &amp;&amp; age &lt;= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age &gt; 18 &amp;&amp; age &lt; 65)
    {
        subscriptionPrice = 40;
    }
    else if (age &gt;= 65 &amp;&amp; age &lt;= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}


AgeInput= “0” ,涵盖第二次防御检查。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age &lt;= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age &gt; 0 &amp;&amp; age &lt;= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age &gt; 5 &amp;&amp; age &lt;= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age &gt; 18 &amp;&amp; age &lt; 65)
    {
        subscriptionPrice = 40;
    }
    else if (age &gt;= 65 &amp;&amp; age &lt;= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}


AgeInput="1000" ,使测试通过最后一次关于最大年龄的验证检查。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age &lt;= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age &gt; 0 &amp;&amp; age &lt;= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age &gt; 5 &amp;&amp; age &lt;= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age &gt; 18 &amp;&amp; age &lt; 65)
    {
        subscriptionPrice = 40;
    }
    else if (age &gt;= 65 &amp;&amp; age &lt;= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}


在仅仅七个测试用例中,我们就设法实现了 100% 的代码覆盖率。但是,如果有人更改了“<”、>、>=”或“<=”条件运算符之一,这些测试用例很可能 不会 捕获回归错误。此外,这种编写测试的方法并不能保证代码是正确的。如果测试基于有缺陷的代码,它们将无法帮助我们交付更好的无问题软件。这是 基于规范的测试设计技术可以帮助我们的 地方。

基于规范的测试:基于等价划分

首先,让我回顾一下基于规范的测试意味着什么。

它是一种测试方法,其中测试用例是根据测试目标和 从需求中导出的 测试条件设计的,例如,执行特定功能或探测非功能属性(如可靠性或可用性)的测试。

等价划分的主要目标是将 测试用例的数量减少到必要的最少 ,并 选择正确的测试用例来覆盖所有可能的场景

等价划分假设

划分的集合称为等价分区或等价类。然后我们只从每个分区中选取一个值进行测试。该技术背后的假设是, 如果分区中的一个条件/值通过,则所有其他条件/值也将通过 。同样, 如果分区中的一个条件失败,则该分区中的所有其他条件也将失败

测试像 1-10 这样的小输入范围很容易,但很难测试像 2-10000 这样的范围。 等价划分 帮助我们遵循 七大测试原则 之一:

不可能进行详尽的测试 :不可能测试所有内容,包括输入和先决条件的所有组合。我们可以使用风险和优先级来集中我们的测试工作,而不是进行详尽的测试。例如:在一个应用程序中,一个屏幕上有 15 个输入字段,每个字段有 5 个可能的值。要测试所有有效组合,您需要进行 30,517,578,125 (515) 次测试。项目时间表 不可能允许进行如此数量的测试。评估和管理风险是最重要的活动之一,也是在任何项目中进行测试的原因。

有时编写 1 到 10 个测试来覆盖像 1-10 这样的集合范围可能更便宜,但大多数时候为更大的集合编写 100,000 或数百万个测试是不可行的。因此我们可以使用 基于规范的测试设计技术 测试用例的数量减少到必要的最少

如果我必须为生产编写前面提到的代码并对其进行测试,我可能会使用 测试驱动开发 。然后,我将根据规范要求设计测试场景。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age &lt;= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age &gt; 0 &amp;&amp; age &lt;= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age &gt; 5 &amp;&amp; age &lt;= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age &gt; 18 &amp;&amp; age &lt; 65)
    {
        subscriptionPrice = 40;
    }
    else if (age &gt;= 65 &amp;&amp; age &lt;= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}

如您所见,在我的测试中,我使用了 NUnit TestCase 属性 。执行该方法后,将根据通过属性提供的值执行七个测试。第一个值代表ageInput;第二个是预期价格。

测试用例是使用等价分区派生的。测试用例的数量没有增加。然而,主要区别在于测试是 基于规范要求 ,而不是代码本身。此外,它们是 在代码之前编写的

从表中可以看出,有七个等价分区:四个有效分区和三个无效分区。我用表格最后一行的值覆盖了所有这些。

要牢记的等价分区错误

虽然这种技术相对简单,但人们在应用时确实会犯一些常见错误。

  1. 不同的子集不能有任何共同的成员。如果一个值存在于两个分区中,则您无法定义它在不同情况下的行为方式。
  2. 这些子集都不能为空。如果您不能从一组中选择测试值,则它对测试没有价值。

基于规范的测试:基于边界值分析

那么什么是边值分析呢?

它是一种黑盒测试设计技术,其中测试用例是基于边界值设计的。但是,边界值是什么?

边界值 是在等价分区的边缘或在边缘任一侧的最小增量距离处的输入值或输出值,例如范围的最小值或最大值。

这是一种细化等价划分的技术。 边界值分析 等价划分 的下一部分。其中,测试用例是在等价类的边缘选择的。覆盖标准是每个边界值,无论是有效的还是无效的,都必须在至少一次测试中表示。

主要区别在于每个等价类中至少有两个边界值。所以我们将进行大约两倍的测试。

是否所有等价类都有边界值?

不,绝对不是。边界值分析仅适用于对等价类的成员进行排序时。

有多少边界值?

对于应该存在多少个边界值,有两种观点。大多数人认为只应从等价划分的每条边导出两个值。因此,在以下条件 0 < Age > 6 中,对于第一条边,边界值将为 0, 1 ,对于第二条边,边界值为 5, 6

在他的 《软件系统测试和质量保证》 一书中,Boris Beizer 解释了另一种选择:每个边界三个值,其中每条边都被算作测试值之一,加上它的每个邻居。对于前面的条件,0 < Age > 6,对于 0,测试值将是 -1、0 和 1。对于 6,测试值将是 6 本身、5 和 7。

在我的职业生涯中,我已经尝试过这两种方法,并且我相信使用第二种方法,我已经能够找到更多的错误。因此,无论测试用例数量增加多少,我都鼓励您使用 Boris Beizer 的技术。

使用边界值分析的测试

使用基于边界值分析规范的测试设计技术在实际代码编写过程之前,我仅根据规范要求为 TransportSubscriptionCardPriceCalculator 创建了总共 20 个测试。


 public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);
    if (!isInteger)
    {
        throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
    }

    if (age &lt;= 0)
    {
        throw new ArgumentException("The age should be greater than zero."); 
    }
    else if (age &gt; 0 &amp;&amp; age &lt;= 5)
    {
        subscriptionPrice = 0;
    }
    else if (age &gt; 5 &amp;&amp; age &lt;= 18)
    {
        subscriptionPrice = 20;
    }
    else if (age &gt; 18 &amp;&amp; age &lt; 65)
    {
        subscriptionPrice = 40;
    }
    else if (age &gt;= 65 &amp;&amp; age &lt;= 122)
    {
        subscriptionPrice = 5;
    }
    else
    {
        throw new ArgumentException("The age should be smaller than 123."); 
    }

    return subscriptionPrice;
}

}


为了达到 100% 的边界值分析覆盖率,您只需要前 16 次测试。但是,我又增加了四个测试,因为有时即使测试值属于公共等价分区,也不意味着它们会产生相同的结果。因此,我使用 null、string.Empty、int.Max + 1 和 int.Minimum – 1 测试了 CalculateSubscriptionPrice。

基于需求的边界值

  1. 0 < 年龄 <= 5 – 左边:-1、0、1 右边:4、5、6
  2. 5 < 年龄 <= 18 – 左边:4、5、6 右边:17、18、19
  3. 18 < 年龄 < 65 – 左边:17、18、19 右边:64、65、66
  4. 65 <= 年龄 <= 122 – 左边:64、65、66 右边:121、122、123

您在哪里可以找到边界值?

类的边界值通常基于规范要求,其中解释了系统在不同用例中的行为方式。然而,这些值通常不会在任何现有规范文档中提及。在这种情况下,如果无法更新需求,则可以使用测试预言机。

测试 Oracle:确定预期结果以与被测软件的实际结果进行比较的来源。预言机可以是现有的系统(用于基准测试)、用户手册或个人的专业知识,但它不应该是代码。

例如,如果您开发了一个计算器应用程序并且没有关于它在某些情况下应该如何表现的完整规范,您可以使用 Microsoft Windows 内置计算器来测试 oracle。

结论

您可以使用基于规范的测试设计策略来编写绝对最少的单元测试来覆盖所有需求。等价划分和边界值分析可以使您避免基于潜在错误代码设计测试的邪恶做法,从而产生通过但不正确的测试。使用您对系统的专业知识、您的智慧和直觉来尝试更多的测试值,因为没有完美的测试设计技术。