这个例子来源于Pro ASP.NET Core 3这一本书,书中介绍了对ASP.NET Core程序进行单元测试的方法,其中有一例,在对购物车逻辑模块进行功能开发的时候,发现单元测试很“吃力”,后面得到了如何对其进行优化,从而大大简化了单元测试的方法。所以这里记录一下。

购物车的实现


    这里只是一个简单的购物车实现,用户在挑选物品放进购物车的时候,将数据临时存放在了Session中(缺点是如果服务器重启,数据会丢失,这里不讨论)。购物车的模型Cart如下:

public class Cart
{
    public List<CartLine> Lines { get; set; } = new List<CartLine>();
    public virtual void AddItem(Product product, int quantity)
    {
        CartLine line = Lines.FirstOrDefault(x => x.Product.ProductID == product.ProductID);
        if (line == null)
        {
            Lines.Add(new CartLine { Product = product, Quantity = quantity });
        }
        else
        {
            line.Quantity += quantity;
        }
    }

    public virtual void RemoveLine(Product product) => Lines.RemoveAll(x => x.Product.ProductID == product.ProductID);
    public decimal ComputerTotalValue() => Lines.Sum(x => x.Product.Price * x.Quantity);
    public virtual void Clear() => Lines.Clear();
}

public class CartLine
{
    public int CartLineID { get; set; }
    public Product Product { get; set; }
    public int Quantity { get; set; }
}

    很简单,购物车有一条条商品记录构成,商品记录包含Id,商品Product对象,以及数量构成。购物车实现了添加,删除,统计金额,清除功能。这个对象的单元测试很简单,这里不写。

    接下来,我们要实现一个功能,将购物车的数据临时存放在Session中。在进行对Session进行读写之前,添加了扩展方法,用来读取Session数据。

public static class SessionExtensions
{
    public static void SetJson(this ISession session, string key, object value)
    {
        session.SetString(key, JsonSerializer.Serialize(value));
    }

    public static T GetJson<T>(this ISession session, string key)
    {
        var sessionData = session.GetString(key);
        return sessionData == null ? default(T) : JsonSerializer.Deserialize<T>(sessionData);
    }
}

    接下来,实现功能,在这里实现的过程中,使用了“Razor Page”,新建了一个Cart.cshtml页面文件和Cart.cshtml.cs对应的模型文件,在其模型页面中做了如下实现:

public class CartModel : PageModel
{
    private IStoreRepository repository;
    public CartModel(IStoreRepository repo)
    {
        repository = repo;
    }
    public Cart Cart { get; set; }
    public string ReturnUrl { get; set; }
    public void OnGet(string returnUrl)
    {
        ReturnUrl = returnUrl ?? "/";
        Cart = HttpContext.Session.GetJson<Cart>("cart") ?? new Cart();
    }
    public IActionResult OnPost(long productId, string returnUrl)
    {
        Product product = repository.Products
        .FirstOrDefault(p => p.ProductID == productId);
        Cart = HttpContext.Session.GetJson<Cart>("cart") ?? new Cart();
        Cart.AddItem(product, 1);
        HttpContext.Session.SetJson("cart", Cart);
        return RedirectToPage(new { returnUrl = returnUrl });
    }
}

    这里面,可以看到,在OnGet方法中,从Session中读取cart对象。在OnPost中,先读取Session里存储的购物车对象,然后修改购物车,最后保存回Session对象。接下来,我们队CartModel进行单元测试。

单元测试


    新建了一个单元测试项目,一般是在待测试的项目名称后面加.Test新建一个新的项目,然后新建一个测试类CartPageTests.cs,要对CartModel里的OnGet方法进行测试,其实就是看能否正确加载购物车。这里使用的是xUnit单元测试框架,代码如下:

public class CartPageTests
{
    [Fact]
    public void Can_Load_Cart()
    {
        // Arrange
        // - create a mock repository
        Product p1 = new Product { ProductID = 1, Name = "P1" };
        Product p2 = new Product { ProductID = 2, Name = "P2" };
        Mock<IStoreRepository> mockRepo = new Mock<IStoreRepository>();
        mockRepo.Setup(m => m.Products).Returns((new Product[] {
        p1, p2
        }).AsQueryable<Product>());
        // - create a cart
        Cart testCart = new Cart();
        testCart.AddItem(p1, 2);
        testCart.AddItem(p2, 1);
        // - create a mock page context and session
        Mock<ISession> mockSession = new Mock<ISession>();
        byte[] data =
        Encoding.UTF8.GetBytes(JsonSerializer.Serialize(testCart));
        mockSession.Setup(c => c.TryGetValue(It.IsAny<string>(), out data));
        Mock<HttpContext> mockContext = new Mock<HttpContext>();
        mockContext.SetupGet(c => c.Session).Returns(mockSession.Object);
        // Action
        CartModel cartModel = new CartModel(mockRepo.Object)
        {
            PageContext = new PageContext(new ActionContext
            {
                HttpContext = mockContext.Object,
                RouteData = new RouteData(),
                ActionDescriptor = new PageActionDescriptor()
            })
        };
        cartModel.OnGet("myUrl");
        //Assert
        Assert.Equal(2, cartModel.Cart.Lines.Count());
        Assert.Equal("myUrl", cartModel.ReturnUrl);
    }
}

    在进行单元测试前,我们首先要隔离依赖,使用Mock方法,把待依赖的对象Mock出来,从CartModel可以看出,他依赖了IStoreRepository来提供对商品信息的查询;依赖了HttpContext.Session来读取Session数据。

    在对IStoreRepository的Mock中,我们用Setup提供了Products属性,让其返回我们准备好的两个模拟Product,这很简单,但是对HttpContext.Session的Mock就比较复杂,没那么简单了。因为这里测试的是是否能正确获取存储在Session里的购物车Cart数据,所以这里首先准备了购物车数据,并且在里面放了两条数据,我们实现要把这个购物车存到Mock的Session对象中。

    第一步是将购物车对象序列化后转为二进制对象,然后Mock一个ISession对象,然后对其中的Get方法赋值(TryGetValue),就是当需要任何一个String(表示为It.IsAny<string>())的时候,我们返回序列化的二进制data对象。

    第二步是Mock出HttpContext对象,对其的Session属性进行赋值,赋值为我们第一步Mock的ISession对象。这还没完。

    第三步,我们还需要把上述的对象复制到CartModel的PageModel对应的PageContext中,在初始化CartModel对象时,对他的PageContext属性进行了赋值,在对PageContext对象进行复制的时候,初始化了ActionContext对象,在ActionContext对象中注入了HttpContext对象。

    最后我们主动调用了cartModel.OnGet方法,然后判断结果。

    对OnPost进行单元测试比OnGet方法更为复杂,因为我们要Mock更新存储在Session中的对象,对OnPost的单元测试方法命名为了Can_Update_Cart(),代码如下:

[Fact]
public void Can_Update_Cart()
{
    // Arrange
    // - create a mock repository
    Mock<IStoreRepository> mockRepo = new Mock<IStoreRepository>();
    mockRepo.Setup(m => m.Products).Returns((new Product[] {
new Product { ProductID = 1, Name = "P1" }
}).AsQueryable<Product>());
    Cart testCart = new Cart();
    Mock<ISession> mockSession = new Mock<ISession>();
    mockSession.Setup(s => s.Set(It.IsAny<string>(), It.IsAny<byte[]>()))
    .Callback<string, byte[]>((key, val) =>
    {
        testCart =
    JsonSerializer.Deserialize<Cart>(Encoding.UTF8.GetString(val));
    });
    Mock<HttpContext> mockContext = new Mock<HttpContext>();
    mockContext.SetupGet(c => c.Session).Returns(mockSession.Object);
    // Action
    CartModel cartModel = new CartModel(mockRepo.Object)
    {
        PageContext = new PageContext(new ActionContext
        {
            HttpContext = mockContext.Object,
            RouteData = new RouteData(),
            ActionDescriptor = new PageActionDescriptor()
        })
    };
    cartModel.OnPost(1, "myUrl");
    //Assert
    Assert.Single(testCart.Lines);
    Assert.Equal("P1", testCart.Lines.First().Product.Name);
    Assert.Equal(1, testCart.Lines.First().Quantity);
}

    这里,在MockSession的时候,我们需要Mock出对Session的存储操作。这里,我们需要,在任何需要存储以String为Key,byte[]为Value的时候(Set(It.IsAny<string>(), It.IsAny<byte[]>())),返回逻辑:testCart =JsonSerializer.Deserialize<Cart>(Encoding.UTF8.GetString(val));

    可以看到,为了对CartModel进行单元测试,异常麻烦,麻烦的地方在于他直接依赖了HttpContext.Session对象,对该对象的Mock比较复杂,需要一步一步来进行。那么如何对他进行优化呢?

从单元测试进行优化


    这里面最主要的问题是,CartModel对存储细节进行了依赖,使得我们单元测试时,必须对这些底层细节进行Mock。我们可以直接将这个存储细节进行包装为一个CartService服务,就是讲这些细节直接放进Cart对象中。而不是CartModel中来进行,这样可以直接Mock这个CartService,这就比较简单了。对代码进行的第一个修改是对Cart对象进行修改,将他的AddItem,RemoveItem,和Clear方法进行修改,将其改为虚方法,这样我们可以对齐进行各自扩展,比如将这几个方法的操作结果进行持久化保存。

public class Cart
{
    public List<CartLine> Lines { get; set; } = new List<CartLine>();
    public virtual void AddItem(Product product, int quantity)
    {
        CartLine line = Lines.FirstOrDefault(x => x.Product.ProductID == product.ProductID);
        if (line == null)
        {
            Lines.Add(new CartLine { Product = product, Quantity = quantity });
        }
        else
        {
            line.Quantity += quantity;
        }
    }
    public virtual void RemoveLine(Product product) => Lines.RemoveAll(x => x.Product.ProductID == product.ProductID);
    public decimal ComputerTotalValue() => Lines.Sum(x => x.Product.Price * x.Quantity);
    public virtual void Clear() => Lines.Clear();
}

    接着,新建一个SessionCart对象,来扩展Cart的功能,提供将该对象存储在Session中的额外功能:

public class SessionCart : Cart
{

    public static Cart GetCart(IServiceProvider serivces)
    {
        ISession session = serivces.GetRequiredService<IHttpContextAccessor>()?.HttpContext.Session;
        SessionCart cart = session?.GetJson<SessionCart>("Cart") ?? new SessionCart();
        cart.Session = session;
        return cart;
    }

    [JsonIgnore]
    public ISession Session { get; set; }

    public override void AddItem(Product product, int quantity)
    {
        base.AddItem(product, quantity);
        Session.SetJson("Cart", this);
    }

    public override void RemoveLine(Product product)
    {
        base.RemoveLine(product);
        Session.SetJson("Cart", this);
    }

    public override void Clear()
    {
        base.Clear();
        Session.Remove("Cart");
    }
}

    可以看到,他重写了在Cart中的三个虚方法,首先调用积累的对应方法,然后执行操作将自身对象保存在以"Cart"为键的Session对象中。

    接下来在Startup中,对服务进行了注入,当需要Cart的时候,调用SessionCart里的GetCart方法:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        ....
        services.AddRazorPages();
        services.AddDistributedMemoryCache();
        services.AddSession();
        services.AddScoped<Cart>(sp => SessionCart.GetCart(sp));
        services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    }
}

    接下来,在Cart.cshtml.cs模型文件中,CartModel里的修改如下:

public class CartModel : PageModel
{
    private IStoreRepository repository;
    public CartModel(IStoreRepository repo, Cart cartService)
    {
        repository = repo;
        Cart = cartService;
    }

    public Cart Cart { get; set; }
    public string ReturnUrl { get; set; }

    public void OnGet(string returnUrl)
    {
        ReturnUrl = returnUrl ?? "/";
    }

    public IActionResult OnPost(long productId, string returnUrl)
    {
        Product product = repository.Products.FirstOrDefault(x => x.ProductID == productId);
        Cart.AddItem(product, 1);
        return RedirectToPage(new { returnUrl = returnUrl });
    }
}

    这里面,直接注入了Cart对象,保存和读取Cart的细节都封装在Cart对象里了,这里没有了对HttpContext.Session的依赖,代码也得到了简化。

    现在,要对OnGet方法进行单元测试,也变得非常容易了,Cart对象都不需要进行Mock了,直接实例化一个基础的,不带存储功能的普通Cart就可以了(不过还是高兴的太早,现在需要对SessionCart进行单元测试了😂)。

[Fact]
public void Can_Load_Cart()
{
    //Given
    Product p1 = new Product { ProductID = 1, Name = "P1" };
    Product p2 = new Product { ProductID = 2, Name = "P2" };
    Mock<IStoreRepository> mockRepo = new Mock<IStoreRepository>();
    mockRepo.Setup(t => t.Products).Returns((new Product[] { p1, p2 }).AsQueryable<Product>());

    Cart testCart = new Cart();
    testCart.AddItem(p1, 2);
    testCart.AddItem(p2, 1);

    CartModel cartModel = new CartModel(mockRepo.Object, testCart);
    cartModel.OnGet("myUrl");
    Assert.Equal(2, cartModel.Cart.Lines.Count());
    Assert.Equal("myUrl", cartModel.ReturnUrl);
}

[Fact]
public void Can_Update_Cart()
{
    Product p1 = new Product { ProductID = 1, Name = "P1" };
    Product p2 = new Product { ProductID = 2, Name = "P2" };
    Mock<IStoreRepository> mockRepo = new Mock<IStoreRepository>();
    mockRepo.Setup(t => t.Products).Returns((new Product[] { p1, p2 }).AsQueryable<Product>());
    Cart testCart = new Cart();

    CartModel cartModel = new CartModel(mockRepo.Object, testCart);
    cartModel.OnPost(1, "myUrl");
 
    Assert.Single(testCart.Lines);
    Assert.Equal("P1", testCart.Lines.First().Product.Name);
    Assert.Equal(1, testCart.Lines.First().Quantity);
}

     跟第一个版本的单元测试相比,代码是不是非常的清晰和易测试。

总结


    单元测试在某些情况下能够直接帮助我们感受到代码的“Bad Semll”,如果代码在单元测试的时候感觉需要mock很多东西,mock起来异常麻烦或者困难,这时就要看看代码是否依赖过多,是否违反了SRP单一职责原则了。