10

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

然而,这似乎使创建简单的、独立的单元测试的任务复杂化了很多。

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

  • 一位作家
  • 一个类别
  • 您可以在其中找到这本书的书店列表

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

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

在 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 属性?通过使用模拟或存根?

如果你模拟存储库包含的东西,你会使用什么样的模拟/存根,而不是模拟被测对象与之交谈消费的东西?

4

7 回答 7

4

两件事情:

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

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

“您将如何解决必须重新创建整个对象集群以便能够测试一个简单事物的问题?您将如何打破这种依赖关系并摆脱我们在我们不需要的所有这些 Book 属性测试?通过使用模拟或存根?

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

于 2010-05-14T12:45:34.960 回答
3

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

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

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

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

于 2010-05-17T15:00:41.137 回答
2

感谢芬格拉斯的回答。我确实在其他测试中使用了模拟,但主要用于交互测试,而不是设置测试上下文。我不确定这种只有所需值的空心对象是否可以称为模拟,以及使用它们是否是个好主意。

我在 Gerard Meszaros 的 xunitpatterns.com 上发现了一些有趣且非常接近问题的东西。他将冗长而复杂的测试设置的代码味道描述为无关信息,可能的解决方案是创建方法虚拟对象。不过,我并没有完全接受他的 Dummy Object 实现,因为在我的示例中,它会迫使我拥有一个 IBook 接口(呃),以便使用非常简单的构造函数实现一个虚拟 Book 并绕过所有工厂创建逻辑。

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

于 2010-05-14T15:13:57.030 回答
1

You might want to try a Test Data Builder. Nice post from Nat Pryce.

This can help if you don't want to go the route of mocks. It can abstract away all those ugly factory methods. Also you can try to push the builders to be used in your production code.

于 2010-05-14T16:38:49.913 回答
1

也许我们也会将 Book 构造函数设为私有(以及嵌套的 Factory),这样除了 Factory 之外,没有人可以实例化一个空的 Book。

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

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

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

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));
    }

}
于 2010-05-17T15:05:31.940 回答
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 时,您的单元测试会变得更简单,因为您关注的是写入端和真正的不变量。

于 2013-01-02T15:06:48.167 回答
0

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

于 2019-09-03T22:56:40.487 回答