在TDD工作流程中使用基于属性的测试的教程

100 阅读3分钟

基于属性的测试是一种测试类型,它使用随机生成的输入来测试被测对象的某个属性或特征。你可以将其与更传统的基于实例的测试方法进行对比,即为被测对象提供具体的测试案例。通常情况下,TDD是使用基于例子的测试来完成的。当我们在TDD工作流程中使用基于属性的测试时会发生什么?

我将使用的工具是.Net CoreC#FsCheckFsCheck.XUnit扩展。

我将使用FizzBuzz作为我们的例子问题。如果你不熟悉,可以在这里查看。

如果你想跟着学1.在.Net Core中创建一个新项目。

dotnet new xunit -o PropertyBasedTesting
cd PropertyBasedTesting/
dotnet add package FsCheck
dotnet add package FsCheck.Xunit
  1. 在你的测试文件中添加一些using 语句。

    using System;
    using FsCheck;
    using FsCheck.Xunit;
    

我们的第一个测试

对于我们的第一个测试,让我们确保任何能被3整除且不能被5整除的东西都返回 "Fizz"。

[Property]
public Property anything_divisible_by_three_but_not_five_returns_fizz(int input)
{
    Func<bool> property = () => Fizz.Buzz(input) == "Fizz";

    return property.When(input % 3 == 0 && input % 5 != 0);
}

我们可以通过第一个测试。

public class Fizz
{
    public static string Buzz(int input) => "Fizz";
}

使用FsCheck内置的模糊功能,我们可以为我们的函数生成随机输入。FSCheck还提供了条件属性--这使得我们可以限制我们的输入只能是能被3整除,但不能被5整除的整数。

我们的第二个测试

我们的第二个测试将确保任何能被5整除但不能被3整除的数字将返回 "Buzz"。

[Property]
public Property anything_divisible_by_five_but_not_three_returns_buzz(int input)
{
    Func<bool> property = () => Fizz.Buzz(input) == "Buzz";

    return property.When(input % 5 == 0 && input % 3 != 0);
}

我们可以通过这个测试。

public class Fizz
{
    public static string Buzz(int input) => input % 5 == 0 ? "Buzz" : "Fizz";
}

我们的第三个测试

接下来,让我们测试一下,确保当我们的输入既能被3又能被5整除时,我们返回 "FizzBuzz"。

[Property]
public Property anything_divisible_by_three_and_five_returns_fizzbuzz(int input)
{
    Func<bool> property = () => Fizz.Buzz(input) == "FizzBuzz";

    return property.When(input % 3 == 0 && input % 5 == 0);
}

我试图用下面的实现来通过这个测试,但是遇到了一个错误。该实现。

public class Fizz
{
    public static string Buzz(int input)
    {
        var output = "";

        if (input % 3 == 0) output += "Fizz";
        if (input % 5 == 0) output += "Buzz";

        return output;
    }
}

FsCheck试图为我生成满足条件的随机输入When(input % 3 == 0 && input == 5) ,但它在想出足够的测试案例之前就超时了。经过一番搜索,发现这是FsCheck的预期行为--它在放弃之前只会尝试生成这么多测试数据。我们可以通过为我们的测试数据编写一个自定义生成器来解决这个问题。

实现自定义生成器

自定义生成器允许我们对生成的模糊测试数据施加条件。我想出的实现方法是这样的。

public static class FizzBuzzGenerator
{
    public static Arbitrary<int> Generate()
    {
        return Arb.Default.Int32().Filter(x => x % 3 == 0 && x % 5 == 0);
    }
}

我们可以通过Arbitrary 属性指示我们失败的测试使用新的生成器。

[Property(Arbitrary = new[] { typeof(FizzBuzzGenerator) })]
public Property anything_divisible_by_three_and_five_returns_fizzbuzz(int input)
{

    var actual = Fizz.Buzz(input);

    return (actual == "FizzBuzz").ToProperty();
}

我们的最终测试

我们现在已经到了关键时刻。我们需要做的最后一件事是写一个测试,确认当输入不被3或5整除时,我们返回输入。我们的最后一个测试。

[Property]
public Property anything_not_divisible_by_three_and_five_should_return_value_as_string(int input)
{
    Func<bool> property = () => Fizz.Buzz(input) == input.ToString();

    return property.When(input % 3 != 0 && input % 5 != 0);
}

这个测试目前是失败的。我知道它为什么会失败,但很难知道它是在哪个输入上失败的。我们可以更新测试,给失败打上 "标签",这样我们就可以看到导致测试失败的确切输入。为了添加标签,我们要做以下工作。

[Property]
public Property anything_not_divisible_by_three_and_five_should_return_value_as_string(int input)
{
    Func<bool> property = () => Fizz.Buzz(input) == input.ToString();

    return property.When(input % 3 != 0 && input % 5 != 0).Label($"Failed on input {input}");
}

Label 函数现在将写一个字符串到控制台,包含我们失败的确切输入。有了这个,我们就可以看到失败的情况,并更新我们的FizzBuzz实现,如下所示。

public class Fizz
{
    public static string Buzz(int input)
    {
        var output = "";

        if (input % 3 == 0) output += "Fizz";
        if (input % 5 == 0) output += "Buzz";

        return string.IsNullOrEmpty(output) ? input.ToString() : output;
    }
}

快速回顾

这个练习比我预期的要容易一些。然而,我可以看到,在一个更复杂的领域中,要想找到属性是很困难的。有一些寻找属性的好指南,但我还没有在一个 "真正的 "应用中尝试。我发现TDD的一个好处是,它可以帮助你通过编写简单的测试来发现你的设计,并允许你的解决方案随着时间的推移而 "出现"。在基于属性的测试中,想出的测试并不感觉那么 "简单"。但是--这是我基于缺乏经验的天真的假设。我还发现自己想知道你是否可以使用基于例子的测试来发现属性。

关于基于属性的测试的更多信息,请查看以下链接。

我们的完整源代码在下面。

using System;
using FsCheck;
using FsCheck.Xunit;

namespace property_based_tests
{
    public class FizzBuzzTests
    {
        [Property]
        public Property anything_divisible_by_three_but_not_five_returns_fizz(int input)
        {
            Func<bool> property = () => Fizz.Buzz(input) == "Fizz";

            return property.When(input % 3 == 0 && input % 5 != 0);
        }

        [Property]
        public Property anything_divisible_by_five_but_not_three_returns_buzz(int input)
        {
            Func<bool> property = () => Fizz.Buzz(input) == "Buzz";

            return property.When(input % 5 == 0 && input % 3 != 0);
        }

        [Property(Arbitrary = new[] { typeof(FizzBuzzGenerator) })]
        public Property anything_divisible_by_three_and_five_returns_fizzbuzz(int input)
        {
            var actual = Fizz.Buzz(input);

            return (actual == "FizzBuzz").ToProperty();
        }

        [Property]
        public Property anything_not_divisible_by_three_and_five_should_return_value_as_string(int input)
        {
            Func<bool> property = () => Fizz.Buzz(input) == input.ToString();

            return property.When(input % 3 != 0 && input % 5 != 0).Label($"Failed on input {input}");
        }
    }

    public class Fizz
    {
        public static string Buzz(int input)
        {
            var output = "";

            if (input % 3 == 0) output += "Fizz";
            if (input % 5 == 0) output += "Buzz";

            return string.IsNullOrEmpty(output) ? input.ToString() : output;
        }
    }

    public static class FizzBuzzGenerator
    {
        public static Arbitrary<int> Generate()
        {
            return Arb.Default.Int32().Filter(x => x % 3 == 0 && x % 5 == 0);
        }
    }
}