mock


本文主要讲述如何使用 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, IPaymentServiceIShipmentService。本例中,我们不会创建任何具体的实例,而是关注于如何设定和测试程序的行为。

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() 方法指定所需的实参。有两种指定参数的方法:

  1. 直接指定具体的参数
var card = new Card("owner", "number", "CVV number");
paymentServiceMock.Setup(p => p.Charge(114,card)).Returns(true);
  1. 使用帮助类 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 返回 truefalse 有两种可能的路径
  • 为这两条执行路径编写两个单元测试
  • 断言(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。

注意:
编写单元测试的时候,良好的编码习惯是令 ArrangeActAssert 三步一目了然,否则会降低单元测试代码的可读性。
可读性对于单元测试代码来说是十分重要的,毕竟“单元测试是最好的文档”,如果单元测试阅读困难,对于大家理解代码是一个阻碍。

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


文章作者: Shichao Zhang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Shichao Zhang !
  目录