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

发布于 2024-09-01 04:10:39 字数 1858 浏览 3 评论 0原文

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

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

假设我们有一个包含书籍的 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 属性?通过使用模拟或存根?

如果您模拟存储库包含的内容,您会使用哪种模拟/存根,而不是模拟被测试对象与之对话的内容消耗?

DDD recommends that the domain objects should be in a valid state at any time. Aggregate roots are responsible for guaranteeing the invariants and Factories for assembling objects with all the required parts so that they are initialized in a valid state.

However this seems to complicate the task of creating simple, isolated unit tests a lot.

Let's assume we have a BookRepository that contains Books. A Book has :

  • an Author
  • a Category
  • a list of Bookstores you can find the book in

These are required attributes : a book has to have an author, a category and at least a book store you can buy the book from.
There's likely to be a BookFactory since it is quite a complex object, and the Factory will initialize the Book with at least all the mentioned attributes. Maybe we'll also make the Book constructor private (and the Factory nested) so that no one can instantiate an empty Book except the Factory.

Now we want to unit test a method of the BookRepository that returns all the Books. To test if the method returns the books, we have to set up a test context (the Arrange step in AAA terms) where some Books are already in the Repository.

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

Given that the only tool at our disposal to create Book objects is the Factory, the unit test now uses and is dependent on the Factory and inderectly on Category, Author and Store since we need those objects to build up a Book and then place it in the test context.

Would you consider this is a dependency in the same way that in a Service unit test we would be dependent on, say, a Repository that the Service would call ?

How would you solve the problem of having to re-create a whole cluster of objects in order to be able to test a simple thing ? How would you break that dependency and get rid of all these Book attributes we don't need in our test ? By using mocks or stubs ?

If you mock up things a Repository contains, what kind of mock/stubs would you use as opposed to when you mock up something the object under test talks to or consumes ?

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(7

所谓喜欢 2024-09-08 04:11:15

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

If I understand the question correctly, the OP wants to reduce the clutter in setting up each and somehow create the hierarchy of domain objects easily. If this is the case, then [https://github.com/AutoFixture/AutoFixture] is a great tool. Or if the question is about why we should create all the objects to create another domain object, I guess the answer is "It depends". If the system under test (SUT) is an aggregate root then it means it deals with life cycle of all other objects anyways, if SUT is some other object, then AutoFixture can help creating those objects for us. It is completely customizable

对你而言 2024-09-08 04:11:11

II 可能有偏见,因为我已经开始学习 DDD 和 CQRS。但我不确定你是否划定了正确的界限。聚合应该只知道它的不变量。你说一本书有一个作者。是的,但这本书对作者的名字没有不变。
因此,我们可以将聚合书籍描绘如下:

 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 时,您的单元测试会变得更简单,因为您专注于写入端和真正的不变量。

II might be biased because I have started learning DDD along side with CQRS. But I am not sure you draw the correct boundaries. An aggregate should know only about its invariants. You say a book has an author. Yes but the book has no invariant on the name of the author.
so we could picture the aggregate book as follows :

 public class Book
 {
     public Guid _idAuthor;

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

         _idAuthor = idAuthor;
     }
 }

Whereas, the Author has an invariant on its author :

 public class Author
 {
     public string _name;

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

         _name= name;
     }
 }

The query side though would might have a need for both the information book name and author name, but this is a query and might not be suited for unit testing IMO.

If you need to be able to add to your library, only book when their author hhas the letter 'e' in it, then the whole discussion is different, but from what I understood, you do not need it right now.

When creating the aggregate Book your unit test becomes simpler then, because you focus on the write side and on the true invariants.

云巢 2024-09-08 04:11:07

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

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

}

Maybe we'll also make the Book
constructor private (and the Factory
nested) so that no one can instantiate
an empty Book except the Factory.

The private Book constructor is source of your problems.

If you make Book's constructor internal instead, the factory doesn't have to be nested. Then you're free to make the factory implement an interface (IBookFactory), and you can inject a mock book factory into your repository.

If you really want to ensure that only book factory implementations create instances, add a method to your repository that accepts the arguments the factory needs:

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

}
没有你我更好 2024-09-08 04:11:05

您可能想尝试测试数据生成器。不错的Nat Pryce 的帖子

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

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.

忆伤 2024-09-08 04:11:00

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

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

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

Thanks Finglas for the answer. I do use mocks in other tests but primarily for interaction testing, not for setting up the test context. I was not sure whether this kind of hollow object with just the needed values could be called a mock and if it was a good idea to use them.

I found something interesting and pretty close to the problem at Gerard Meszaros' xunitpatterns.com. He describes the code smell of having a long and complicated test setup as Irrelevant Information, with possible solutions being Creation Methods or Dummy Objects. I'm not entirely sold on his Dummy Object implementation though, since in my example it would force me to have an IBook interface (ugh) in order to implement a dummy Book with a very simple constructor and bypass all the Factory creation logic.

I guess a mix of isolation-framework-generated mocks and creation methods could help me clarify and simplify my tests.

可可 2024-09-08 04:10:55

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

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

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

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

For pure unit tests, mocks and stubs are definitely the solution. But since you are going after a more integration level tests, and mocks (or stubs or whatever) aren't solving your problem, you really have two reasonable choices:

  • create test factories to help you set up the data you need. These will probably be test-specific, which not only build up a bookstore, but populate it with a reasonable set up books. That way you compress your setup code down into a line or two, and use them for other tests. This code may grow to create various scenarios that are needed for integration type tests.

  • create a set up test fixtures. These are small, but conceptually complete sets of data for your tests to use. These are generally stored in some sort of serialized form (xml, csv, sql), and loaded at the beginning of each tests into your database so that you have a valid state. They are really just a general factory that works by reading static files.

If you use fixtures, you can take the single or multiple fixture approach. If you can get away with a single "canonical" set of data for most of your unit tests, that will be simpler, but sometimes that creates a data set that has too many records to be understandable, or simply doesn't express the range of scenarios you need to support. Some problems require multiple sets of data to be thoroughly tested.

固执像三岁 2024-09-08 04:10:52

有两件事:

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

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

“你会如何解决这个问题
必须重新创建整个集群
对象以便能够测试
简单的事?你会怎样打破
那种依赖并摆脱一切
我们不需要这些 Book 属性
我们的测试?通过使用模拟或存根?”

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

Two things:

  • Use mock objects within the tests. You're currently using concrete objects.

  • With regards the complex set up, at some point you will need some valid books. Extract this logic to a set up method, to run before each test. Have that set up method create a valid collection of books and so forth.

"How would you solve the problem of
having to re-create a whole cluster of
objects in order to be able to test a
simple thing ? How would you break
that dependency and get rid of all
these Book attributes we don't need in
our test ? By using mocks or stubs ?"

A mock object would let you do this. If a test only needs a book with a valid author, your mock object would specify that author, the other attributes would be defaulted. As your test only cares about a valid author, there is no need to set up the other attributes.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文