本文主要讲述如何使用 mock 技术进行单元测试。
1. Demo
我们通过一个简单的电商系统来演示。我们希望购物车中的商品被运送给付款的用户。此程序包含以下部分:
CartController,包含获取购物车内商品付款的逻辑。如果用户成功支付,则需要将商品运送给用户。- Helper Services
ICartService:计算购物车内所有商品的总价并得到购物车中的商品清单,这样我们在得到付款后可以根据清单发货IPaymentService:在指定的隐含工卡中扣费IShipmentService:将商品运输至指定的地址
2. 创建 WebApi
创建一个 webapi project 和一个 test project。
2.1 WebApi Project
包含 CartController and its helper services.
首先,创建一个工作目录
mkdir <new directory name>
cd <new directory name>
然后在此目录中创建一个 solution
dotnet new sln
之后创建 api project:
dotnet new webapi -o api
最后将这个 project 添加到 solution 中:
dotnet sln add api/api.csproj
2.2 代码
Controllers 下的 CartController.cs:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Services;
namespace api.Controllers
{
[ApiController]
[Route("[controller]")]
public class CartController
{
private readonly ICartService _cartService;
private readonly IPaymentService _paymentService;
private readonly IShipmentService _shipmentService;
public CartController(
ICartService cartService,
IPaymentService paymentService,
IShipmentService shipmentService
)
{
_cartService = cartService;
_paymentService = paymentService;
_shipmentService = shipmentService;
}
[HttpPost]
public string CheckOut(ICard card, IAddressInfo addressInfo)
{
var result = _paymentService.Charge(_cartService.Total(), card);
if (result)
{
_shipmentService.Ship(addressInfo, _cartService.Items());
return "charged";
}
else {
return "not charged";
}
}
}
}
我们已经创建好了控制器,但是它有一些尚未实现的依赖,ICartService, IPaymentService 和 IShipmentService。本例中,我们不会创建任何具体的实例,而是关注于如何设定和测试程序的行为。
2.2 Services/ICartService.cs
namespace Services
{
public interface ICartService
{
double Total();
IEnumerable<CartItem> Items();
}
}
此接口只是购物车的一种表现,告知我们购物车中的商品 Items() 以及商品总额 Total()。
2.3 Services/IPaymentService.cs
namespace Services
{
public interface IPaymentService
{
bool Charge(double total, ICard card);
}
}
此接口只包含一个方法 Charge,第一个参数是总金额,第二个参数是需要从哪张银行卡/信用卡扣款。
2.4 Services/IShipmentService.cs
using System;
using System.Generic;
namespace Services
{
public interface IShipmentService
{
void Ship(IAddressInfo info, IEnumerable<CartItem> items);
}
}
2.5 Services/Models.cs
namespace Services
{
public interface IAddressInfo
{
public string Street { get; set; }
public string Address { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
public string PhoneNumber { get; set; }
}
public interface ICard
{
public string CardNumber { get; set; }
public string Name { get; set; }
public DateTime ValidTo { get; set; }
}
public interface CartItem
{
public string ProductId { get; set; }
public int Quantity { get; set; }
public double Price{ get; set; }
}
}
3. 创建 test
以 xunit 为例讲解:
dotnet new xunit -o test
添加至 solution
dotnet sln add test/test.csproj
加下来,添加 mocking library moq:
dotnet add test/test.csproj package moq
4. Moq 是如何工作的?
moq 的基本思路是,创建 interface 的具体实现,并决定接口方法被调用时的响应。
4.1 创建第一个 Mock
var paymentServiceMock = new Mock<IPaymentService>();
上面并不是具体的实现而是一个 mock 对象。一个 mock 对象可以:
- 自定义行为。你可以指定 mock 对象的某一方法被调用时的响应
- 验证。验证是代码被调用后执行的操作。比如,我们可以验证某一方法被传入某些特定参数调用。
4.2 定义 mock 行为
现在我们已经有了一个 mock 对象,我们可以如下定义 mock 对象的行为:
paymentServiceMock.Setup(p => p.Charge()).Returns(true);
显然,上述代码是不能通过编译的。我们需要为 Charge() 方法指定所需的实参。有两种指定参数的方法:
- 直接指定具体的参数
var card = new Card("owner", "number", "CVV number");
paymentServiceMock.Setup(p => p.Charge(114,card)).Returns(true);
- 使用帮助类
It指定参数。
paymentServiceMock.Setup(p => p.Charge(It.IsAny<double>(),card)).Returns(true);
4.3 Accessing our implementation
在测试代码中,如果我们需要传入 mock 好的对象,需要使用 mock 对象的 Object 属性来表示其具体实现。下面的代码中,我们将 mock 的 ICard 对象通过 cardMock.Object 传入 Charge() 方法作为实参。
var cardMock = new Mock<ICard>();
paymentServiceMock.Setup(p => p.Charge(It.IsAny<double>(),cardMock.Object)).Returns(true);
5. 添加单元测试
我们把默认的测试文件重命名为 CartControllerTest.cs。
我们想要:
- 测试所有的执行路径。根据
Charge返回true或false有两种可能的路径 - 为这两条执行路径编写两个单元测试
- 断言(Assert)。
让我们编写第一个测试:
// CartControllerTest.cs
[Fact]
public void ShouldReturnCharged()
{
// arrange
paymentServiceMock.Setup(p => p.Charge(It.IsAny<double>(), cardMock.Object)).Returns(true);
// act
var result = controller.CheckOut(cardMock.Object, addressInfoMock.Object);
// assert
shipmentServiceMock.Verify(s => s.Ship(addressInfoMock.Object, items.AsEnumerable()), Times.Once());
Assert.Equal("charged", result);
}
让我们一起来简要分析一下这个 UT。
注意:
编写单元测试的时候,良好的编码习惯是令Arrange,Act和Assert三步一目了然,否则会降低单元测试代码的可读性。
可读性对于单元测试代码来说是十分重要的,毕竟“单元测试是最好的文档”,如果单元测试阅读困难,对于大家理解代码是一个阻碍。
Arrange
paymentServiceMock.Setup(p => p.Charge(It.IsAny<double>(), cardMock.Object)).Returns(true);
这里我们定义了 mock 对象的 Charge 方法的行为:当从任意 carkMock 扣除任意金额时,返回 true。
Act
var result = controller.CheckOut(cardMock.Object, addressInfoMock.Object);
我们将执行结果赋值给 result。
Assert
shipmentServiceMock.Verify(s => s.Ship(addressInfoMock.Object, items.AsEnumerable()), Times.Once());
Assert.Equal("charged", result);
这里我们执行了两句断言。
第一个 verify 验证了 Ship() 方法仅仅被调用了 1 次。
第二个是说 result 返回值应该和 "charged" 相等。
6. 更多
在软件开发过程中,单元测试是很重要的一环。更多关于 moq 的内容,请参阅 https://github.com/Moq/moq4/wiki/Quickstart。