我正在使用实体框架作为我的存储库构建一个系统,虽然我同意我不应该测试实体框架,但我希望能够验证我的存储库中围绕实体框架的逻辑。然而,我在 Stack Overflow 上或其他地方都无法找到真正的解决方案。大多数问题最终都会导致人们说不要测试框架。
在我的示例中,我有一个存储库,它采用 BillEntity 类型并将其存储为 BillDal,现在我的账单的存储计划与我的实体不同。示例 账单实体具有行项目,而行项目又具有分配,但它们没有 ID。但是,我的存储对象将行项目和分配存储为账单上的平面对象。这就是所有存储库逻辑,并且应该测试其映射和计算。后来我想用更复杂的映射逻辑替换我的 dum、clear 和 readd 逻辑,这些逻辑也需要测试。
我尝试在内存数据库中使用 SQLLite,但无法创建它,因为我在模型上定义了“排序规则”,例如
modelBuilder.UseCollation("utf8mb4_unicode_520_ci")
我的数据库是 MariaDb。是否存在可以插入并模拟 EntityFramework 的测试框架,而无需我自己模拟所有内容?
我知道我可以只测试我的辅助方法,但我也希望能够为我的存储库编写功能测试。
这是我想要进行单元测试的存储库代码:
public class BillsMySqlRepository : BaseMySQLRepository, IBillsRepository
{
public async Task<BillEntity> GetById(long id)
{
await using var context = await this.contextFactory.CreateDbContextAsync();
var dal = await context.Bills
.Include(x => x.LineItems)
.Include(x => x.Allocations)
.FirstOrDefaultAsync(x => x.Id == id);
this.ThrowNotFoundIfNull(dal, "Bill");
var entity = this.mapper.Map<BillEntity>(dal);
entity.LineItems = this.CreateLineItemEntities(dal);
return entity;
}
private List<BillLineItemEntity> CreateLineItemEntities(BillDal dal)
{
var lineItemEntities = new List<BillLineItemEntity>();
var allocationMap = this.CreateAllocationMap(dal.Allocations);
foreach (var lineItemDal in dal.LineItems)
{
var lineItemEntity = this.mapper.Map<BillLineItemEntity>(lineItemDal);
lineItemEntity.Allocations = allocationMap.TryGetValue(lineItemDal.Id, out var value)
? value.Select(x => this.mapper.Map<BillAllocationEntity>(x)).ToList()
: new List<BillAllocationEntity>();
lineItemEntities.Add(lineItemEntity);
}
return lineItemEntities;
}
private Dictionary<long, List<BillAllocationDal>> CreateAllocationMap(List<BillAllocationDal> allocationDals)
{
var allocationMap = new Dictionary<long, List<BillAllocationDal>>();
foreach (var allocation in allocationDals)
{
if (!allocationMap.ContainsKey(allocation.LineItemId))
{
allocationMap.Add(allocation.LineItemId, new List<BillAllocationDal>());
}
allocationMap[allocation.LineItemId].Add(allocation);
}
return allocationMap;
}
public async Task<long> Create(CreateOrUpdateBillEntity entity)
{
Guard.Against.InvalidCreateOrUpdateBillEntity(entity, nameof(entity));
await using var context = await this.contextFactory.CreateDbContextAsync();
var dal = this.mapper.Map<BillDal>(entity);
this.AddId(dal);
dal.CreatedMilliseconds = DateTimeOffset.Now.ToUnixTimeMilliseconds();
dal.UpdatedMilliseconds = DateTimeOffset.Now.ToUnixTimeMilliseconds();
this.AddLineItemAndAllocations(dal, entity);
this.UpdateMetadataFields(dal, entity);
context.Bills.Add(dal);
await context.SaveChangesAsync();
return dal.Id;
}
public async Task Update(long id, CreateOrUpdateBillEntity entity)
{
Guard.Against.NegativeOrZero(id, nameof(id));
Guard.Against.InvalidCreateOrUpdateBillEntity(entity, nameof(entity));
await using var context = await this.contextFactory.CreateDbContextAsync();
var dal = await context.Bills
.Include(x => x.LineItems)
.Include(x => x.Allocations)
.FirstOrDefaultAsync(x => x.Id == id);
this.ThrowNotFoundIfNull(dal, "Bill");
// TODO: Later we need to make this smarter that matches and updates instead of just clearing and re-adding
context.BillLineItems.RemoveRange(dal.LineItems);
context.BillAllocations.RemoveRange(dal.Allocations);
this.AddLineItemAndAllocations(dal, entity);
// TODO DONE
this.UpdateMetadataFields(dal, entity);
await context.SaveChangesAsync();
}
}
达尔斯:
[Table("bills")]
[Index(nameof(CompanyId), Name = "index_bills_company_id")]
public class BillDal : IIdDal, ICompanyIdDal
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
[Column("id")]
public long Id { get; set; }
[Required]
[Column("company_id")]
public long CompanyId { get; set; }
[StringLength(150)]
[Column("reference")]
public string? Reference { get; set; }
[Column("memo")]
public string? Memo { get; set; }
[Column("source")]
public long? Source { get; set; }
[Column("source_type")]
public BillSource? SourceType { get; set; }
[Column("destination")]
public long? Destination { get; set; }
[Column("destination_type")]
public BillSource? DestinationType { get; set; }
[Column("property_id")]
public long PropertyId { get; set; }
[Column("bill_date_ms")]
public long BillDateMilliseconds { get; set; }
[Column("due_date_ms")]
public long? DueDateMilliseconds { get; set; }
[Required]
[Column("create_ms")]
public long CreatedMilliseconds { get; set; }
[Required]
[Column("updated_ms")]
public long UpdatedMilliseconds { get; set; }
// The following field are use as metadata to be able to fetch the bills quicker
[Required]
[Column("amount")]
public decimal Amount { get; set; }
[Required]
[Column("amount")]
public decimal Due { get; set; }
[ForeignKey(nameof(CompanyId))]
public virtual CompanyDal Company { get; set; }
public virtual List<BillAllocationDal> Allocations { get; set; }
public virtual List<BillLineItemDal> LineItems { get; set; }
}
[Table("bills_allocations")]
public class BillAllocationDal : IIdDal, ICompanyIdDal
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
[Column("id")]
public long Id { get; set; }
[Required]
[Column("company_id")]
public long CompanyId { get; set; }
[Required]
[Column("bill_id")]
public long BillId { get; set; }
[Required]
[Column("bill_line_item_id")]
public long LineItemId { get; set; }
[Required]
[Column("transaction_id")]
public long TransactionId { get; set; }
[Required]
[Column("amount")]
public decimal Amount { get; set; }
[ForeignKey(nameof(BillId))]
public virtual BillDal Bill { get; set; }
[ForeignKey(nameof(CompanyId))]
public virtual CompanyDal Company { get; set; }
[ForeignKey(nameof(TransactionId))]
public virtual TransactionDal Transaction { get; set; }
[ForeignKey(nameof(LineItemId))]
public virtual BillLineItemDal LineItem { get; set; }
}
[Table("bills_line_items")]
public class BillLineItemDal : IIdDal, ICompanyIdDal
{
[Required]
[Column("bill_id")]
public long BillId { get; set; }
[Required]
[Column("chart_of_account_id")]
public long ChartOfAccountId { get; set; }
[StringLength(1024)]
[Column("description")]
public string Description { get; set; }
[Required]
[Column("amount")]
public decimal Amount { get; set; }
[ForeignKey(nameof(BillId))]
public virtual BillDal Bill { get; set; }
[ForeignKey(nameof(CompanyId))]
public virtual CompanyDal Company { get; set; }
[Required]
[Column("company_id")]
public long CompanyId { get; set; }
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
[Column("id")]
public long Id { get; set; }
}
我的实体:
public class BillEntity
{
public long Id { get; set; }
public long CompanyId { get; set; }
public string? Reference { get; set; }
public string? Memo { get; set; }
public long VendorId { get; set; }
public long PropertyId { get; set; }
public DateTimeOffset BillDate { get; set; }
public DateTimeOffset? DueDate { get; set; }
public List<BillLineItemEntity> LineItems { get; set; }
}
public class BillLineItemEntity
{
public long ChartOfAccountId { get; set; }
public string? Description { get; set; }
public decimal Amount { get; set; }
public List<BillAllocationEntity> Allocations { get; set; }
}
public class BillAllocationEntity
{
public long TransactionId { get; set; }
public decimal Amount { get; set; }
}
存储库模式超越 EF DbContext/DbSets 的一个理由是促进测试边界,这意味着存储库是您模拟的内容,而不是您测试的内容。您不需要编写测试来确认 EF 执行其应有的操作,或者 Automapper 执行其预期的操作。这些产品已经过测试,值得信赖。存储库不应包含任何需要测试的业务逻辑。最坏的情况是,如果存在一些边界逻辑,例如处理添加/删除场景而不是转储引用列表和重新添加引用,则该逻辑可以分为可测试的类。 IE。提供两个 ID 列表进行比较的方法,以查找需要添加哪些 ID 以及需要删除哪些 ID。
使用 Automapper 时,请考虑使用
ProjectTo
方法,而不是使用急切加载的关联来加载实体,调用 Map
,然后迭代关系。这可以产生更有效的查询,减少 JOIN 产生的笛卡尔积的大小。