如何为使用EF Core及仓储模式的带分页功能的控制器异步方法编写单元测试
如何为使用EF Core及仓储模式的带分页功能的控制器异步方法编写单元测试
嘿,我来帮你搞定这个单元测试的问题!针对你这种用EF Core+仓储模式、带分页的控制器异步方法,咱们一步步拆解,写出靠谱的测试代码:
先明确测试目标
咱们的核心是隔离控制器的依赖(仓储和数据库),只验证控制器的分页逻辑是否正确:比如有没有正确计算分页偏移量、返回的分页数据总数/当前页数据是否符合预期,异步方法是否能正常执行。
方案一:Mock 暴露IQueryable的仓储(适配你现有代码结构)
你现在的仓储是直接暴露IQueryable<Product>的,这种情况下要支持异步查询(比如CountAsync、ToListAsync),需要给Mock的IQueryable加个异步支持的包装,咱们一步步来:
1. 先补全控制器的异步分页方法示例
假设你的控制器异步分页方法是这样的(毕竟你贴的代码没写完,我补个最常见的场景):
using Microsoft.AspNetCore.Mvc; using Project.Models; using Microsoft.EntityFrameworkCore; using System.Threading.Tasks; using System.Linq; namespace Project.Controllers { public class ProductsController : Controller { private readonly IProductRepository _repo; public ProductsController(IProductRepository repo) { _repo = repo; } // 带分页的异步方法 public async Task<IActionResult> Index(int page = 1, int pageSize = 10) { var totalItems = await _repo.Products.CountAsync(); var pagedProducts = await _repo.Products .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); // 假设你有对应的视图模型类 var vm = new ProductListViewModel { Products = pagedProducts, PagingInfo = new PagingInfo { CurrentPage = page, ItemsPerPage = pageSize, TotalItems = totalItems } }; return View(vm); } } // 配套视图模型 public class ProductListViewModel { public List<Product> Products { get; set; } public PagingInfo PagingInfo { get; set; } } public class PagingInfo { public int CurrentPage { get; set; } public int ItemsPerPage { get; set; } public int TotalItems { get; set; } } }
2. 编写异步IQueryable的辅助类
因为EF Core的异步扩展方法需要IQueryable实现IAsyncQueryProvider接口,所以咱们写个辅助类来包装普通的IQueryable,让它支持异步:
using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; public class AsyncQueryProvider<TEntity> : IAsyncQueryProvider { private readonly IQueryProvider _innerProvider; public AsyncQueryProvider(IQueryProvider innerProvider) { _innerProvider = innerProvider; } public IQueryable CreateQuery(Expression expression) => new AsyncEnumerable<TEntity>(expression); public IQueryable<TElement> CreateQuery<TElement>(Expression expression) => new AsyncEnumerable<TElement>(expression); public object Execute(Expression expression) => _innerProvider.Execute(expression); public TResult Execute<TResult>(Expression expression) => _innerProvider.Execute<TResult>(expression); public TResult ExecuteAsync<TResult>(Expression expression, CancellationToken cancellationToken) { var resultType = typeof(TResult).GetGenericArguments()[0]; var syncExecute = _innerProvider.Execute(expression); return (TResult)Task.FromResult(syncExecute).GetType().GetMethod("Result").Invoke(Task.FromResult(syncExecute), null); } } public class AsyncEnumerable<T> : EnumerableQuery<T>, IAsyncEnumerable<T>, IQueryable<T> { public AsyncEnumerable(IEnumerable<T> enumerable) : base(enumerable) { } public AsyncEnumerable(Expression expression) : base(expression) { } public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default) => new AsyncEnumerator<T>(this.AsEnumerable().GetEnumerator()); IQueryProvider IQueryable.Provider => new AsyncQueryProvider<T>(this.Provider); } public class AsyncEnumerator<T> : IAsyncEnumerator<T> { private readonly IEnumerator<T> _innerEnumerator; public AsyncEnumerator(IEnumerator<T> innerEnumerator) { _innerEnumerator = innerEnumerator; } public ValueTask DisposeAsync() { _innerEnumerator.Dispose(); return ValueTask.CompletedTask; } public ValueTask<bool> MoveNextAsync() => ValueTask.FromResult(_innerEnumerator.MoveNext()); public T Current => _innerEnumerator.Current; }
3. 编写单元测试代码
用xUnit+Moq来写测试(这俩是.NET里最常用的测试框架):
using Xunit; using Moq; using Project.Controllers; using Project.Models; using Microsoft.AspNetCore.Mvc; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace Project.Tests.Controllers { public class ProductsControllerTests { [Fact] public async Task Index_ReturnsCorrectPagedData_ForSecondPage() { // 1. 准备测试数据 var testProducts = new List<Product> { new Product { Id = 1, Name = "商品1" }, new Product { Id = 2, Name = "商品2" }, new Product { Id = 3, Name = "商品3" }, new Product { Id = 4, Name = "商品4" }, new Product { Id = 5, Name = "商品5" } }.AsQueryable(); // 2. 包装成支持异步的IQueryable var asyncTestProducts = new AsyncEnumerable<Product>(testProducts); // 3. Mock仓储 var mockRepo = new Mock<IProductRepository>(); mockRepo.Setup(repo => repo.Products).Returns(asyncTestProducts); // 4. 创建控制器实例 var controller = new ProductsController(mockRepo.Object); // 5. 调用要测试的异步方法(测试第2页,每页2条) var result = await controller.Index(page: 2, pageSize: 2); // 6. 断言验证结果 var viewResult = Assert.IsType<ViewResult>(result); var vm = Assert.IsType<ProductListViewModel>(viewResult.Model); // 验证分页数据:第2页应该返回商品4、商品5 Assert.Equal(2, vm.Products.Count); Assert.Contains(vm.Products, p => p.Id == 4); Assert.Contains(vm.Products, p => p.Id == 5); // 验证分页信息 Assert.Equal(5, vm.PagingInfo.TotalItems); Assert.Equal(2, vm.PagingInfo.CurrentPage); Assert.Equal(2, vm.PagingInfo.ItemsPerPage); } } }
方案二:优化仓储接口,让测试更简单
如果不想写上面的辅助类,你可以把分页和异步逻辑封装到仓储里,让控制器只调用仓储的封装方法,这样Mock起来会轻松很多:
1. 修改仓储接口与实现
// 修改后的仓储接口 namespace Project.Models; public interface IProductRepository { // 封装分页和异步查询 Task<int> GetTotalProductCountAsync(); Task<List<Product>> GetPagedProductsAsync(int page, int pageSize); } // 实现类更新 public class EFProductRepository : IProductRepository { private readonly ProjectDBContext _context; public EFProductRepository(ProjectDBContext ctx) => _context = ctx; public async Task<int> GetTotalProductCountAsync() => await _context.Products.CountAsync(); public async Task<List<Product>> GetPagedProductsAsync(int page, int pageSize) => await _context.Products .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); }
2. 同步更新控制器方法
public async Task<IActionResult> Index(int page = 1, int pageSize = 10) { var totalItems = await _repo.GetTotalProductCountAsync(); var pagedProducts = await _repo.GetPagedProductsAsync(page, pageSize); var vm = new ProductListViewModel { Products = pagedProducts, PagingInfo = new PagingInfo { CurrentPage = page, ItemsPerPage = pageSize, TotalItems = totalItems } }; return View(vm); }
3. 编写简洁的单元测试
这种情况下Mock仓储就不用管IQueryable的异步细节了,直接Mock返回结果就行:
[Fact] public async Task Index_ReturnsCorrectPagedData_ForSecondPage() { // 1. Mock仓储 var mockRepo = new Mock<IProductRepository>(); mockRepo.Setup(r => r.GetTotalProductCountAsync()).ReturnsAsync(5); mockRepo.Setup(r => r.GetPagedProductsAsync(2, 2)) .ReturnsAsync(new List<Product> { new Product { Id = 4, Name = "商品4" }, new Product { Id = 5, Name = "商品5" } }); // 2. 创建控制器 var controller = new ProductsController(mockRepo.Object); // 3. 调用方法 var result = await controller.Index(2, 2); // 4. 断言验证 var viewResult = Assert.IsType<ViewResult>(result); var vm = Assert.IsType<ProductListViewModel>(viewResult.Model); Assert.Equal(2, vm.Products.Count); Assert.Equal(5, vm.PagingInfo.TotalItems); Assert.Equal(2, vm.PagingInfo.CurrentPage); }
一些小提示
- 单元测试要聚焦控制器的逻辑:比如验证分页参数是否正确传递、视图模型是否正确组装,不用关心仓储和数据库的具体实现。
- 如果你用的是xUnit,测试方法必须加
async,返回Task,这样才能正确执行异步测试。 - 方案二的代码结构更符合“单一职责”:仓储负责数据查询逻辑,控制器只负责接收请求、调用仓储、返回结果,测试起来也更清晰。
备注:内容来源于stack exchange,提问作者mort89
相关产品推荐
相关产品推荐

