如何保持单元测试简单和隔离,并仍然保证DDD不变量?

时间:2010-05-14 10:33:21

标签: unit-testing mocking domain-driven-design stub ddd-repositories

DDD建议域对象随时都应处于有效状态。聚合根负责保证不变量和工厂用于组装具有所有必需部分的对象,以便它们在有效状态下初始化。

然而,这似乎使创建简单,孤立的单元测试的任务变得复杂。

假设我们有一个包含Books的BookRepository。一本书有:

  • 作者
  • a Category
  • 您可以在
  • 中找到该书的书店列表

这些是必需的属性:一本书必须有一个作者,一个类别和至少一个书店,你可以从中购买这本书。 可能有一个BookFactory,因为它是一个非常复杂的对象,而Factory将至少用所有提到的属性初始化Book。也许我们也会将Book构造函数设为私有(以及Factory嵌套),这样就没有人可以实例化除Factory之外的空书。

现在我们要对BookRepository的一个方法进行单元测试,该方法返回所有的Books。为了测试该方法是否返回书籍,我们必须设置一个测试上下文(AAA术语中的Arrange步骤),其中一些Books已经存储在Repository中。

在C#中:

[Test]
public void GetAllBooks_Returns_All_Books() 
{
    //Lengthy and messy Arrange section
    BookRepository bookRepository = new BookRepository();
    Author evans = new Author("Evans", "Eric");
    BookCategory category = new BookCategory("Software Development");
    Address address = new Address("55 Plumtree Road");
    BookStore bookStore = BookStoreFactory.Create("The Plum Bookshop", address);
    IList<BookStore> bookstores = new List<BookStore>() { bookStore };
    Book domainDrivenDesign = BookFactory.Create("Domain Driven Design", evans, category, bookstores);
    Book otherBook = BookFactory.Create("other book", evans, category, bookstores);
    bookRepository.Add(domainDrivenDesign);
    bookRepository.Add(otherBook);

    IList<Book> returnedBooks = bookRepository.GetAllBooks();

    Assert.AreEqual(2, returnedBooks.Count);
    Assert.Contains(domainDrivenDesign, returnedBooks);
    Assert.Contains(otherBook, returnedBooks);
}

鉴于我们处理创建Book对象的唯一工具是Factory,单元测试现在使用并依赖于Factory,并且仅依赖于Category,Author和Store,因为我们需要这些对象来构建Book然后把它放在测试环境中。

您是否会认为这是一种依赖关系,就像在服务单元测试中我们将依赖于服务所调用的存储库一样?

为了能够测试一个简单的东西,你如何解决重新创建整个对象集群的问题?你如何打破这种依赖性并摆脱我们在测试中不需要的所有这些Book属性?通过使用模拟或存根?

如果你模拟了存储库包含的东西,你会使用什么样的模拟/存根,而不是当你模拟测试对象消费

7 个答案:

答案 0 :(得分:4)

两件事:

  • 在测试中使用模拟对象。您目前正在使用具体对象。

  • 关于复杂的设置,在某些时候你需要一些有效的书籍。将此逻辑提取到设置方法,以在每次测试之前运行。让该设置方法创建有效的书籍集合等等。

  

“你如何解决问题?   不得不重新创建一个完整的集群   对象,以便能够测试   简单的事情?你怎么打破   依赖和摆脱所有   这些Book属性我们不需要   我们的测试?通过使用模拟或存根?“

模拟对象可以让你这样做。如果测试只需要具有有效作者的书籍,则模拟对象将指定该作者,其他属性将被默认。由于您的测试只关心有效的作者,因此无需设置其他属性。

答案 1 :(得分:3)

对于纯单元测试,模拟和存根绝对是解决方案。但是,由于您正在进行更多的集成级别测试,并且模拟(或存根或其他)无法解决您的问题,因此您确实有两个合理的选择:

  • 创建测试工厂以帮助您设置所需的数据。这些可能是特定于测试的,它不仅构建一个书店,而且用合理的设置书籍填充它。这样,您可以将设置代码压缩为一行或两行,并将其用于其他测试。此代码可能会增长,以创建集成类型测试所需的各种方案。

  • 创建一个设置测试装置。这些是小型的,但在概念上是完整的数据集供您的测试使用。它们通常以某种序列化形式(xml,csv,sql)存储,并在每次测试开始时加载到数据库中,以便您具有有效状态。它们实际上只是一个通用读取静态文件的工厂。

如果您使用灯具,您可以采用单个或多个灯具方法。如果您可以为大多数单元测试使用单个“规范”数据集,那么这将更简单,但有时会创建一个数据集,该数据集包含太多无法理解的记录,或者根本无法表达范围您需要支持的方案。有些问题需要对多组数据进行全面测试。

答案 2 :(得分:1)

感谢Finglas的回答。我在其他测试中使用模拟但主要用于交互测试,而不是用于设置测试上下文。我不确定这种具有所需值的空心物体是否可以称为模拟物,如果使用它们是个好主意。

我在Gerard Meszaros的xunitpatterns.com上找到了一些有趣且非常接近问题的东西。他将代码气味描述为Irrelevant Information长而复杂的测试设置,可能的解决方案为Creation MethodsDummy Objects。我的Dummy Object实现并没有完全卖掉,因为在我的例子中,它会迫使我有一个IBook接口(ugh),以便用一个非常简单的构造函数实现一个虚拟Book并绕过所有Factory创建逻辑。

我想混合隔离框架生成的模拟和创建方法可以帮助我澄清和简化我的测试。

答案 3 :(得分:1)

您可能想尝试测试数据生成器。很好post from Nat Pryce

如果您不想走模拟路线,这可以提供帮助。它可以抽象出所有那些丑陋的工厂方法。您也可以尝试将构建器推送到生产代码中使用。

答案 4 :(得分:1)

  

也许我们也会制作这本书   构造函数私有(和工厂   嵌套)这样就没有人可以实例化了   除工厂外的一本空书。

私有Book构造函数是您的问题的根源。

如果改为使Book的构造函数为内部,则工厂不必嵌套。然后你可以自由地使工厂实现一个接口(IBookFactory),你可以将一个模拟书工厂注入你的存储库。

如果您真的想确保只有图书工厂实现创建实例,请向您的存储库添加一个接受工厂所需参数的方法:

public class BookRepository {

    public IBookFactory bookFactory;

    public BookRepository(IBookFactory bookFactory) {
        this.bookFactory = bookFactory;
    }

    // Abbreviated list of arguments
    public void AddNew(string title, Author author, BookStore bookStore) {
        this.Add(bookFactory.Create(title, author, bookStore));
    }

}

答案 5 :(得分:0)

II可能有偏见,因为我已经开始与CQRS一起学习DDD。但我不确定你是否画出了正确的界限。聚合应该只知道它的不变量。你说一本书有作者。是的,但这本书对作者的名字没有任何不变性。 所以我们可以将整本书描述如下:

 public class Book
 {
     public Guid _idAuthor;

     public Book(Guid idAuthor)
     {
         if(idAuthor==guid.empty) throw new ArgumentNullException();

         _idAuthor = idAuthor;
     }
 }

然而,作者对其作者有一个不变量:

 public class Author
 {
     public string _name;

     public Book(string name)
     {
         if(name==nullorEmpty) throw new ArgumentNullException();

         _name= name;
     }
 }

虽然查询方可能需要信息书名和作者名,但这是一个查询,可能不适合单位测试IMO。

如果你需要能够添加到你的图书馆,只有当他们的作者中有字母“e”时才会预订,那么整个讨论会有所不同,但根据我的理解,你现在不需要它。

创建聚合Book时,单元测试变得更简单,因为您专注于写入侧和真正的不变量。

答案 6 :(得分:0)

如果我正确理解了这个问题,OP希望减少设置每个对象的麻烦,并以某种方式轻松地创建域对象的层次结构。如果是这种情况,那么[javaFX 11是一个很好的工具。或者,如果问题是关于为什么我们应该创建所有对象以创建另一个域对象,我猜答案是“取决于”。如果被测系统(SUT)是聚合根,则意味着它无论如何都处理所有其他对象的生命周期,如果SUT是其他对象,则AutoFixture可以帮助我们创建这些对象。完全可定制