如何在 .NET 中编写可测试的代码






3.97/5 (18投票s)
本文演示了如何管理依赖项以简化代码的单元测试。
引言
测试驱动开发(TDD)如果掌握得当,会非常有益。不幸的是,市面上许多教程和培训资源都略过了如何编写可测试的代码,因为它们通常是示例代码,不包含实际代码中常见的层级,例如服务层、数据访问层等。不可避免的是,当您尝试测试包含这些依赖项的代码时,测试会变得非常缓慢,难以编写,并且经常会因为底层依赖项返回的结果与预期不符而失败。
背景
编写良好的代码应该被分离成不同的层,每一层负责应用程序的不同部分。实际的分层会根据需求和开发者的习惯而有所不同,但常见的方案是:
- 用户界面/表示层:这是您的表示逻辑和 UI 交互代码。
- 业务逻辑/服务层:这是您的业务逻辑。例如:购物车代码。这个购物车知道如何计算购物车总价,如何统计订单中的商品数量等。
- 数据访问层/持久化层:这部分代码知道如何连接数据库并返回一个购物车,或者如何将购物车保存到数据库。
- 数据库:购物车内容保存在这里。
无依赖管理
在没有依赖管理的情况下,当您为表示层编写测试时,您的代码会连接到真实的服务,这些服务又会连接到真实的数据访问代码,最终访问真实数据库。实际上,当您测试“添加到购物车”或“获取购物车商品数量”等功能时,您希望代码能够独立运行,并能够保证代码产生 **可预测** 的结果。没有依赖管理,您的 UI 测试“添加到购物车”功能将非常缓慢,并且您的依赖项会返回不可预测的结果,这可能导致测试失败。
解决方案是依赖注入
解决这个问题的方案是依赖注入。依赖注入(Dependency Injection,简称 DI)对于初学者来说可能显得复杂和令人困惑,但实际上,它是一个非常非常简单的概念和过程,包含几个基本步骤。我们要做的就是集中管理您的依赖项,在本例中是 `ShoppingCart` 对象的用法,然后松散耦合您的代码,这样当您运行应用程序时,它会使用真实的服务;而在测试时,您可以使用快速且可靠的假服务。请注意,有几种方法可以实现这一点;为了简单起见,我只演示构造函数注入。
步骤 1 - 识别您的依赖项
依赖项是指您的代码与其他层交互的地方。例如,当您的表示层与服务层交互时。您的表示代码依赖于服务层,但我们希望独立测试表示代码。
public class ShoppingCartController : Controller
{
public ActionResult GetCart()
{
//shopping cart service as a concrete dependency
ShoppingCartService shoppingCartService = new ShoppingCartService();
ShoppingCart cart = shoppingCartService.GetContents();
return View("Cart", cart);
}
public ActionResult AddItemToCart(int itemId, int quantity)
{
//shopping cart service as a concrete dependency
ShoppingCartService shoppingCartService = new ShoppingCartService();
ShoppingCart cart = shoppingCartService.AddItemToCart(itemId, quantity);
return View("Cart", cart);
}
步骤 2 - 集中管理您的依赖项
虽然有几种方法可以做到这一点,但在本例中,我将创建一个 `ShoppingCartService` 类型的成员变量,然后在构造函数中创建的实例中为其赋值。在每次使用 `ShoppingCartService` 的地方,我将重用这个实例,而不是创建新实例。
public class ShoppingCartController : Controller
{
private ShoppingCartService _shoppingCartService;
public ShoppingCartController()
{
_shoppingCartService = new ShoppingCartService();
}
public ActionResult GetCart()
{
//now using the shared instance of the shoppingCartService dependency
ShoppingCart cart = _shoppingCartService.GetContents();
return View("Cart", cart);
}
public ActionResult AddItemToCart(int itemId, int quantity)
{
//now using the shared instance of the shoppingCartService dependency
ShoppingCart cart = _shoppingCartService.AddItemToCart(itemId, quantity);
return View("Cart", cart);
}
}
步骤 3 - 松散耦合
针对接口而不是具体对象进行编程。如果您用 `IShoppingCartService` 接口编写代码,而不是直接使用具体的 `ShoppingCartService`,那么在测试时,您可以替换为一个实现 `IShoppingCartService` 接口的假购物车服务。在下图的示例中,请注意,唯一的变化是成员变量的类型现在是 `IShoppingCartService`,而不是 `ShoppingCartService`。
public interface IShoppingCartService
{
ShoppingCart GetContents();
ShoppingCart AddItemToCart(int itemId, int quantity);
}
public class ShoppingCartService : IShoppingCartService
{
public ShoppingCart GetContents()
{
throw new NotImplementedException("Get cart from Persistence Layer");
}
public ShoppingCart AddItemToCart(int itemId, int quantity)
{
throw new NotImplementedException("Add Item to cart then return updated cart");
}
}
public class ShoppingCart
{
public List<product> Items { get; set; }
}
public class Product
{
public int ItemId { get; set; }
public string ItemName { get; set; }
}
public class ShoppingCartController : Controller
{
//Concrete object below points to actual service
//private ShoppingCartService _shoppingCartService;
//loosely coupled code below uses the interface rather than the
//concrete object
private IShoppingCartService _shoppingCartService;
public ShoppingCartController()
{
_shoppingCartService = new ShoppingCartService();
}
public ActionResult GetCart()
{
//now using the shared instance of the shoppingCartService dependency
ShoppingCart cart = _shoppingCartService.GetContents();
return View("Cart", cart);
}
public ActionResult AddItemToCart(int itemId, int quantity)
{
//now using the shared instance of the shoppingCartService dependency
ShoppingCart cart = _shoppingCartService.AddItemToCart(itemId, quantity);
return View("Cart", cart);
}
}
步骤 4 - 注入依赖项
现在我们将所有依赖项集中在一个地方,并且我们的代码已与这些依赖项松散耦合。与之前一样,有几种方法可以处理下一步。如果没有设置像 NInject 或 StructureMap 这样的 IoC 容器,最简单的方法就是重载构造函数。
//loosely coupled code below uses the interface rather
//than the concrete object
private IShoppingCartService _shoppingCartService;
//MVC uses this constructor
public ShoppingCartController()
{
_shoppingCartService = new ShoppingCartService();
}
//You can use this constructor when testing to inject the
//ShoppingCartService dependency
public ShoppingCartController(IShoppingCartService shoppingCartService)
{
_shoppingCartService = shoppingCartService;
}
步骤 5 - 使用存根进行测试
下面是一个可能的测试夹具示例。请注意,我已经创建了一个 `ShoppingCartService` 的假版本(也称为存根)。这个存根被传递到我的 Controller 对象中,并且 `GetContents` 方法被实现为返回一些假数据,而不是调用实际访问数据库的代码。由于这是 100% 代码,它比查询数据库 **快得多**,而且我永远不必担心在测试完成后准备测试数据或清理测试数据。请注意,由于步骤 2 中我们集中管理了依赖项,所以我只需要注入一次。由于步骤 3,我的依赖项是松散耦合的,所以只要它实现了 `IShoppingCartService` 接口,我就可以传入任何对象,无论是真实的还是假的。
[TestClass]
public class ShoppingCartControllerTests
{
[TestMethod]
public void GetCartSmokeTest()
{
//arrange
ShoppingCartController controller =
new ShoppingCartController(new ShoppingCartServiceStub());
// Act
ActionResult result = controller.GetCart();
// Assert
Assert.IsInstanceOfType(result, typeof(ViewResult));
}
}
/// <summary>
/// This is is a stub of the ShoppingCartService
/// </summary>
public class ShoppingCartServiceStub : IShoppingCartService
{
public ShoppingCart GetContents()
{
return new ShoppingCart
{
Items = new List<product> {
new Product {ItemId = 1, ItemName = "Widget"}
}
};
}
public ShoppingCart AddItemToCart(int itemId, int quantity)
{
throw new NotImplementedException();
}
}
接下来我该怎么做?
- 使用 IoC/DI 容器:.NET 中最常见和最受欢迎的 IoC 容器是 StructureMap 和 Ninject。在实际的生产代码中,您将拥有大量的依赖项,您的依赖项又会有依赖项,等等。您会很快发现这变得难以管理。答案是使用 DI/IoC 框架来管理它们。
- 使用隔离框架:创建存根和模拟对象可能会非常耗时,使用模拟框架可以为您节省大量时间和代码。.NET 中最常见的框架是 Rhino Mocks 和 Moq。