EF Core 测试示例EF Core testing sample
提示
可在 GitHub 上找到此文档中的代码作为可运行示例。 请注意,其中某些测试应该会失败。 下面说明了这种情况的原因。
此文档介绍了用于测试使用 EF Core 的代码的示例。
应用程序The application
该示例包含两个项目:
- ItemsWebApi:通过单个控制器ASP.NET Core 支持的非常简单的 Web API
- 测试:用于测试控制器的XUnit测试项目
模型和业务规则The model and business rules
此 API 支持以下两种实体类型: Items 和 Tags 。
- Items具有区分大小写的名称和的集合 Tags 。
- 每个 Tag 都有一个标签和一个计数,表示它应用到的次数 Item 。
- 每个 Item 仅应有一个 Tag 具有给定标签的。
- 如果多次用相同标签标记项,则会增加带有该标签的现有标记的计数,而不会增加正在创建的新标记。
- 删除将 Item 删除所有关联的 Tags 。
Item实体类型The Item entity type
Item
实体类型:
public class Item
{
private readonly int _id;
private readonly List<Tag> _tags = new List<Tag>();
private Item(int id, string name)
{
_id = id;
Name = name;
}
public Item(string name)
{
Name = name;
}
public Tag AddTag(string label)
{
var tag = _tags.FirstOrDefault(t => t.Label == label);
if (tag == null)
{
tag = new Tag(label);
_tags.Add(tag);
}
tag.Count++;
return tag;
}
public string Name { get; }
public IReadOnlyList<Tag> Tags => _tags;
}
及其在中的配置 DbContext.OnModelCreating
:
modelBuilder.Entity<Item>(
b =>
{
b.Property("_id");
b.HasKey("_id");
b.Property(e => e.Name);
b.HasMany(e => e.Tags).WithOne().IsRequired();
});
请注意,实体类型限制了它可用于反映域模型和业务规则的方式。 具体而言:
- 主键直接映射到
_id
字段,而不公开公开- EF 检测并使用接受主键值和名称的私有构造函数。
- 此
Name
属性是只读的,仅在构造函数中设置。 - Tags公开为
IReadOnlyList<Tag>
以防止任意修改。- EF
Tags
通过匹配属性的名称将属性与_tags
支持字段相关联。 AddTag
方法采用标签标签,并实现上面所述的业务规则。 也就是说,只为新标签添加了标记。 否则,现有标签上的计数将增加。
- EF
- 为
Tags
多对一关系配置导航属性- 不需要从到的导航属性 Tag Item ,因此不包含。
- 此外,不 Tag 会定义外键属性。 EF 将创建和管理卷影状态中的属性。
Tag实体类型The Tag entity type
Tag
实体类型:
public class Tag
{
private readonly int _id;
private Tag(int id, string label)
{
_id = id;
Label = label;
}
public Tag(string label) => Label = label;
public string Label { get; }
public int Count { get; set; }
}
及其在中的配置 DbContext.OnModelCreating
:
modelBuilder.Entity<Tag>(
b =>
{
b.Property("_id");
b.HasKey("_id");
b.Property(e => e.Label);
});
与类似 Item , Tag 隐藏其主键并使属性成为 Label
只读的。
ItemsControllerThe ItemsController
Web API 控制器非常基本。 它 DbContext
通过构造函数注入从依赖关系注入容器中获取:
private readonly ItemsContext _context;
public ItemsController(ItemsContext context)
=> _context = context;
它具有获取所有 Items 或 Item 具有给定名称的方法:
[HttpGet]
public IEnumerable<Item> Get()
=> _context.Set<Item>().Include(e => e.Tags).OrderBy(e => e.Name);
[HttpGet]
public Item Get(string itemName)
=> _context.Set<Item>().Include(e => e.Tags).FirstOrDefault(e => e.Name == itemName);
它有一个用于添加新的方法 Item :
[HttpPost]
public ActionResult<Item> PostItem(string itemName)
{
var item = _context.Add(new Item(itemName)).Entity;
_context.SaveChanges();
return item;
}
用标签标记的方法 Item :
[HttpPost]
public ActionResult<Tag> PostTag(string itemName, string tagLabel)
{
var tag = _context
.Set<Item>()
.Include(e => e.Tags)
.Single(e => e.Name == itemName)
.AddTag(tagLabel);
_context.SaveChanges();
return tag;
}
以及用于删除 Item 和全部关联的方法 Tags :
[HttpDelete("{itemName}")]
public ActionResult<Item> DeleteItem(string itemName)
{
var item = _context
.Set<Item>()
.SingleOrDefault(e => e.Name == itemName);
if (item == null)
{
return NotFound();
}
_context.Remove(item);
_context.SaveChanges();
return item;
}
为了减少混乱,已经删除了大多数验证和错误处理。
测试The Tests
这些测试被组织成通过多数据库提供程序配置运行:
- SQL Server 提供程序,它是应用程序使用的提供程序
- SQLite 提供程序
- 使用内存中 SQLite 数据库的 SQLite 提供程序
- EF 内存中数据库提供程序
为实现此目的,方法是将所有测试放在一个基类中,然后从此继承以测试每个提供程序。
提示
如果使用的不是 LocalDB,则需要更改 SQL Server 连接字符串。
提示
有关使用 SQLite 进行内存中测试的指南,请参阅使用 sqlite 进行测试。
以下两个测试应该会失败:
Can_remove_item_and_all_associated_tags
与 EF 内存中数据库提供程序一起运行时Can_add_item_differing_only_by_case
与 SQL Server 提供程序一起运行时
下面更详细地介绍了这种情况。
设置和播种数据库Setting up and seeding the database
与大多数测试框架一样,XUnit 将为每个测试运行创建一个新的测试类实例。 此外,XUnit 不会并行运行给定测试类中的测试。 这意味着我们可以在测试构造函数中设置和配置数据库,并且它将处于每个测试的已知状态。
提示
此示例将重新创建每个测试的数据库。 这适用于 SQLite 和 EF 内存中数据库测试,但会涉及到与其他数据库系统(包括 SQL Server)的巨大开销。 跨测试共享数据库中介绍了降低此开销的方法。
运行每个测试时:
- 为使用中的提供程序配置 DbContextOptions,并将其传递到基类构造函数
- 这些选项存储在属性中,并在整个测试中用于创建 DbContext 实例
- 调用种子方法来创建和播种数据库
- Seed 方法通过删除并重新创建数据库来确保数据库干净
- 创建了一些已知的测试实体,并将其保存到数据库中
protected ItemsControllerTest(DbContextOptions<ItemsContext> contextOptions)
{
ContextOptions = contextOptions;
Seed();
}
protected DbContextOptions<ItemsContext> ContextOptions { get; }
private void Seed()
{
using (var context = new ItemsContext(ContextOptions))
{
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
var one = new Item("ItemOne");
one.AddTag("Tag11");
one.AddTag("Tag12");
one.AddTag("Tag13");
var two = new Item("ItemTwo");
var three = new Item("ItemThree");
three.AddTag("Tag31");
three.AddTag("Tag31");
three.AddTag("Tag31");
three.AddTag("Tag32");
three.AddTag("Tag32");
context.AddRange(one, two, three);
context.SaveChanges();
}
}
然后,每个具体的测试类都继承自此。 例如:
public class SqliteItemsControllerTest : ItemsControllerTest
{
public SqliteItemsControllerTest()
: base(
new DbContextOptionsBuilder<ItemsContext>()
.UseSqlite("Filename=Test.db")
.Options)
{
}
}
测试结构Test structure
即使应用程序使用依赖关系注入,测试也不是这样。 在此处使用依赖项注入会很好,但它需要的附加代码却没有什么价值。 而是使用创建 DbContext, new
然后直接作为依赖关系传递到控制器。
然后,每个测试在控制器上执行受测方法,并断言结果与预期结果相同。 例如:
[Fact]
public void Can_get_items()
{
using (var context = new ItemsContext(ContextOptions))
{
var controller = new ItemsController(context);
var items = controller.Get().ToList();
Assert.Equal(3, items.Count);
Assert.Equal("ItemOne", items[0].Name);
Assert.Equal("ItemThree", items[1].Name);
Assert.Equal("ItemTwo", items[2].Name);
}
}
请注意,不同的 DbContext 实例用于对数据库进行种子设定并运行测试。 这可确保在进行种子设定时测试不使用(或正在转移)上下文所跟踪的实体。 它还更适合于 web 应用和服务中发生的情况。
用于改变数据库的测试在测试中创建第二个 DbContext 实例,原因类似。 也就是说,创建新的、干净的上下文,然后从数据库中读取该数据库,以确保将更改保存到数据库中。 例如:
[Fact]
public void Can_add_item()
{
using (var context = new ItemsContext(ContextOptions))
{
var controller = new ItemsController(context);
var item = controller.PostItem("ItemFour").Value;
Assert.Equal("ItemFour", item.Name);
}
using (var context = new ItemsContext(ContextOptions))
{
var item = context.Set<Item>().Single(e => e.Name == "ItemFour");
Assert.Equal("ItemFour", item.Name);
Assert.Equal(0, item.Tags.Count);
}
}
另外两个相关测试涵盖了有关添加标记的业务逻辑。
[Fact]
public void Can_add_tag()
{
using (var context = new ItemsContext(ContextOptions))
{
var controller = new ItemsController(context);
var tag = controller.PostTag("ItemTwo", "Tag21").Value;
Assert.Equal("Tag21", tag.Label);
Assert.Equal(1, tag.Count);
}
using (var context = new ItemsContext(ContextOptions))
{
var item = context.Set<Item>().Include(e => e.Tags).Single(e => e.Name == "ItemTwo");
Assert.Equal(1, item.Tags.Count);
Assert.Equal("Tag21", item.Tags[0].Label);
Assert.Equal(1, item.Tags[0].Count);
}
}
[Fact]
public void Can_add_tag_when_already_existing_tag()
{
using (var context = new ItemsContext(ContextOptions))
{
var controller = new ItemsController(context);
var tag = controller.PostTag("ItemThree", "Tag32").Value;
Assert.Equal("Tag32", tag.Label);
Assert.Equal(3, tag.Count);
}
using (var context = new ItemsContext(ContextOptions))
{
var item = context.Set<Item>().Include(e => e.Tags).Single(e => e.Name == "ItemThree");
Assert.Equal(2, item.Tags.Count);
Assert.Equal("Tag31", item.Tags[0].Label);
Assert.Equal(3, item.Tags[0].Count);
Assert.Equal("Tag32", item.Tags[1].Label);
Assert.Equal(3, item.Tags[1].Count);
}
}
使用不同数据库提供程序时出现的问题Issues using different database providers
使用与生产应用程序中使用的不同的数据库系统进行测试可能导致出现问题。 它们在测试使用 EF Core 的代码的概念级别中进行了介绍。
以下部分介绍了本示例中的测试演示的两个问题示例。
应用程序中断时的测试通过Test passes when the application is broken
应用程序的要求之一是 “ Items 具有区分大小写的名称和集合 Tags “。 这非常简单,可以测试:
[Fact]
public void Can_add_item_differing_only_by_case()
{
using (var context = new ItemsContext(ContextOptions))
{
var controller = new ItemsController(context);
var item = controller.PostItem("itemtwo").Value;
Assert.Equal("itemtwo", item.Name);
}
using (var context = new ItemsContext(ContextOptions))
{
var item = context.Set<Item>().Single(e => e.Name == "itemtwo");
Assert.Equal(0, item.Tags.Count);
}
}
对 EF 内存中数据库运行此测试表明一切正常。 使用 SQLite 时,所有内容仍能正常工作。 但运行时测试会失败 SQL Server!
System.InvalidOperationException : Sequence contains more than one element
at System.Linq.ThrowHelper.ThrowMoreThanOneElementException()
at System.Linq.Enumerable.Single[TSource](IEnumerable`1 source)
at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.Execute[TResult](Expression query)
at Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider.Execute[TResult](Expression expression)
at System.Linq.Queryable.Single[TSource](IQueryable`1 source, Expression`1 predicate)
at Tests.ItemsControllerTest.Can_add_item_differing_only_by_case()
这是因为在默认情况下,EF 内存中数据库数据库和 SQLite 数据库都是区分大小写的。 另一方面,SQL Server 不区分大小写!
按照设计,EF Core 不会更改这些行为,因为强制进行区分大小写可能会对性能产生重大影响。
知道这是一个问题,我们可以修复应用程序并在测试中进行补偿。 但是,此处的要点是,如果仅通过 EF 内存中数据库或 SQLite 提供程序进行测试,则可能会丢失此 bug。
当应用程序正确时测试失败Test fails when the application is correct
我们的应用程序的另一个要求是 “删除” Item 应删除所有关联的 Tags 。 “ 同样,易于测试:
[Fact]
public void Can_remove_item_and_all_associated_tags()
{
using (var context = new ItemsContext(ContextOptions))
{
var controller = new ItemsController(context);
var item = controller.DeleteItem("ItemThree").Value;
Assert.Equal("ItemThree", item.Name);
}
using (var context = new ItemsContext(ContextOptions))
{
Assert.False(context.Set<Item>().Any(e => e.Name == "ItemThree"));
Assert.False(context.Set<Tag>().Any(e => e.Label.StartsWith("Tag3")));
}
}
此测试通过 SQL Server 和 SQLite,但对于 EF 内存中数据库失败!
Assert.False() Failure
Expected: False
Actual: True
at Tests.ItemsControllerTest.Can_remove_item_and_all_associated_tags()
在这种情况下,应用程序将正常工作,因为 SQL Server 支持级联删除。 SQLite 还支持级联删除,这与大多数关系数据库一样,因此,可以在 SQLite 上进行测试。 另一方面,EF 内存中数据库不支持级联删除。 这意味着应用程序的此部分不能通过 EF 内存中数据库提供程序进行测试。