在“无人问津”的世界中进行单元测试


23

我不认为自己是DDD专家,但作为解决方案架构师,请尽可能尝试应用最佳实践。我知道围绕DDD中的(公共)二传手“样式”的赞成和反对有很多讨论,我可以看到论点的两面。我的问题是,我在一个拥有各种技能,知识和经验的团队中工作,这意味着我不能相信每个开发人员都会以“正确”的方式做事。例如,如果我们设计域对象以便通过一种方法来执行对对象内部状态的更改,但提供公共属性设置器,则有人将不可避免地设置该属性而不是调用该方法。使用此示例:

public class MyClass
{
    public Boolean IsPublished
    {
        get { return PublishDate != null; }
    }

    public DateTime? PublishDate { get; set; }

    public void Publish()
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        PublishDate = DateTime.Today;

        Raise(new PublishedEvent());
    }
}

我的解决方案是将属性设置器设为私有,这是可能的,因为我们用来为对象水合的ORM使用反射,因此它能够访问私有设置器。但是,这在尝试编写单元测试时会出现问题。例如,当我想编写一个单元测试来验证我们不能重新发布的要求时,我需要指出该对象已经发布。我当然可以通过两次调用Publish来做到这一点,但是我的测试假设第一次调用正确实现了Publish。好像有点臭。

让我们使用以下代码使场景更真实一些:

public class Document
{
    public Document(String title)
    {
        if (String.IsNullOrWhiteSpace(title))
            throw new ArgumentException("title");

        Title = title;
    }

    public String ApprovedBy { get; private set; }
    public DateTime? ApprovedOn { get; private set; }
    public Boolean IsApproved { get; private set; }
    public Boolean IsPublished { get; private set; }
    public String PublishedBy { get; private set; }
    public DateTime? PublishedOn { get; private set; }
    public String Title { get; private set; }

    public void Approve(String by)
    {
        if (IsApproved)
            throw new InvalidOperationException("Already approved.");

        ApprovedBy = by;
        ApprovedOn = DateTime.Today;
        IsApproved = true;

        Raise(new ApprovedEvent(Title));
    }

    public void Publish(String by)
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        if (!IsApproved)
            throw new InvalidOperationException("Cannot publish until approved.");

        PublishedBy = by;
        PublishedOn = DateTime.Today;
        IsPublished = true;

        Raise(new PublishedEvent(Title));
    }
}

我想编写验证以下内容的单元测试:

  • 除非文件获得批准,否则我无法发布
  • 我无法重新发布文档
  • 发布时,正确设置了PublishedBy和PublishedOn值
  • 发布后,将引发PublishedEvent

如果无法访问设置器,则无法将对象置于执行测试所需的状态。对二传手的开放访问将阻止阻止访问的目的。

您如何解决此问题?


我越想这件事,就越觉得整个问题都带有副作用的方法。更确切地说,是可变的不可变对象。在DDD世界中,是否不应该同时从Approve和Publish中返回一个新的Document对象,而不是更新该对象的内部状态?
pdr 2013年

1
快速问题,您正在使用哪个O / RM。我是EF的忠实拥护者,但是将设置者声明为受保护的对象确实使我感到有点不对劲。
迈克尔·布朗

由于我负责处理自由范围开发,因此我们现在混在一起。一些使用AutoMapper从DataReader中合并的ADO.NET,两个Linq-to-SQL模型(将替代下一个) )和一些新的EF模型。
SonOfPirate 2013年

两次调用Publish根本没有什么臭味,这是解决问题的方法。
2013年

Answers:


27

我无法将对象置于执行测试所需的状态。

如果无法将对象置于执行测试所需的状态,则无法在生产代码中将对象置于状态,因此不需要测试状态。显然,在您的情况下情况并非如此,您可以将对象置于所需状态,只需调用Approve。

  • 除非文档已获得批准,否则我无法发布:编写一个测试,要求在批准之前调用publish会导致正确的错误而不更改对象状态。

    void testPublishBeforeApprove() {
        doc = new Document("Doc");
        AssertRaises(doc.publish, ..., NotApprovedException);
    }
    
  • 我无法重新发布文档:编写一个批准对象的测试,然后一次成功调用publish,但是第二次在不更改对象状态的情况下导致正确的错误。

    void testRePublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        AssertRaises(doc.publish, ..., RepublishException);
    }
    
  • 发布后,正确设置了PublishedBy和PublishedOn值:编写一个调用批准然后调用publish的测试,断言对象状态正确更改

    void testPublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        Assert(doc.PublishedBy, ...);
        ...
    }
    
  • 发布后,将引发PublishedEvent:挂接到事件系统并设置一个标志以确保它被调用

您还需要编写批准测试。

换句话说,不要测试内部字段与IsPublished和IsApproved之间的关系,如果这样做,则测试将非常脆弱,因为更改字段将意味着更改测试代码,因此该测试将毫无意义。相反,您应该以这种方式测试公共方法的调用之间的关系,即使您修改了字段也不需要修改测试。


批准中断时,几个测试将中断。您不再测试代码单元,而是测试完整的实现。
pdr 2013年

我同意PDR的关注,这就是为什么我会犹豫不决。是的,这看起来最干净,但是我不喜欢有多个原因导致单个测试失败。
SonOfPirate

4
我还没有看到只能因一个可能的原因而失败的单元测试。另外,您可以将测试的“状态操作”部分放入setup()方法中,而不是测试本身。
Peter K.

12
为什么要approve()以某种方式脆性而又以setApproved(true)某种方式不脆性? approve()是测试中的合法依赖项,因为它是需求中的依赖项。如果依赖性仅存在于测试中,那将是另一个问题。
Karl Bielefeldt

2
@pdr,您将如何测试堆栈类?您会尝试独立测试push()pop()方法吗?
Winston Ewert

2

另一种方法是创建类的构造函数,该构造函数允许在实例化时设置内部属性:

 public Document(
  String approvedBy,
  DateTime? approvedOn,
  Boolean isApproved,
  Boolean isPublished,
  String publishedBy,
  DateTime? publishedOn,
  String title)
{
  ApprovedBy = approvedBy;
  ApprovedOn = approvedOn;
  IsApproved = isApproved;
  IsApproved = isApproved;
  PublishedBy = publishedBy;
  PublishedOn = publishedOn;
}

2
这根本无法很好地扩展。我的对象可以具有更多的属性,其中任意数量的对象在其生命周期中的任何给定点都具有或不具有值。我遵循的原则是,构造函数包含对象处于有效初始状态或对象运行所需的依赖项所必需的属性的参数。该示例中属性的目的是在操纵对象时捕获当前状态。具有每个属性的构造函数或具有不同组合的重载是一种巨大的气味,而且正如我所说的那样,它无法扩展。
SonOfPirate

明白了 您的示例没有提及更多的属性,示例中的数字只是将其作为有效方法的“风口浪尖”。似乎这在告诉您有关您的设计的一些信息:不能在实例化时将对象置于任何有效状态。这意味着您需要将其置于有效的初始状态,然后他们将其操纵为正确的状态以进行测试。这意味着李·瑞安的答案是前进的道路
Peter K.

即使对象具有一个属性并且永远不会更改,这种解决方案也是不好的。是什么阻止任何人在生产中使用此构造函数?您将如何标记此构造函数[TestOnly]?
2013年

为什么生产不好?(真的,我想知道)。有时有必要在创建时重新创建对象的精确状态,而不仅仅是单个有效的初始对象。
彼得·K

1
因此,尽管这有助于将对象置于有效的初始状态,但要在其生命周期中对其进行测试以测试其行为,则需要从初始状态更改该对象。当您不能简单地设置属性来更改对象的状态时,我的OP就要测试这些其他状态。
SonOfPirate

1

一种策略是您继承该类(在本例中为Document)并针对继承的类编写测试。继承的类允许通过某种方式设置测试中的对象状态。

在C#中,一种策略可能是将设置器内部化,然后将内部结构暴露给测试项目。

您也可以像描述的那样使用类API(“我当然可以通过两次调用Publish来做到这一点”)。这将使用对象的公共服务来设置对象状态,对我来说似乎并不难。就您的示例而言,这可能就是我要做的方法。


我以为这是一个可能的解决方案,但犹豫要使我的属性可重写或将设置程序公开为受保护的,因为感觉就像我在打开对象并破坏封装。我认为使财产受到保护肯定比公共甚至内部/朋友更好。我一定会多考虑一下这种方法。简单有效。有时这是最好的方法。如果有人不同意,请添加详细说明。
SonOfPirate

1

为了绝对隔离地测试域对象接收到的命令和查询,我习惯为每个测试提供对象处于预期状态的序列化。在测试的“安排”部分,它从我之前准备的文件中加载要测试的对象。起初,我从二进制序列化开始,但是事实证明,json易于维护。每当测试中的绝对隔离提供实际价值时,这证明行之有效。

编辑一个注释,有时JSON序列化会失败(就像循环对象的图一样,这是一种气味,顺便说一句)。在这种情况下,我可以进行二进制序列化。这有点实用,但是可以。:-)


如果没有设置器并且不想调用它的公共方法来设置它,那么如何准备处于预期状态的对象?
2013年

我为此写了一个小工具。它通过反射加载一个类,该类使用其公共构造函数创建一个新实体(通常仅使用标识符),并在其上调用一个Action <TEntity>数组,在每次操作后保存一个快照(使用基于操作索引的常规名称)和实体名称)。该工具在每次重构实体代码时手动执行,并且快照由DCVS跟踪。显然,每个Action都调用该实体的公共命令,但这是在测试运行中完成的,这种方式确实是单元测试。
Giacomo Tesio

我不知道那会如何改变。如果它仍然在sut(被测系统)上调用公共方法,则没有什么区别,只是在测试中调用那些方法。
2013年

生成快照后,它们将存储在文件中。每个测试不依赖于获取实体的初始状态所需的操作顺序,而是取决于状态本身(从快照加载)。然后,将被测方法本身与其他方法的更改隔离开来。
Giacomo Tesio

当有人更改用于为测试准备序列化状态的公共方法而忘记运行该工具来重新生成序列化对象时该怎么办?即使代码有错误,测试仍然是绿色的。我仍然说这不会改变任何东西。您仍然运行公共方法,因此请设置要测试的对象。但是您要在运行测试之前就运行它们。
2013年

-7

你说

尽可能尝试应用最佳做法

我们用来水合对象的ORM使用反射,因此它能够访问私有设置器

而且我不得不认为,使用反射绕过类的访问控制并不是我所描述的“最佳实践”。它也将非常缓慢。


就个人而言,我会废弃您的单元测试框架,并在类中添加一些内容-看来您是从测试整个类的角度编写测试,这很好。过去,对于一些需要测试的棘手组件,我将断言和设置代码嵌入到类本身中(以前是在每个类中都有一个test()方法的常见设计模式),因此您创建了一个客户端它只是实例化一个对象并调用测试方法,该方法可以根据需要进行设置,而不会像反射hack那样令人讨厌。

如果您担心代码膨胀,只需将测试方法包装在#ifdefs中,以使其仅在调试代码中可用(可能是最佳实践本身)


4
-1:废弃测试框架并返回类中的测试方法将回到单元测试的黑暗年代。
罗伯·约翰逊

9
我没有-1,但是通常在生产中包含测试代码是Bad Thing(TM)
Peter K.

OP还做什么?坚持与私人二传手搞砸吗?就像选择要喝的毒药一样。我对OP的建议是将单元测试放入调试代码中,而不是生产中。以我的经验,将单元测试放在另一个项目中仅意味着该项目无论如何都与原始项目紧密相关,因此与dev PoV几乎没有区别。
gbjbaanb
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.