测试友好的架构

Test-Friendly Architecture

我有一个关于设计 classes 以便测试友好的最佳方法的问题。假设我有一个 OrderService class,用于下新订单、查看订单状态等。 class 将需要访问客户信息、库存信息、运输信息等。因此 OrderService class 将需要使用 CustomerService、InventoryService 和 ShippingService。每个服务也有自己的后备存储库。

将 OrderService class 设计为易于测试的最佳方法是什么?我见过的两种常用模式是依赖注入和服务定位器。对于依赖注入,我会做这样的事情:

class OrderService
{
   private ICustomerService CustomerService { get; set; }
   private IInventoryService InventoryService { get; set; }
   private IShippingService ShippingService { get; set; }
   private IOrderRepository Repository { get; set; }

   // Normal constructor
   public OrderService()
   {
      this.CustomerService = new CustomerService();
      this.InventoryService = new InventoryService();
      this.ShippingService = new ShippingService();
      this.Repository = new OrderRepository();         
   }

   // Constructor used for testing
   public OrderService(
      ICustomerService customerService,
      IInventoryService inventoryService,
      IShippingService shippingService,
      IOrderRepository repository)
   {
      this.CustomerService = customerService;
      this.InventoryService = inventoryService;
      this.ShippingService = shippingService;
      this.Repository = repository;
   }
}

// Within my unit test
[TestMethod]
public void TestSomething()
{
   OrderService orderService = new OrderService(
      new FakeCustomerService(),
      new FakeInventoryService(),
      new FakeShippingService(),
      new FakeOrderRepository());
}

这样做的缺点是,每次我创建一个我在测试中使用的 OrderService 对象时,都需要大量代码来调用我的测试中的构造函数。我的服务 classes 也以他们使用的每个服务和存储库 class 的一堆属性结束。当我扩展我的程序并在各种服务和存储库 classes 之间添加更多依赖项时,我必须返回并向我已经创建的 classes 的构造函数添加越来越多的参数。

对于服务定位器模式,我可以这样做:

class OrderService
{
   private CustomerService CustomerService { get; set; }
   private InventoryService InventoryService { get; set; }
   private ShippingService ShippingService { get; set; }
   private OrderRepository Repository { get; set; }

   // Normal constructor
   public OrderService()
   {
      ServiceLocator serviceLocator = new ServiceLocator();
      this.CustomerService = serviceLocator.CreateCustomerService()
      this.InventoryService = serviceLocator.CreateInventoryService();
      this.ShippingService = serviceLocator.CreateShippingService();
      this.Repository = serviceLocator.CreateOrderRepository();         
   }

   // Constructor used for testing
   public OrderService(IServiceLocator serviceLocator)
   {
      this.CustomerService = serviceLocator.CreateCustomerService()
      this.InventoryService = serviceLocator.CreateInventoryService();
      this.ShippingService = serviceLocator.CreateShippingService();
      this.Repository = serviceLocator.CreateOrderRepository();   
   }
}

// Within a unit test
[TestMethod]
public void TestSomething()
{
   OrderService orderService = new OrderService(new TestServiceLocator());
}

我喜欢服务定位器模式在调用构造函数时如何减少代码,但它也提供了较少的灵活性。

设置我的服务 class 依赖于多个其他服务和存储库以便轻松测试它们的推荐方法是什么?我上面展示的任何一种或两种方法都很好,还是有更好的方法?

只是一个非常快速的回答,可以让您走上正轨。 根据我的经验,如果您的目标是易于测试的代码,您往往会得到干净可维护的代码作为一个很好的副作用。 :-)

需要记住的一些要点:

  1. SOLID 原则将真正帮助您创建良好、干净、可测试的代码。

    (S + O + I) 将此服务分解成更小的服务,它们只做一件事,因此只有一个更改的理由。至少下订单和检查订单状态是完全不同的事情。如果你仔细考虑一下,你真的不需要遵循最明显的步骤(例如检查信用->检查库存->检查运输),其中一些可以乱序完成 - 但那是另一回事可能需要不同商业模式的故事。无论如何,如果您真的需要的话,您可以使用 Facade 模式在这些较小的服务之上创建一个简化的视图。

  2. 使用 IoC 容器(例如 unity)

  3. 使用 Mocking 框架(例如 Moq)

  4. 服务定位器模式实际上被认为是一种 anti-pattern/code 气味 - 所以请不要使用它。

  5. 您的测试应该使用与实际代码相同的路径,所以去掉 'Normal constructor'。第一个示例中的 'Constructor used for testing' 是构造函数的外观。

  6. 不要在 class 中实例化所需的服务 - 它们应该作为接口传入。 IoC 容器会帮你处理这部分。这样你就遵循了 Solid 中的 D(依赖倒置原则)

  7. 尽可能避免直接在您自己的 class 中使用 using/referencing 静态 classes/methods。在这里,我谈论的是直接使用 DateTime.Now() 之类的东西,而不是先将它们包装在 interface/class 中。 例如,在这里你可以有一个带有 GetLocalTime() 方法的 IClock 接口,你的 classes 可以使用它而不是直接使用系统函数。这允许您在 运行 时间注入一个 SystemClock class,并在测试期间注入一个 MockClock。通过这样做,您可以完全控制 return 被测试的 system/class 的确切时间。这一原则显然适用于所有其他可能 return 不可预测结果的静态引用。我知道它增加了你需要传递给你的 classes 的另一件事,但它至少使预先存在的依赖关系明确并防止目标帖子在测试期间不断移动(不必诉诸黑魔法,像 MS Fakes)。

  8. 这是一个小问题,但是你这里的私有属性真的应该是字段

"testable" 代码与松耦合代码之间存在差异。

使用 DI 的主要目的是松耦合。可测试性是从松散耦合的代码中获得的附带好处。但是可测试的代码不一定是松耦合的。

虽然注入服务定位器显然比对服务定位器的静态引用更松散,但这仍然不是最佳实践。最大的缺点是lack of transparency of dependencies。您现在可以通过实施服务定位器节省几行代码,然后认为您赢了,但是当您实际上必须 compose 您的应用程序时,这样做所获得的一切都将丢失。在 intellisense 中查看构造函数以确定 class 具有哪些依赖项然后定位该 class 的源代码以尝试找出它具有哪些依赖项具有明显的优势。

因此,您可能已经猜到了,我建议您使用构造函数注入。但是,您的示例中还有一个称为 bastard injection 的反模式。混蛋注入的主要缺点是您通过在内部更新 classes 将它们紧密耦合在一起。这看似无辜,但如果您需要将您的服务移动到单独的库中会怎样?很有可能会在您的应用程序中导致循环依赖。

处理这个问题的最佳方法(特别是当您处理服务而不是配置设置时)是使用 pure DI 或 DI 容器,并且只有一个构造函数。您还应该使用保护条款来确保没有任何依赖项就无法创建您的订单服务。

class OrderService
{
   private readonly ICustomerService customerService;
   private readonly IInventoryService inventoryService;
   private readonly IShippingService shippingService;
   private readonly IOrderRepository repository;


   // Constructor used for injection (the one and only)
   public OrderService(
      ICustomerService customerService,
      IInventoryService inventoryService,
      IShippingService shippingService,
      IOrderRepository repository)
   {
        if (customerService == null)
            throw new ArgumentNullException("customerService");
        if (inventoryService == null)
            throw new ArgumentNullException("inventoryService");
        if (shippingService == null)
            throw new ArgumentNullException("shippingService");
        if (repository == null)
            throw new ArgumentNullException("repository");              

        this.customerService = customerService;
        this.inventoryService = inventoryService;
        this.shippingService = shippingService;
        this.repository = repository;
   }
}

// Within your unit test
[TestMethod]
public void TestSomething()
{
   OrderService orderService = new OrderService(
      new FakeCustomerService(),
      new FakeInventoryService(),
      new FakeShippingService(),
      new FakeOrderRepository());
}

// Within your application (pure DI)
public class OrderServiceContainer
{
    public OrderServiceContainer()
    {
        // NOTE: These classes may have dependencies which you need to set here.
        this.customerService = new CustomerService();
        this.inventoryService = new InventoryService();
        this.shippingService = new ShippingService();
        this.orderRepository = new OrderRepository();
    }

    private readonly IOrderService orderService;
    private readonly ICustomerService customerService;
    private readonly IInventoryServcie inventoryService;
    private readonly IShippingService shippingService;
    private readonly IOrderRepository orderRepository;

    public ResolveOrderService()
    {
        return new OrderService(
            this.customerService,
            this.inventoryService,
            this.shippingService,
            this.orderRepository);
    }
}

// In your application's composition root, resolve the object graph
var orderService = new OrderServiceContainer().ResolveOrderService();

我也同意戈登的回答。如果您有 4 个服务依赖项,那么您的 class 承担了太多责任,这是一种代码味道。您应该考虑重构为 aggregate services 以使您的 class 成为唯一的责任人。当然,有时需要4个依赖,但总是值得退一步看看是否有领域概念应该是另一个显式服务。

NOTE: I am not necessarily saying that Pure DI is the best approach, but it can work for some small applications. When an application becomes complex, using a DI container can pay dividends by using convention-based configuration.