使用数据库依赖的 .NET Core 应用程序的集成测试





5.00/5 (3投票s)
具有数据库依赖项时遇到的测试问题以及如何解决这些问题。
引言
对具有数据库依赖的应用程序进行自动化测试是一项艰巨的任务。单元测试帮不上忙,因为数据库无法完美模拟。当进行 update
, delete
或 insert
操作时,可以通过在之后运行 select
查询来检查查询的结果,但这样您无法检查不必要的副作用。可能受到影响的表比需要的更多,或者执行的查询比需要的更多。这是解决此类问题的方案。
背景
如果有一些 .NET Core 的 TDD 经验,最好是使用 xUnit,并且有 EF Core 经验会很有帮助。
Using the Code
首先,这是要测试的代码。需要注入一个数据库上下文依赖,以及将数据保存到数据库中的方法。添加并保存的实体作为该方法的输出返回。
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
: 执行实际工作的测试方法。它创建方法参数,调用该方法,然后断言所有相关内容:是否为主键分配了值,是否确实只有一个实体更改,此实体更改是否确实是一个添加(不仅仅是更新),以及添加的实体是否具有我们期望的类型。我们在没有运行后续 select 查询的情况下对此进行断言。这可能是因为我们在运行测试时通过另一种方法接收所有实体更改。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();
}
}
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
查询的情况下完成。
历史
- 2020 年 5 月 10 日:初始版本