如何构造表现出相同行为的多个对象的单元测试?


9

在很多情况下,我可能有一个具有某些行为的现有类:

class Lion
{
    public void Eat(Herbivore herbivore) { ... }
}

我有一个单元测试

[TestMethod]
public void Lion_can_eat_herbivore()
{
    var herbivore = buildHerbivoreForEating();
    var test = BuildLionForTest();
    test.Eat(herbivore);
    Assert.IsEaten(herbivore);
}

现在,发生的事情是我需要创建一个与Lion具有类似行为的Tiger类:

class Tiger
{
    public void Eat(Herbivore herbivore) { ... }
}

...由于我想要相同的行为,因此我需要运行相同的测试,因此我需要执行以下操作:

interface IHerbivoreEater
{
    void Eat(Herbivore herbivore);
}

...然后重构测试:

[TestMethod]
public void Lion_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildLionForTest);
}


public void IHerbivoreEater_can_eat_herbivore(Func<IHerbivoreEater> builder)
{
    var herbivore = buildHerbivoreForEating();
    var test = builder();
    test.Eat(herbivore);
    Assert.IsEaten(herbivore);
}

...然后为我的新Tiger课程添加另一个测试:

[TestMethod]
public void Tiger_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}

...然后我重构我Lion和的Tiger类(通常通过继承,但有时通过组合):

class Lion : HerbivoreEater { }
class Tiger : HerbivoreEater { }

abstract class HerbivoreEater : IHerbivoreEater
{
    public void Eat(Herbivore herbivore) { ... }
}

...一切都很好。但是,由于该功能已包含在HerbivoreEater类中,因此现在感觉在每个子类上对这些行为中的每一个进行测试都存在问题。然而,实际上是这些子类被消耗了,并且仅仅是实现细节,它们碰巧共享重叠的行为(例如LionsTigers可能具有完全不同的最终用途)。

多次测试相同的代码似乎是多余的,但是在某些情况下,子类可以并且确实会覆盖基类的功能(是的,它可能违反了LSP,但请直面它,IHerbivoreEater它只是一个方便的测试接口-它对最终用户可能没有关系)。因此,我认为这些测试确实有价值。

在这种情况下其他人会做什么?您只是将测试移至基类,还是测试所有子类的预期行为?

编辑

基于@pdr的答案,我认为我们应该考虑这一点:IHerbivoreEater仅仅是方法签名合同;它没有指定行为。例如:

[TestMethod]
public void Tiger_eats_herbivore_haunches_first()
{
    IHerbivoreEater_eats_herbivore_haunches_first(BuildTigerForTest);
}

[TestMethod]
public void Cheetah_eats_herbivore_haunches_first()
{
    IHerbivoreEater_eats_herbivore_haunches_first(BuildCheetahForTest);
}

[TestMethod]
public void Lion_eats_herbivore_head_first()
{
    IHerbivoreEater_eats_herbivore_head_first(BuildLionForTest);
}

为了争辩,您不应该有一个Animal包含的类Eat吗?所有动物都进食,因此Tigerand Lion类可以从动物继承。
松饼人

1
@Nick-很好,但是我认为情况有所不同。正如@pdr指出的,如果将Eat行为放在基类中,则所有子类都应表现出相同的Eat行为。但是,我说的是2个相对不相关的类,它们碰巧共享一种行为。例如,考虑的Fly行为,Brick并且Person我们可以假设它表现出类似的飞行行为,但是让它们从通用基类派生并不一定有意义。
Scott Whitlock

Answers:


6

这很棒,因为它显示了测试如何真正驱动您对设计的思考方式。您正在感知设计中的问题并提出正确的问题。

有两种查看方法。

IHerbivoreEater是合同。所有IHerbivoreEaters必须具有可以接受草食动物的Eat方法。现在,您的测试不在乎如何食用;您的狮子可能从臀部开始,而老虎可能从喉咙开始。您测试所关心的只是在吃掉草食动物后将其吃掉。

另一方面,您要说的部分是,所有IHerbivoreEaters都以完全相同的方式吃草食动物(因此是基类)。在这种情况下,完全没有IHerbivoreEater合同是没有意义的。它什么也没提供。您可能还只是继承自HerbivoreEater。

或完全淘汰Lion和Tiger。

但是,如果Lion和Tiger在饮食习惯上在各个方面都不同,那么您需要开始怀疑是否会遇到复杂的继承树问题。如果您还想从Feline派生两个类,或者仅从KingOfItsDomain(也许和Shark)派生一个Lion类,该怎么办?这就是LSP真正出现的地方。

我建议更好地封装通用代码。

public class Lion : IHerbivoreEater
{
    private IHerbivoreEatingStrategy _herbivoreEatingStrategy;
    private Lion (IHerbivoreEatingStrategy herbivoreEatingStrategy)
    {
        _herbivoreEatingStrategy = herbivoreEatingStrategy;
    }

    public Lion() : this(new StandardHerbivoreEatingStrategy())
    {
    }

    public void Eat(Herbivore herbivore)
    {
        _herbivoreEatingStrategy.Eat(herbivore);
    }
}

老虎也是如此。

现在,这是一个美丽的事物(因为我不打算这样做,所以很漂亮)。如果仅使该私有构造函数可用于测试,则可以传递一个伪造的IHerbivoreEatingStrategy,然后简单地测试消息是否正确传递给封装的对象。

而您最初所担心的复杂测试只需测试StandardHerbivoreEatingStrategy。一堂课,一组测试,不需要重复的代码。

而且,如果以后您想告诉老虎,它们应该以不同的方式吃草食动物,则这些测试都无需改变。您只是在创建一个新的HerbivoreEatingStrategy并对其进行测试。接线在集成测试级别上进行测试。


+1策略模式是我首先想到的问题。
StuperUser 2011年

很好,但现在将我的问题中的“单元测试”替换为“集成测试”。我们不会遇到同样的问题吗?IHerbivoreEater是一份合同,但仅限于我需要进行测试的情况。在我看来,这是鸭式打字确实有帮助的一种情况。我现在只想将它们都发送到相同的测试逻辑。我认为界面不应该对这种行为作出承诺。测试应该做到这一点。
Scott Whitlock

好问题,好答案。您不必具有私有的构造函数。您可以使用IoC容器将IHerbivoreEatingStrategy连接到StandardHerbivoreEatingStrategy。
azheglov

@ScottWhitlock:“我不认为该接口应对此行为作出承诺。测试应做到这一点。” 我就是这么说的 如果确实可以实现这种行为,则应该摆脱它,而只需使用(base-)类。您根本不需要测试。
pdr

@azheglov:同意,但是我的回答已经足够长了:)
pdr

1

多次测试相同的代码似乎是多余的,但是在某些情况下,子类可以并且确实会覆盖基类的功能。

您会以一种环回的方式询问是否适合使用白盒知识来省略某些测试。从黑盒的角度来看,LionTiger属于不同类别。因此,不熟悉该代码的人将对其进行测试,但是您拥有丰富的实施知识,您知道可以简单地测试一只动物就摆脱困境。

开发单元测试的部分原因是允许您自己稍后重构,但保持相同的黑盒界面。单元测试可以帮助您确保班级继续履行与客户的合同,或者至少迫使您意识到并仔细考虑合同的变化。您自己意识到这一点,Lion或者Tiger可能Eat在以后的某个时间超越。如果可以远程进行,则可以通过以下简单的单元测试来测试您所支持的每只动物都可以食用:

[TestMethod]
public void Tiger_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}

应该做的很简单并且足够,并且将确保您可以检测到对象何时无法满足其约定。


我想知道这个问题是否真的归结为对黑盒测试和白盒测试的偏好。我倾向于黑匣子训练营,这也许就是为什么我要测试自己的方式。感谢您指出了这一点。
斯科特·惠特洛克

1

您做对了。将单元测试视为对新代码的单次使用行为的测试。这与从生产代码进行的调用相同。

在这种情况下,您是完全正确的;Lion或Tiger的用户将(至少不必)关心它们都是HerbivoreEaters,并且实际上为该方法运行的代码在基类中都是相同的。同样,HerbivoreEater摘要(由具体的Lion或Tiger提供)的用户也不会在意它的内容。他们关心的是,他们的Lion,Tiger或未知的HerbivoreEater具体实现会正确地Eat()草食动物。

因此,您基本上要测试的是狮子会按预期进食,而老虎会按预期进食。对两者进行测试很重要,因为两者的进食方式不一定完全相同。通过对两者进行测试,可以确保您不想更改的内容都没有。因为这两个都是草食动物,所以至少在添加猎豹之前,您还测试了所有草食动物都将按预期食用。您的测试将完全覆盖并充分执行代码(前提是您还对吃草食动物的草食动物的所有预期结果做出了所有预期的断言)。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.