目录
简要说明了.NET Core数据库测试存在的问题。随后,通过GitHub上的具体代码示例说明了解决方案。
介绍
对具有数据库依赖性的应用程序进行自动测试是一项艰巨的任务。因为数据库不是完全可模拟的,所以单元测试不会对您有所帮助。如果做一个update,delete或者insert,您可以在查询之后运行select查询来检查查询的结果,但是这样您就不会检查不需要的副作用。可能受影响的表比需要的多,或者执行的查询比需要的多。这是这些问题的解决方案。
背景
拥有TDD for .NET Core的经验会有所帮助,最好具有xUnit的经验,并且EF Core经验会有所帮助。
使用代码
首先,这是要测试的代码。存在要注入的数据库上下文依赖关系,以及将数据保存到数据库中的方法。添加并保存的实体作为方法的输出返回。
public class TodoRepository : ITodoRepository
{
private readonly ProjectContext _projectContext;
public TodoRepository(ProjectContext projectContext)
{
_projectContext = projectContext;
}
public async Task<Entities.TodoItem> SaveItem(TodoItem item)
{
var newItem = new Entities.TodoItem()
{
To do = item.Todo
};
_projectContext.TodoItems.Add(newItem);
await _projectContext.SaveChangesAsync();
return newItem;
}
}
从逻辑上讲,此依赖关系需要正确解决。Startup类中有一个用于此目的的方法。上面描述的存储库类被添加到这里,就像它需要的数据库上下文和依赖于它的控制器一样。
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddDbContext<ProjectContext>(options =>
{
var connectionString = Configuration["ConnectionString"];
options.UseSqlite(connectionString,
sqlOptions =>
{
sqlOptions.MigrationsAssembly
(typeof(Startup).GetTypeInfo().Assembly.GetName().Name);
});
});
services.AddTransient<ITodoRepository, TodoRepository>();
}
可以解析要测试的依赖关系很好,但是现在我们需要将依赖关系用于测试目的。这样的测试应如下所示:
public class TodoRepositoryTest : TestBase<ITodoRepository>
{
private ITodoRepository _todoRepository;
private readonly List<(object Entity, EntityState EntityState)> _entityChanges =
new List<(object Entity, EntityState entityState)>();
public TodoRepositoryTest(WebApplicationFactory<Startup> webApplicationFactory) :
base(webApplicationFactory, @"Data Source=../../../../project3.db")
{
}
[Fact]
public async Task SaveItemTest()
{
// arrange
var todoItem = new TodoItem()
{
To do = "TestItem"
};
// act
var savedEntity = await _todoRepository.SaveItem(todoItem);
// assert
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:要声明的entitychanges(更改的种类,例如添加/更新的内容以及实体本身)
- SaveItemTest:完成实际工作的测试方法。它创建方法参数,调用该方法,然后对所有相关的内容进行断言:如果为主键分配了一个值,如果实际上只有一个实体发生了更改,如果此实体所做的更改确实是增加(而不仅仅是更新)并且如果添加的实体具有我们期望的类型。我们对此断言,之后没有运行选择查询。这可能是因为在通过另一种方法运行测试时,我们仅接收到所有实体更改。
- AddEntityChange:这是刚才提到的另一种方法。它接收所有包含实体本身的实体更改。
- SetTestInstance要使用名为_todoRepository的测试实例,需要通过此方法进行设置。
从具有所有样板代码的基类中调用该SetTestInstance方法以设置数据库集成测试。这是基类:
public abstract class TestBase<TTestType> : IDisposable, ITestContext,
IClassFixture<WebApplicationFactory<Startup>>
{
protected readonly HttpClient HttpClient;
protected TestBase(WebApplicationFactory<Startup> webApplicationFactory,
string newConnectionString)
{
HttpClient = webApplicationFactory.WithWebHostBuilder(whb =>
{
whb.ConfigureAppConfiguration((context, configbuilder) =>
{
configbuilder.AddInMemoryCollection(new Dictionary<string, string>
{
{"ConnectionString", newConnectionString}
});
});
whb.ConfigureTestServices(sc =>
{
sc.AddSingleton<ITestContext>(this);
ReplaceDbContext(sc, newConnectionString);
var scope = sc.BuildServiceProvider().CreateScope();
var testInstance = scope.ServiceProvider.GetService<TTestType>();
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<ProjectContext, TestProjectContext>();
}
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<ProjectContext> options,
ITestContext testContext) : base(options)
{
_testContext = testContext;
}
public override async Task<int> 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 中复制到一个update操作中,该操作在更改真正保存到数据库之后被调用。这样,我们的测试类始终会收到已断言的已保存更改。测试问题现已解决。完整的代码在GiHub上。
兴趣点
我真的很喜欢我发现的这种工作方式。编写样板代码很烦人,但这是一项一次性的工作。对于使用Entity Framework Core 3.1的每个数据库测试,该代码均可重用。我可以测试所有需要测试的东西。完成的update,insert和delete,受影响的实体以及更改的实体总数也很有意义。在测试select之后,无需运行任何查询就可以完成所有操作。