事件溯源中对业务领域实现单元测试 -CodeOpinion


使用事件源时,测试聚合要比对当前状态的存储进行测试更简单。聚合的输入是事件,聚合的输出是事件:

Given a stream of events
When a valid command is performed
Then new event(s) occurs

 
通常,将聚合用作建模我的域的一种方式。聚合是公开命令的地方,如果调用/调用了这些命令,将导致创建新事件。这个一般概念是事件源中的测试如何变得简单。
为了使用聚合,您首先需要从事件存储中提取所有现有事件,在聚合中重播它们以获取当前状态,然后将聚合返回给调用方。为此,我通常使用一个存储库来完成这项工作并构建聚合。


为了说明这一点,我们有使用存储库获取聚合的客户端代码。存储库调用事件存储以获取此特定聚合的现有事件(如果有)。

此时,存储库将创建一个空的聚合,并重播它从事件存储中收到的所有事件。

一旦重建了聚合,它就会将其返回给客户端。此时客户端可能会调用聚合中的各种方法/命令。
 
建立事件
现在,client代码访问聚合:它将可能在聚合上执行一个或多个命令。


如果客户端在聚合上调用ShipProduct()命令,并且聚合处于这样做的有效状态并传递所有不变量,则它将创建一个新事件,该事件将在内部保留在其中。

如果客户端代码随后调用了另一个命令,则另一个事件将被附加到内部列表中。

这是流程的第二阶段,我们创建了新事件,这些事件是在聚合上调用命令的结果。请记住,此第二阶段是事件源测试的“何时”阶段。
  
保存事件
最后一步是获取聚合中新创建的事件,并将这些事件持久保存到事件存储中。


这意味着客户代码将回调我们的存储库,并向其传递Aggreagate。

存储库将获取新事件,并将这些事件附加到该特定聚合事件流的事件存储中。
请记住,此阶段时我们的测试中的“ Then”。
 
Given, When, Then
如果您按照基本的3个步骤来加载聚合,调用命令,保存新事件,则可以将其归结为:

Given a stream of events
When a valid command is performed
Then new event(s) occurs

可以将其用作测试聚合的测试策略:

using System;
using System.Collections.Generic;

namespace EventSourcing.Demo
{
    public class WarehouseProductState
    {
        public int QuantityOnHand { get; set; }
    }

    public class WarehouseProduct : AggregateRoot
    {
        public string Sku { get; }

        private readonly WarehouseProductState _warehouseProductState = new();

        public WarehouseProduct(string sku)
        {
            Sku = sku;
        }

        public override void Load(IEnumerable<IEvent> events)
        {
            foreach (var evnt in events)
            {
                Apply(evnt as dynamic);
            }
        }

        public static WarehouseProduct Load(string sku, IEnumerable<IEvent> events)
        {
            var warehouseProduct = new WarehouseProduct(sku);
            warehouseProduct.Load(events);
            return warehouseProduct;
        }

        public void ShipProduct(int quantity)
        {
            if (quantity > _warehouseProductState.QuantityOnHand)
            {
                throw new InvalidDomainException("Cannot Ship to a negative Quantity on Hand.");
            }

            var productShipped = new ProductShipped(Sku, quantity, DateTime.UtcNow);

            Apply(productShipped);
            Add(productShipped);
        }

        private void Apply(ProductShipped evnt)
        {
            _warehouseProductState.QuantityOnHand -= evnt.Quantity;
        }

        public void ReceiveProduct(int quantity)
        {
            var productReceived = new ProductReceived(Sku, quantity, DateTime.UtcNow);

            Apply(productReceived);
            Add(productReceived);
        }

        private void Apply(ProductReceived evnt)
        {
            _warehouseProductState.QuantityOnHand += evnt.Quantity;
        }

        public void AdjustInventory(int quantity, string reason)
        {
            if (_warehouseProductState.QuantityOnHand + quantity < 0)
            {
                throw new InvalidDomainException(
"Cannot adjust to a negative Quantity on Hand.");
            }

            var inventoryAdjusted = new InventoryAdjusted(Sku, quantity, reason, DateTime.UtcNow);

            Apply(inventoryAdjusted);
            Add(inventoryAdjusted);
        }

        private void Apply(InventoryAdjusted evnt)
        {
            _warehouseProductState.QuantityOnHand += evnt.Quantity;
        }

        public WarehouseProductState GetState()
        {
            return _warehouseProductState;
        }

        public int GetQuantityOnHand()
        {
            return _warehouseProductState.QuantityOnHand;
        }
    }

    public class InvalidDomainException : Exception
    {
        public InvalidDomainException(string message) : base(message)
        {

        }
    }
}

上面的WarehouseProduct具有3个命令:ShipProduct,ReceiveProduct和AdjustInventory。如果它们传递了任何不变式,所有这些都会导致创建各自的事件。
为了说明此情况,对于ShipProduct命令,应创建一个ProductShipped事件。
 
public class WarehouseProductTests
  {
      private readonly string _sku;
      private readonly int _initialQuantity;
      private readonly WarehouseProduct _sut;
      private readonly Fixture _fixture;

      public WarehouseProductTests()
      {
          _fixture = new Fixture();
          _fixture.Customizations.Add(new Int32SequenceGenerator());
          _sku = _fixture.Create<string>();
          _initialQuantity = (int)_fixture.Create<uint>();

          _sut = WarehouseProduct.Load(_sku, new [] {
              new ProductReceived(_sku, _initialQuantity, DateTime.UtcNow)
          });
      }

      [Fact]
      public void ShipProductShouldRaiseProductShipped()
      {
          var quantityToShip = _fixture.Create<int>();
          _sut.ShipProduct(quantityToShip);

          var outEvents = _sut.GetUncommittedEvents();
          outEvents.Count.ShouldBe(1);
          var outEvent = outEvents.Single();
          outEvent.ShouldBeOfType<ProductShipped>();

          var productShipped = (ProductShipped)outEvent;
          productShipped.ShouldSatisfyAllConditions(
              x => x.Quantity.ShouldBe(quantityToShip),
              x => x.Sku.ShouldBe(_sku),
              x => x.EventType.ShouldBe("ProductShipped")
          );
      }
}