【问题标题】:How to design and test methods with complex orchestrating logic?如何设计和测试具有复杂编排逻辑的方法?
【发布时间】:2014-08-04 20:17:17
【问题描述】:

我将从简短的例子开始我的问题:

SomeResult DoSomething(input)
{
    var a = svc1.getA(input);
    if (condition with a)
    {
        var b = svc2.getB(a);
        if (cond with b)
        {
            var c = svc3.getC(b);
            if (cond with c)
            {
            }
            else
            {
            }
        }
        else
        {
        }
    }
    else
    {
    }
}

我相信这里的想法很清楚。我们有复杂的分支逻辑,其中条件取决于注入服务返回的中间结果。

当我们想要cond with c 的部分时,我们必须模拟svc1svc2svc3。 要出现在cond with b,我们必须模拟svc1svc2

因此,每次我们更深入时,我们都会重播执行路径的所有上部。猜猜它通常是怎么做的?宾果游戏,复制粘贴!
我们有一堆单元测试,其中大部分行都被对象的(a,b,c ...)初始化和服务模拟占用。当abc 是具有数十个属性的对象时,这一切看起来就像一个真正的地狱。 cond with a 的微小变化可以轻松同时破坏 20 个测试。

我坚持有一些“跳到我想测试的地方”的概念。

如果我们这样修改代码会怎样:

SomeResult DoSomething(input)
{
    var a = svc1.getA(input);
    if (condition with a)
    {
        var b = svc2.getB(a);
        if (cond with b)
        {
            ProcessBLikeThis(b);
        }
        else
        {
        }
    }
    else
    {
    }
}

然后我们可以将ProcessBLikeThis与不相关的逻辑分开测试。
然而,为了使其可测试,它必须是公开的。此外,由于我们希望通过测试验证 ProcessBLikeThis 是根据cond with b 使用给定参数调用的,因此我们需要使用隔离器或使ProcessBLikeThis 成为某个接口的方法。

但是,除了 DRY-adherent 可测试性之外,这种粒度设计没有其他必要性。

因此,我希望能提供一些关于如何设计和测试此类方法的指导。

加法:

我也忘了提到我的队友强烈反对将初始化逻辑放在可重用的方法中,因为他们认为可以放在那里和不能放在那里之间没有严格的界限,并期望有一天有人会扩展代码并破坏测试逻辑。他们更喜欢复制粘贴作为一种隔离手段。

【问题讨论】:

  • 你是说初始化逻辑不是一个选项?或者只是你的队友不喜欢它?
  • 对我来说,这不仅是一种选择,而且是很自然的事情。是的,他们有一些恐惧而不是不喜欢。
  • 你在使用 C# 和 Visual Studio 吗?
  • C#,VS2013: MSTest + TypeMockIsolator
  • 您需要以不同的方式分解代码,以使其既可维护又可测试。这里给出的例子让我想起了the example problem that I here explain how to address

标签: unit-testing testing dry


【解决方案1】:

我的队友强烈反对将初始化逻辑放在可重用的方法中,因为他们认为可以放在那里和不能放在那里之间没有严格的界限,并期望有一天有人会扩展代码并破坏测试逻辑。他们更喜欢复制/粘贴作为隔离的手段。

如果您的团队想要重复自己,而您不希望他们这样做,那么需要进行讨论并形成共识,以便每个人都怀着相同的目标工作。重复一些测试设置代码是有争议的,通常取决于可读性,但是,这通常可以通过合理地命名任何方法和变量来克服,这样它们的用法就很明显了。

测试不能重用代码,因为有人可能会更改共享代码并中断测试的论点是一个空论点。如果有人 确实 更改了共享逻辑并且一堆测试没有破坏,那么您将遇到更大的问题。正如您所说,更可能的情况是生产代码中的一个小改动将导致一堆测试失败。如果测试不共享相关的设置代码,那么修复很可能会被盲目地复制/粘贴到每个测试中以使其正常工作。

也就是说,简化测试的常用方法是创建不同级别的间接性,从而减少测试。对于您发布的代码,一种方法可能是将流逻辑与操作逻辑分开。

您最终可能会得到类似这样的代码(名称显然需要根据您的情况量身定制):

interface ISomeActioner {
    bool IsTriggered( SomeStateProvider state);
    SomeResult TriggeredAction(SomeStateProvider state);
    SomeResult UntriggeredAction(SomeStateProvider state);
}

SomeResult DoSomething(input) {
    SomeResult result = Unknown;
    foreach(var actioner in _someActions) {
        if(IsTriggered(/* some state provider */)) {
            result = actioner.TriggeredAction(/* some state provider */);
        } else {
            result = actioner.UntriggeredAction(/* some state provider */);
        }
        if(result != Unknown) break;
    }
    return result;
}

然后您最终实现了几个实现ISomeActioner 接口的类。这些类中的每一个都很简单。它检查来自状态提供者的状态并返回一个标志来指示应该调用它的哪些其他函数。这些类可以单独进行测试,以确保每个公共方法都符合预期,方法是在调用其每个方法之前将SomeStateProvider 设置为适当的状态。

然后需要将这些类的有序列表注入到包含DoSomething 方法的类中。这允许您在测试 DomeSomething 方法时使用接口的模拟实例,这实际上变成了对 for 循环的测试。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-12-27
    • 2019-04-27
    • 1970-01-01
    • 2020-05-04
    • 2018-01-16
    相关资源
    最近更新 更多