【问题标题】:looping in unit test bad?在单元测试中循环不好?
【发布时间】:2011-01-08 17:45:52
【问题描述】:

我有一个依赖随机掷骰子的单元测试。我掷了一个 20 面的骰子,如果值为 20,则视为重击。

我现在正在做的是将 20 面骰子滚动 300 次。如果其中任何一个结果是 20,那么我知道我受到了重击。

代码如下:

public class DiceRoll
{
    public int Value { get; set; }
    public bool IsCritical { get; set; }

    // code here that sets IsCritical to true if "Value" gets set to 20
}

[Test]
public void DiceCanRollCriticalStrikes()
{
    bool IsSuccessful = false;
    DiceRoll diceRoll = new DiceRoll();

    for(int i=0; i<300; i++)
    {
        diceRoll.Value = Dice.Roll(1, 20); // roll 20 sided die once
        if(diceRoll.Value == 20 && diceRoll.IsCritical)
        {
            IsSuccessful = true;
            break;
        }
    }

    if(IsSuccessful)
        // test passed
    else
        // test failed 
}

虽然测试完全符合我的要求,但我还是忍不住觉得自己做错了什么。

在相关说明中,DiceRoll 类中还有其他信息,但我的问题是关于单元测试中的循环,所以我将其省略以使其更清楚

【问题讨论】:

    标签: c# unit-testing loops dice


    【解决方案1】:

    这种方法的问题在于您依赖于随机行为。有可能在 300 次滚动中,想要的状态永远不会出现,并且单元测试失败,而测试的代码没有错误。

    我会考虑通过接口(例如“IDiceRoller”)从 Dice 类中提取骰子滚动逻辑。然后您可以在您的应用程序中实现随机骰子滚轮,并在您的单元测试项目中实现另一个骰子滚轮。这将始终返回一个预定义的值。通过这种方式,您可以为特定的骰子值编写测试,而无需求助于循环并希望值显示出来。

    例子:

    (应用程序中的代码)

    public interface IDiceRoller
    {
        int GetValue(int lowerBound, int upperBound);
    }
    
    public class DefaultRoller : IDiceRoller
    {
        public int GetValue(int lowerBound, int upperBound)
        {
            // return random value between lowerBound and upperBound
        }
    }
    
    public class Dice
    {
        private static IDiceRoller _diceRoller = new DefaultRoller();
    
        public static void SetDiceRoller(IDiceRoller diceRoller)
        {
            _diceRoller = diceRoller;
        }
    
        public static void Roll(int lowerBound, int upperBound)
        {
            int newValue = _diceRoller.GetValue(lowerBound, upperBound);
            // use newValue
        }
    }
    

    ...在您的单元测试项目中:

    internal class MockedDiceRoller : IDiceRoller
    {
        public int Value { get; set; }
    
        public int GetValue(int lowerBound, int upperBound)
        {
            return this.Value;
        }
    }
    

    现在,在您的单元测试中,您可以创建一个 MockedDiceRoller,设置您希望骰子获得的值,在 Dice 类中设置模拟骰子滚轮,滚动并验证该行为:

    MockedDiceRoller diceRoller = new MockedDiceRoller();
    diceRoller.Value = 20;
    Dice.SetDiceRoller(diceRoller);
    
    Dice.Roll(1, 20);
    Assert.IsTrue(Dice.IsCritical);
    

    【讨论】:

    • 让我想起了 xkcd 漫画:“int rolldice() { return 4; } // 由真正的骰子生成;保证是随机的”。
    • 好的,这正是我想要的。很好的解释,我正在学习更多关于单元测试的知识。非常感谢。
    • 也看看 mocking。这使您可以建立一些自定义的场景;您不必创建 DefaultRoller 类,因为它将由模拟框架自动创建。
    【解决方案2】:

    虽然测试完全符合我的要求 想要它,我情不自禁地觉得 我做错了什么。

    你的直觉是正确的。无论您掷多少次,都无法从数学上确保您将掷出 20。虽然这很可能会发生,但这并不能构成一个好的单元测试。

    相反,请进行单元测试,以验证是否已注册致命一击 IFF(如果且仅当)掷出 20。

    您可能还想验证您的随机数生成器是否为您提供了良好的分布,但这是另一个单元测试。

    【讨论】:

    • 但是我还要循环吗?或者我是否要重新设计我的 Dice 课程,以便我能够强制自己的结果?例如,即使检查 IFF a 20 isrolled 也意味着我必须循环说 300 次以验证“随机”20 是否已滚动。
    • 不,两个不同的单元测试类。 DiceRoll 类的单元测试以及 Value 和 IsCritical 属性之间的关系。 Dice 类的 Roll 方法的另一个单元测试,它多次调用循环(10K?也许更多?)并验证它是否为您提供了可接受的“随机性”水平。如果您只是包装 .NET Random 类,您可能不会费心编写 Dice.Roll 测试。
    【解决方案3】:

    现在,一个相反的观点:

    在 300 次投掷中得不到 20 次的几率约为 500 万分之一。总有一天,你的单元测试可能会因此而无法通过,但即使你不接触任何代码,它也会在你下次测试时通过。

    我的意思是,你的测试可能永远不会因为运气不好而失败,如果失败了,那又如何?加强这个测试用例的努力可能最好花在项目的其他部分上。如果您想在不使测试更复杂的情况下更加偏执,请将其更改为 400 卷(失败几率:8.14 亿分之一)。

    【讨论】:

    • 我同意五百万分之一的概念。我对原始测试的问题是他正在测试两个不同的东西:1)那 20 = 致命一击。 2) 骰子最终会出现 20 次。您不需要迭代 300 次来测试 #1,并且根据 #2 的实现方式,您可能甚至都懒得写这个测试。
    • 虽然我同意你所说的,但我提出这个问题的原因是为了了解更多有关正确单元测试的信息。
    猜你喜欢
    • 2011-08-16
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-01-25
    • 1970-01-01
    • 1970-01-01
    • 2014-03-03
    • 2010-11-25
    相关资源
    最近更新 更多