如何测试 DDD 命令处理程序中的逻辑?

How do I test logic in DDD Command handlers?

我正在尝试学习 DDD 和 Clean Architecture 的一些想法,但遇到了一个问题:如何对应用层中的命令处理程序进行单元测试,验证它们对域对象调用正确的方法,不测试那些域对象中的逻辑? 假设我有以下域实体:

public class User
{
    public User(int id)
    {
        Id = id;
    }

    public int Id { get; }

    public void RemoveProfilePicture()
    {
        ...
    }
    ...
}

我有以下简单命令 class:

public class RemoveUserProfilePictureCommand : ICommand
{
    public RemoveUserProfilePictureCommand(int userId)
    {
        UserId = userId;
    }
    public int UserId { get; }
}

和一个命令处理程序(位于应用层):

public class Handler : ICommandHandler<RemoveUserProfilePictureCommand>
{
    private readonly IUserRepository userRepository;

    public Handler(IUserRepository userRepository)
    {
        this.userRepository = userRepository;
    }

    public void Handle(RemoveUserProfilePictureCommand command)
    {
        var user = userRepository.GetById(command.UserId);
        user.RemoveProfilePicture();
    }
}

我想验证对 Handle 的调用是否会找到正确的 User 并对其调用域方法 RemoveProfilePicture。我找到的仅有的两个选项不满足我,我正在寻找更好的解决方案。

基于领域逻辑的副作用断言

显而易见的解决方案是断言域模型上的操作已发生,例如:

[Fact]
public void Handle_UserExists_ShouldRemoveProfilePicture()
{
    var user = new User(id: 555);
    repository.GetById(user.Id).Returns(user);
    var command = new RemoveUserProfilePictureCommand(user.Id);

    handler.Handle(command);

    Assert.Null(user.ProfilePicture);
} 

我对这种方法的问题是我们根据域模型内部的逻辑进行断言,如果该逻辑比将 ProfilePicture 属性 设置为 null 我们仍然必须在命令处理程序的测试中断言其结果,即使域逻辑已经被其自己的单元测试覆盖。问题源于应用层 classes 与域 classes 的紧密耦合。这让我找到了第二个解决方案:

将应用层与领域层解耦

如果 User class 会实现一个接口,比如说 IUser,那么测试中的假存储库可以 return [=20= 的不同实现] 到验证调用了正确方法的命令处理程序。这里的问题是,根据我的理解,应用层应该是域周围的薄包装器,不应该与它分离。此外,我发现的所有示例始终使用域对象的具体类型,在实体中实现接口似乎很奇怪 class.


有人能找到更好的解决方案来测试此类 classes 吗?因为它不是关于某些边缘情况,而是关于几乎每个命令处理程序 class,并且其中大多数比我上面给出的简单示例更复杂。

public void Handle(RemoveUserProfilePictureCommand command)
{
    var user = userRepository.GetById(command.UserId);
    user.RemoveProfilePicture();
}

我的首选答案:通过让第二个审阅者签署“代码非常简单,显然没有缺陷”的声明来测试它,然后不管它。说真的,你期望重构这两行多少次?这些重构中有多少不是自动的?

第二种可能是再增加一层间接寻址;不是让处理程序直接与存储库和实体一起工作,而是工作一层,根据您 可以 轻松模拟的事物来表达协议。所以你在存储库周围有一个包装器,包装器从“真实存储库”中获取模型的用户并将该用户包装在另一个可模拟对象中。

实际上,这部分代码将域模型视为外部依赖......当然是(从处理程序的角度来看)。

这是一种交易:您可以保持域模型关闭,同时减少未经测试的有趣代码的数量,但所需的代码块数量会增加。还有一些过火的风险,这可能导致“测试驱动设计损坏”。

当域模型 API 不稳定时,额外的间接层可能对您有利或不利。

“添加更多东西”还有许多其他变体,但权衡实际上没有改变:您获得更多覆盖范围,复杂性增加,增加维护风险。

选项 #3:如您所述,您可以测试对域模型的更改。您最终得到的是“社交”测试,而不是“孤独”测试。

有时有助于保持测试稳定的一种变体是确保断言在域模型本身的演算中表达。实际上,你有一个用户实体作为控制,另一个是你的测量,然后你要求域模型比较两者。

但请再次注意,我们正在引入更复杂的元素来覆盖一些非常明显的代码。

我的方法是在域 类 上使用基于陈述的测试,正如您所做的那样。

当涉及到 integration/application 问题时,我通常使用 基于交互的测试 并且我不关心状态,除非它有助于确定交互是否通过.我不会检查状态,而是询问我的模拟框架是否进行了特定调用。

对于您所展示的真正简单的场景,我认为我不会费心。如果为了代码覆盖而特别要求我这样做,那么我可能会,但对于我自己的项目,我可能不会:)

在我看来,DDD 的思想是在实现中使用与在业务中使用的逻辑相同的逻辑。明确、可见,避免业务和软件之间的逻辑不匹配。

什么是命令?它旨在更改域(数据)的状态。可以接受和执行或拒绝的意图。

接受与拒绝,就是做决定。它可以以功能方式实现为纯逻辑。这可能是相当复杂的逻辑(例如,想想贷款申请)。但是测试纯逻辑很简单。这个逻辑显然属于领域。我所说的“直截了​​当”是指纯逻辑没有任何副作用,并且给定相同的输入总是产生相同的输出。因此,测试是基于 table 的。您只需使用一些参数调用它并断言输出。

但是你需要准备数据来做出决定。接下来你应该执行它。这是一个应用程序逻辑。

我所说的准备数据是指进入数据库、存储、配置参数和外部服务。您很可能需要了解域的当前状态。您需要授权详细信息。您需要了解可能影响决定的任何其他详细信息。所有这些都发生在组件边界。它并不复杂,主要只是访问读取模型,因此您只需模拟集成并验证它们是否使用正确的参数调用。

执行决定也发生在边界。这意味着更新域数据,并可能通知某些第三方。测试方法与数据准备步骤相同。您模拟集成并验证参数。

当然,集成实现应该单独测试。像 SQL 查询、HTTP 交互、读写文件......但我想这不是你问题的一部分。

DDD 的难点在于很难在一个简单的例子上进行演示。在这种情况下,很可能不需要。如果您只是更新图像 - 那么领域逻辑是什么?另一方面,如果您在某个业务领域有经验,那么想出一个现实的例子会很容易。但是,如果您不知道领域 - 通常任何用例看起来都像 CRUD。

从我的头顶...考虑注册房地产购买合同。作为输入,您有一套文件。然后专家与一些系统集成以确保文档中的所有数据都是有效的。然后检查 属性 是否符合合同条件。那么一个决定就是所有权。或者拒绝。接下来,当做出决定时,在州登记处进行所有必要的记录,转移密钥等等...

那你可以问,这样的DDD和业务流程引擎运行的业务流程有什么区别?不同之处主要在于时间跨度和一些决策步骤。在 DDD 中,所有流程都在数百毫秒内发生。只有一个决策步骤。没有中间持久状态——所有都存储在内存中。虽然业务流程可能需要数天和数周时间。并且可能涉及多个决策、数据准备、执行和条件分支。

我在做受 DDD 启发的 CQRS 项目时也遇到了完全相同的问题。据我了解,您也在尝试开发 CQRS 项目,因为您正在使用诸如命令之类的概念,并且坚持要在命令处理程序中返回 null 值。

话虽如此,断言 null 值并不是在测试任何东西。您要测试的是系统中发生了副作用。在您的情况下,您要确保“已删除用户个人资料图片”。

您似乎缺少的是 CQRS 的另一半:事件。当命令处理程序成功完成其事务(即副作用)时,您通常会分派一个代表该事件的事件。然后,您的单元测试将简单地断言 UserProfilePictureRemoved 事件已分派(或入队,具体取决于您的实现)。