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

ASP.NET Core 中的集成测试:DBContext 初始化陷阱

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2019年11月3日

CPOL

3分钟阅读

viewsIcon

25459

本文解释了在按照官方教程实现 ASP.NET Core 集成测试后不久遇到的问题,以及解决方案。

引言

最近在设置一个新的 .NET Core Web API 项目时,我决定使用 XUnit 编写一些集成测试,遵循 本教程

在编写第一个测试后不久,我在并行运行测试时遇到了问题。以下文字包含对问题的描述和建议的解决方案。 示例代码可在 https://github.com/majda-osmic/Analysis.XUnit.Parallel 找到。

安装

我创建了一个 CustomWebApplicationFactory(与教程中的方式相同)

    public class CustomWebApplicationFactory<TStartup> : 
             WebApplicationFactory<TStartup> where TStartup : class
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                var descriptor = services.SingleOrDefault
                   (d => d.ServiceType == typeof(DbContextOptions<CustomerDbContext>));

                if (descriptor != null)
                {
                    services.Remove(descriptor);
                }

                // Add ApplicationDbContext using an in-memory database for testing.
                services.AddDbContext<CustomerDbContext>
                  ((_, context) => context.UseInMemoryDatabase("InMemoryDbForTesting"));

                // Build the service provider.
                var serviceProvider = services.BuildServiceProvider();

                // Create a scope to obtain a reference to the database
                // context (ApplicationDbContext).
                using var scope = serviceProvider.CreateScope();

                var db = scope.ServiceProvider.GetRequiredService<CustomerDbContext>();
                var logger = scope.ServiceProvider.GetRequiredService
                             <ILogger<CustomWebApplicationFactory<TStartup>>>();

                // Ensure the database is created.
                db.Database.EnsureCreated();

                try
                {
                    // Seed the database with test data.
                    db.InitializeTestDatabase();
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, $"An error occurred seeding the database 
                                        with test messages. Error: {ex.Message}");
                }
            });
        }
    }

InitializeTestDatabase 扩展方法向数据库添加一些虚拟的 Customer 数据

        public static CustomerDbContext InitializeTestDatabase(this CustomerDbContext context)
        {
            if (!context.Customers.Any())
            {
                context.Customers.Add(new Customer
                {
                    FirstName = "John",
                    LastName = "Doe"
                });

                context.Customers.Add(new Customer
                {
                    FirstName = "Jane",
                    LastName = "Doe"
                });

                context.Customers.Add(new Customer
                {
                    FirstName = "Max",
                    LastName = "Mustermann"
                });

                context.SaveChanges();
            }
            return context;
        }

被测试的 API 包含一个非常简单的 Controller,如下所示

    [Route("[controller]")]
    [ApiController]
    public class CustomersController : ControllerBase
    {
        private readonly CustomerDbContext _context;

        public CustomersController(CustomerDbContext context)
        {
            _context = context;
        }

        // GET: Customers
        [HttpGet]
        public async Task<ActionResult<IEnumerable<Customer>>> GetCustomers()
        {
            return await _context.Customers.ToListAsync();
        }

        //.....
    }

现在,在设置好虚拟数据后,我编写了如下所示的测试方法

        [Theory]
        [InlineData("/Customers")]
        public async Task Get_ShouldReturnCorrectData(string url)
        {
            // Arrange
            var client = _factory.CreateClient();

            // Act
            var response = await client.GetAsync(url).ConfigureAwait(false);

            // Assert
            response.EnsureSuccessStatusCode(); // Status Code 200-299

            var customers = await response.DeserializeContent
                               <List<Customer>>().ConfigureAwait(false);
            Assert.Equal(3, customers.Count);
        }

内存中的测试数据库已使用 3 个虚拟客户对象初始化,因此我们期望获得 3 个 customer 对象作为对 GET Customers 请求的响应,并且这正是测试运行时发生的情况;一切都按预期工作。

问题

在进行一些测试,创建另一个测试类并编写更多测试后,Get_ShouldReturnCorrectData 突然开始失败,并显示以下消息

我开始调试测试,但这次奏效了。 事实证明,该测试仅在与其他所有测试一起执行时才失败。 在浏览代码,试图找出可能发生的事情时,我突然想到我的 Customer 模型类看起来像这样

    public class Customer
    {
        [Key]
        public int ID { get; set; }

        public string FirstName { get; set; } = string.Empty;
        public string LastName  { get; set; } = string.Empty;
    }

在我的 InitializeTestDatabase 方法中,我忘记设置我要添加的对象的 ID 属性。 我修复了该问题,测试变为绿色

        public static CustomerDbContext InitializeTestDatabase(this CustomerDbContext context)
        {
            if (!context.Customers.Any())
            {
                context.Customers.Add(new Customer
                {
                    ID = 1,
                    FirstName = "John",
                    LastName = "Doe"
                });

                context.Customers.Add(new Customer
                {
                    ID = 2,
                    FirstName = "Jane",
                    LastName = "Doe"
                });

                context.Customers.Add(new Customer
                {
                    ID = 3,
                    FirstName = "Max",
                    LastName = "Mustermann"
                });

                context.SaveChanges();
            }
            return context;
        }

但是,为什么?
在 Visual Studio 中启用所有公共运行时异常并在调试模式下运行所有测试后,在调用 InitializeTestDatabase 方法时出现以下异常

显然,InitializeTestDatabase 方法被多次调用,并且在设置 ID 属性后测试变为绿色的原因是 ArgumentException 实际上被包围方法调用的 try/catch 块吞没了。

那么,这些多次调用来自哪里? 为什么 Customer 对象被添加到 DBSet,尽管 InitializeTestDatabase 方法仅在集合中没有项目时才这样做

       if (!context.Customers.Any())
       {
              //…
       }

包含测试方法的实际测试装置如下所示

    public class CustomerTests : IClassFixture<CustomWebApplicationFactory<Startup>>
    {
        private readonly CustomWebApplicationFactory<Startup> _factory;

        public CustomerTests(CustomWebApplicationFactory<Startup> factory)
        {
            _factory = factory;
        }

        [Theory]
        [InlineData("/Customers")]
        public async Task Get_ShouldReturnCorrectData(string url)
        {
            // Arrange
            var client = _factory.CreateClient();

            // Act
            var response = await client.GetAsync(url).ConfigureAwait(false);

            // Assert
            response.EnsureSuccessStatusCode(); // Status Code 200-299

            var customers = await response.DeserializeContent
                                  <List<Customer>>().ConfigureAwait(false);
            Assert.Equal(3, customers.Count);
        }
    }

在添加也从 IClassFixture<CustomWebApplicationFactory<Startup>> 继承的第二个测试类后,CustomWebApplicationFactory 中的 ConfigureWebHost 方法被调用了两次:每个测试装置实例一次。 由于所有测试都是并行设置和运行的,因此在检查 Customers DbSet 中是否有任何项目时,会发生经典的同步问题。 本能地,由于一切都设置了两次,人们会认为传递给 InitializeTestDatabase 方法的 DBContext 对象应该是独立的,但由于这行代码,它们都访问相同的内存数据库

   services.AddDbContext<CustomerDbContext>
    ((_, context) => context.UseInMemoryDatabase("InMemoryDbForTesting"));

解决方案

解决此问题的最明显的解决方案,也是我为我的项目选择的解决方案,就是简单地锁定 InitializeTestDatabase 方法。

        private static object _customerContextLock = new object();

        public static CustomerDbContext InitializeTestDatabase(this CustomerDbContext context)
        {
            lock (_customerContextLock)
            {
                if (!context.Customers.Any())
                {
                    context.Customers.Add(new Customer
                    {
                        ID = 1,
                        FirstName = "John",
                        LastName = "Doe"
                    });

                    context.Customers.Add(new Customer
                    {
                        ID = 2,
                        FirstName = "Jane",
                        LastName = "Doe"
                    });

                    context.Customers.Add(new Customer
                    {
                        ID = 3,
                        FirstName = "Max",
                        LastName = "Mustermann"
                    });

                    context.SaveChanges();
                }
                return context;
            }
        }

根据用例,可以确保每个 CustomWebApplicationFactory 实例都使用单独的数据库,或者简单地只有一个 CustomWebApplicationFactory 实例,在这种情况下,可以通过类嵌套来实现测试的逻辑分离。

历史

  • 2019 年 11 月 3 日:初始版本
© . All rights reserved.