65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2020 年 5 月 10 日

CPOL

4分钟阅读

viewsIcon

13285

具有数据库依赖项时遇到的测试问题以及如何解决这些问题。

引言

对具有数据库依赖的应用程序进行自动化测试是一项艰巨的任务。单元测试帮不上忙,因为数据库无法完美模拟。当进行 update, deleteinsert 操作时,可以通过在之后运行 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();
     }
}
基类中最重要的一部分是构造函数。在 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 的每个数据库测试。我可以测试我需要测试的一切。完成的 updateinsertdelete 操作、受影响的实体以及更改的实体总数是否有意义。所有这些都可以在不运行任何 select 查询的情况下完成。

历史

  • 2020 年 5 月 10 日:初始版本
© . All rights reserved.