这个例子来源于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单一职责原则了。