Entity Framework Core数据库测试实践

在进行数据库操作测试时,经常会遇到一些挑战,因为数据库并不是完美的可模拟对象。当执行更新、删除或插入操作时,可以通过执行一个查询来检查结果,但这并不能检查到不期望的副作用。可能有更多的表受到了影响,或者执行了更多的查询。本文将介绍如何解决这类问题。

对于.NET Core的测试驱动开发(TDD)有一定的了解会很有帮助,最好有xUnit和EF Core的经验。

使用代码

首先,来看要测试的代码。这里有一个数据库上下文依赖注入,以及一个将数据保存到数据库的方法。添加并保存的实体作为方法的输出返回。

public class TodoRepository : ITodoRepository { private readonly ProjectContext _projectContext; public TodoRepository(ProjectContext projectContext) { _projectContext = projectContext; } public async Task SaveItem(TodoItem item) { var newItem = new Entities.TodoItem() { Todo = item.Todo }; _projectContext.TodoItems.Add(newItem); await _projectContext.SaveChangesAsync(); return newItem; } }

逻辑上,这个依赖需要正确解决。在Startup类中有一个方法用于此目的。上面描述的存储库类在这里被添加,就像它需要的数据库上下文以及依赖它的控制器一样。

public void ConfigureServices(IServiceCollection services) { services.AddControllers(); services.AddDbContext(options => { var connectionString = Configuration["ConnectionString"]; options.UseSqlite(connectionString, sqlOptions => { sqlOptions.MigrationsAssembly(typeof(Startup).GetTypeInfo().Assembly.GetName().Name); }); }); services.AddTransient(); }

测试依赖

现在需要使用依赖进行测试。这样的测试应该看起来像这样:

public class TodoRepositoryTest : TestBase { private ITodoRepository _todoRepository; private readonly List<(object Entity, EntityState EntityState)> _entityChanges = new List<(object Entity, EntityState entityState)>(); public TodoRepositoryTest(WebApplicationFactory webApplicationFactory) : base(webApplicationFactory, @"Data Source=../../../../project3.db") { } [Fact] public async Task SaveItemTest() { var todoItem = new TodoItem() { Todo = "TestItem" }; var savedEntity = await _todoRepository.SaveItem(todoItem); Assert.NotNull(savedEntity); Assert.NotEqual(0, savedEntity.Id); Assert.Equal(todoItem.Todo, savedEntity.Todo); var onlyAddedItem = _entityChanges.Single(); Assert.Equal(EntityState.Added, onlyAddedItem.EntityState); var addedEntity = (Database.Entities.TodoItem)onlyAddedItem.Entity; Assert.Equal(addedEntity.Id, savedEntity.Id); } public override void AddEntityChange(object newEntity, EntityState entityState) { _entityChanges.Add((newEntity, entityState)); } protected override void SetTestInstance(ITodoRepository testInstance) { _todoRepository = testInstance; } }

类的方法和变量

这个类有以下方法和变量:

  • _todoRepository: 要测试的实例
  • _entityChanges: 实体变化(变化类型如添加/更新和实体本身)以进行断言
  • SaveItemTest: 执行实际工作的测试方法。它创建方法参数,调用方法,然后对所有相关的内容进行断言:是否分配了主键值,是否真的只有一个实体变化,这个实体变化是否真的是添加(不仅仅是更新),以及添加的实体是否是期望的类型。断言这一点,而不需要在测试后运行任何查询。这可能是因为在运行测试时通过另一种方法接收所有实体变化。
  • AddEntityChange: 这是上面提到的另一种方法。它接收所有实体变化,包括实体本身。
  • SetTestInstance: 要使用名为_todoRepository的测试实例,需要通过此方法设置。

基类

基类是:

public abstract class TestBase : IDisposable, ITestContext, IClassFixture> { protected readonly HttpClient HttpClient; protected TestBase(WebApplicationFactory webApplicationFactory, string newConnectionString) { HttpClient = webApplicationFactory.WithWebHostBuilder(whb => { whb.ConfigureAppConfiguration((context, configbuilder) => { configbuilder.AddInMemoryCollection(new Dictionary { { "ConnectionString", newConnectionString } }); }); whb.ConfigureTestServices(sc => { sc.AddSingleton(this); ReplaceDbContext(sc, newConnectionString); var scope = sc.BuildServiceProvider().CreateScope(); var testInstance = scope.ServiceProvider.GetService(); SetTestInstance(testInstance); }); }).CreateClient(); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } public abstract void AddEntityChange(object newEntity, EntityState entityState); private void ReplaceDbContext(IServiceCollection serviceCollection, string newConnectionString) { var serviceDescriptor = serviceCollection.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(ProjectContext)); serviceCollection.Remove(serviceDescriptor); serviceCollection.AddDbContext(); } protected abstract void SetTestInstance(TTestType testInstance); protected virtual void Dispose(bool disposing) { if (disposing) HttpClient.Dispose(); } }

基类中最重要的部分是构造函数。在xUnit中,测试的初始化通常在构造函数中完成。一旦正确完成,测试就可以轻松实现。这些是构造函数中调用的最重要的方法:

  • AddInMemoryCollection: 这里,设置特定于测试的配置参数,即连接字符串。
  • AddSingleton: 测试本身被解析为单例,以便从数据库上下文中获取更新。
  • ReplaceDbContext: 现有的数据库上下文需要被替换为一个继承自它的数据库上下文,以扩展其功能,使其能够更新测试。
  • CreateClient: 调用此方法以触发Program类和Startup类中的代码。
  • GetService: 需要调用测试方法的实例可以通过此方法调用解析。这是可能的,因为Program类和Startup类中的代码被触发了。
  • SetTestInstance: 需要调用测试方法的实例需要通过调用此方法设置。

引入新依赖

由于在这里引入了一个新的依赖(TestProjectContext),需要实现这个依赖:

public class TestProjectContext : ProjectContext { private readonly ITestContext _testContext; public TestProjectContext(DbContextOptions options, ITestContext testContext) : base(options) { _testContext = testContext; } public override async Task SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken()) { Action updateEntityChanges = () => { }; var entries = ChangeTracker.Entries(); foreach (var entry in entries) { var state = entry.State; updateEntityChanges += () => _testContext.AddEntityChange(entry.Entity, state); } var result = await base.SaveChangesAsync(cancellationToken); updateEntityChanges(); return result; } }

每次保存实体变化(在本应用程序中总是通过SaveChangesAsync完成)时,变化从ChangeTracker复制到一个更新操作中,该操作在变化实际保存到数据库后被调用。通过这种方式,测试类总是接收到保存的变化以进行断言。现在测试问题已经解决了。完整的代码在GitHub上。

沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485