Написание интеграционных тестов для EF Core и базы данных SQL Server

Рабочий пример в конце этой статьи (я сохранил все, что испробовал [причина длинной статьи], чтобы другие могли извлечь из этого пользу позже)

Я пытаюсь написать интеграционные тесты для моей библиотеки классов EF Core 3.1. В качестве среды модульного тестирования я использовал XUnit и следовал руководству от Microsoft: https://learn.microsoft.com/en-us/ef/core/testing/sharing-databases

Вот как выглядит установка (это немного длиннее, потому что я фактически создаю базу данных в своем SQL Server на случай, если мне нужно увидеть реальный результат из вывода тестов):

 public class SharedDatabaseFixture : IDisposable
 {
        private static readonly object _lock = new object();
        private static bool _databaseInitialized;
        private static string _DatabaseName = "Database.Server.Local";
        private static IConfigurationRoot config;

        public SharedDatabaseFixture()
        {
            config = new ConfigurationBuilder()
               .AddJsonFile($"appsettings.Development.json", true, true)
               .Build();

            var test = config.GetValue<string>("DataSource");

            var connectionStringBuilder = new SqlConnectionStringBuilder
            {
                DataSource = config.GetValue<string>("DataSource"),
                InitialCatalog = _DatabaseName,
                IntegratedSecurity = true,
            };

            var connectionString = connectionStringBuilder.ToString();
            Connection = new SqlConnection(connectionString);

            CreateEmptyDatabaseAndSeedData();
            Connection.Open();
        }

        public bool ShouldSeedActualData { get; set; } = true;
        public DbConnection Connection { get; set; }

        public ApplicationDbContext CreateContext(DbTransaction transaction = null)
        {
            var identity = new GenericIdentity("[email protected]", "Admin");
            var contextUser = new ClaimsPrincipal(identity); //add claims as needed
            var httpContext = new DefaultHttpContext() { User = contextUser };
            var defaultHttpContextAccessor = new HttpContextAccessor();
            defaultHttpContextAccessor.HttpContext = httpContext;

            var context = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlServer(Connection).Options, null, defaultHttpContextAccessor);

            if (transaction != null)
            {
                context.Database.UseTransaction(transaction);
            }
           
            return context;
        }

        private static void ExecuteSqlCommand(SqlConnectionStringBuilder connectionStringBuilder, string commandText)
        {
            using (var connection = new SqlConnection(connectionStringBuilder.ConnectionString))
            {
                connection.Open();

                using (var command = connection.CreateCommand())
                {
                    command.CommandText = commandText;
                    command.ExecuteNonQuery();
                }
            }
        }

        private static SqlConnectionStringBuilder Master => new SqlConnectionStringBuilder
        {
            DataSource = config.GetValue<string>("DataSource"),
            InitialCatalog = "master",
            IntegratedSecurity = true
        };

        private static string Filename => Path.Combine(Path.GetDirectoryName(typeof(SharedDatabaseFixture).GetTypeInfo().Assembly.Location), $"{_DatabaseName}.mdf");
        private static string LogFilename => Path.Combine(Path.GetDirectoryName(typeof(SharedDatabaseFixture).GetTypeInfo().Assembly.Location), $"{_DatabaseName}_log.ldf");

        private static void CreateDatabaseRawSQL()
        {
            ExecuteSqlCommand(Master, $@"IF(db_id(N'{_DatabaseName}') IS NULL) BEGIN CREATE DATABASE [{_DatabaseName}] ON (NAME = '{_DatabaseName}', FILENAME = '{Filename}') END");
        }

        private static List<T> ExecuteSqlQuery<T>(SqlConnectionStringBuilder connectionStringBuilder, string queryText, Func<SqlDataReader, T> read)
        {
            var result = new List<T>();

            using (var connection = new SqlConnection(connectionStringBuilder.ConnectionString))
            {
                connection.Open();

                using (var command = connection.CreateCommand())
                {
                    command.CommandText = queryText;

                    using (var reader = command.ExecuteReader())
                    {
                        while (reader.Read())
                        {
                            result.Add(read(reader));
                        }
                    }
                }
            }

            return result;
        }

        private static void DestroyDatabaseRawSQL()
        {
            var fileNames = ExecuteSqlQuery(Master, $@"SELECT [physical_name] FROM [sys].[master_files] WHERE [database_id] = DB_ID('{_DatabaseName}')", row => (string)row["physical_name"]);

            if (fileNames.Any())
            {
                ExecuteSqlCommand(Master, $@"ALTER DATABASE [{_DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;EXEC sp_detach_db '{_DatabaseName}', 'true'");
                fileNames.ForEach(File.Delete);
            }

            if (File.Exists(Filename))
                File.Delete(Filename);

            if (File.Exists(LogFilename))
                File.Delete(LogFilename);
        }

        private void CreateEmptyDatabaseAndSeedData()
        {
            lock (_lock)
            {
                if (!_databaseInitialized)
                {
                    using (var context = CreateContext())
                    {
                        try
                        {
                            DestroyDatabaseRawSQL();
                        }
                        catch (Exception) { }

                        try
                        {
                            CreateDatabaseRawSQL();
                            context.Database.EnsureCreated();
                        }
                        catch (Exception) { }

                        if (ShouldSeedActualData)
                        {
                            List<UserDB> entities = new List<UserDB>()
                            {
                                new UserDB() { Id = "[email protected]", Name= "Admin" }
                            };

                            context.Users.AddRange(entities);
                            context.SaveChanges();

                            List<IdentityRole> roles = new List<IdentityRole>()
                            {
                                new IdentityRole(){Id = "ADMIN",Name = nameof(DefaultRoles.Admin), NormalizedName = nameof(DefaultRoles.Admin)},
                                new IdentityRole(){Id = "FINANCE",Name = nameof(DefaultRoles.Finance), NormalizedName = nameof(DefaultRoles.Finance)}
                            };

                            context.Roles.AddRange(roles);
                            context.SaveChanges();

                        }
                    }

                    _databaseInitialized = true;
                }
            }
        }

        public void Dispose()
        {
            Connection.Dispose();
        }
}

Тогда тестовый класс выглядит следующим образом (для простоты показаны только 2 теста):

public class BaseRepositoryTests : IClassFixture<SharedDatabaseFixture>
{
        private readonly SharedDatabaseFixture fixture;
        private IMapper _mapper;

        public BaseRepositoryTests(SharedDatabaseFixture fixture)
        {
            this.fixture = fixture;

            var config = new MapperConfiguration(opts =>
            {
                opts.AddProfile<CountriesDBMapper>();
                opts.AddProfile<EmployeeDBMapper>();
                opts.AddProfile<EmployeeAccountDBMapper>();
            });

            _mapper = config.CreateMapper();
        }

        [Fact]
        public async Task EntityCannotBeSavedIfDbEntityIsNotValid()
        {
            using (var transaction = fixture.Connection.BeginTransaction())
            {
                using (var context = fixture.CreateContext(transaction))
                {
                    var baseCountryRepository = new BaseRepository<CountryDB, Country>(context, _mapper);
                    var invalidCountry = new Country() { };

                    //Act
                    var exception = await Assert.ThrowsAsync<DbUpdateException>(async () => await baseCountryRepository.CreateAsync(invalidCountry));
                    Assert.NotNull(exception.InnerException);
                    Assert.Contains("Cannot insert the value NULL into column", exception.InnerException.Message);
                }
            }
        }

        [Fact]
        public async Task EntityCanBeSavedIfEntityIsValid()
        {
            using (var transaction = fixture.Connection.BeginTransaction())
            {
                using (var context = fixture.CreateContext(transaction))
                {
                    var baseCountryRepository = new BaseRepository<CountryDB, Country>(context, _mapper);
                    var item = new Country() { Code = "SK", Name = "Slovakia" };

                    //Act
                    var result = await baseCountryRepository.CreateAsync(item);
                    Assert.NotNull(result);
                    Assert.Equal(1, result.Id);
                }
            }
        }
}

Наконец, вот пример реализации репозитория (CRUD):

  public async Task<TModel> CreateAsync(TModel data)
    {
        var newItem = mapper.Map<Tdb>(data);

        var entity = await context.Set<Tdb>().AddAsync(newItem);
        await context.SaveChangesAsync();

        return mapper.Map<TModel>(entity.Entity);
    }

    public async Task<bool> DeleteAsync(long id)
    {
        var item = await context.Set<Tdb>().FindAsync(id).ConfigureAwait(false);
        if (item == null)
            throw new ArgumentNullException();

        var result = context.Set<Tdb>().Remove(item);
        await context.SaveChangesAsync(); 

        return (result.State == EntityState.Deleted || result.State == EntityState.Detached);
    }

Если я запускаю эти тесты по отдельности, каждый из них проходит без проблем. Однако, если я запускаю все тесты из BaseRepositoryTests, у меня возникают случайные проблемы, поскольку каким-то образом транзакции базы данных не откатываются, а данные сохраняются и распределяются между тестами.

Я проверил, и действительно, каждая транзакция имеет свой уникальный идентификатор, поэтому они не должны конфликтовать. Я что-то пропустил здесь? Я имею в виду, что, по мнению Microsoft, это правильный подход, но я явно что-то упустил. Единственное отличие от других руководств, которые я смог найти, это то, что я использую SaveChangesAsync в своей реализации репозитория, в то время как другие используют SaveChanges... однако я считаю, что это не должно быть основной причиной моей проблемы.

Любая помощь в отношении этого вопроса будет высоко оценена.

Обновление 1:

Как было предложено в комментариях, я пробовал два отдельных подхода. Первый заключался в использовании CommitableTransaction следующим образом:

Обновление метода:

[Fact]
public async Task EntityCanBeSavedIfEntityIsValid()
{
    using (var transaction = new CommittableTransaction(new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted }))
    {
        using (var context = fixture.CreateContext(transaction))
        {
            var baseCountryRepository = new BaseRepository<CountryDB, Country>(context, _mapper);
            var item = new Country() { Code = "SK", Name = "Slovakia" };

            //Act
            var result = await baseCountryRepository.CreateAsync(item);
            Assert.NotNull(result);
            Assert.Equal(1, result.Id);
        }
    }
}

Общее обновление прибора:

public ApplicationDbContext CreateContext(CommittableTransaction transaction = null)
{
    ... other code

    if (transaction != null)
    {
        context.Database.EnlistTransaction(transaction);
    }
   
    return context;
}

К сожалению, это закончилось тем же результатом при массовом запуске моих тестов кода (данные, которые я сохранял, в конечном итоге увеличивались, а не отбрасывались после каждого теста).

Второе, что я пробовал, это использовать TransactionScope следующим образом:

[Fact]
public async Task EntityCanBeModifiedIfEntityExistsAndIsValid()
{
    using (var scope = new TransactionScope(TransactionScopeOption.Required, new TransactionOptions { IsolationLevel = IsolationLevel.ReadUncommitted }, TransactionScopeAsyncFlowOption.Enabled))
    {
        using (var context = fixture.CreateContext())
        {
            var baseCountryRepository = new BaseRepository<CountryDB, Country>(context, _mapper);
            var item = new Country() { Code = "SK", Name = "Slovakia" };

            //Act
            var insertResult = await baseCountryRepository.CreateAsync(item);
            Assert.NotNull(insertResult);
            Assert.Equal(1, insertResult.Id);
            Assert.Equal("SK", insertResult.Code);
            Assert.Equal("Slovakia", insertResult.Name);

            //Act
            insertResult.Code = "SVK";

            var result = await baseCountryRepository.UpdateAsync(insertResult.Id, insertResult);
            Assert.Equal(1, result.Id);
            Assert.Equal("SVK", result.Code);
            Assert.Equal("Slovakia", result.Name);
        }

        scope.Complete();
    }
}

Как и прежде, это не дало никаких новых результатов.

Последнее, что я пробовал, это удалить :IClassFixture<SharedDatabaseFixture> из тестового класса и вместо этого создать новый экземпляр моей базы данных в конструкторе (который запускается для каждого запуска теста), как показано ниже:

public BaseRepositoryTests()
{
    this.fixture = new SharedDatabaseFixture();
    var config = new MapperConfiguration(opts =>
    {
        opts.AddProfile<CountriesDBMapper>();
        opts.AddProfile<EmployeeDBMapper>();
        opts.AddProfile<EmployeeAccountDBMapper>();
    });

    _mapper = config.CreateMapper();
}

Как и прежде, никаких новых результатов от этого обновления не последовало.

Рабочая установка

Общая база данных (в основном класс, отвечающий за создание базы данных... основное отличие от предыдущей версии теперь заключается в том, что в конструкторе он принимает уникальный идентификатор, который используется при создании базы данных -> для создания базы данных с уникальным именем. Кроме того, я также добавил новый метод ForceDestroyDatabase(), который отвечает за уничтожение базы данных после того, как тест сделал свою работу.Я не помещал его в метод Dispose(), так как иногда вы хотите проверить, что на самом деле произошло с базой данных, где в этом случае вы просто не вызываете метод... см. далее)

public class SharedDatabaseFixture : IDisposable
    {
        private static readonly object _lock = new object();
        private static bool _databaseInitialized;
        private string _DatabaseName = "FercamPortal.Server.Local.";
        private static IConfigurationRoot config;
        public SharedDatabaseFixture(string guid)
        {
            config = new ConfigurationBuilder()
               .AddJsonFile($"appsettings.Development.json", true, true)
               .Build();

            var test = config.GetValue<string>("DataSource");

            this._DatabaseName += guid;

            var connectionStringBuilder = new SqlConnectionStringBuilder
            {
                DataSource = config.GetValue<string>("DataSource"),
                InitialCatalog = _DatabaseName,
                IntegratedSecurity = true,
            };
            var connectionString = connectionStringBuilder.ToString();
            Connection = new SqlConnection(connectionString);

            CreateEmptyDatabaseAndSeedData();
            Connection.Open();
        }
         ...other code the same as above, skipped for clarity

private void CreateEmptyDatabaseAndSeedData()
            {
                lock (_lock)
                {
                    using (var context = CreateContext())
                    {
                        try
                        {
                            DestroyDatabaseRawSQL();
                        }
                        catch (Exception ex) { }
    
                        try
                        {
                            CreateDatabaseRawSQL();
                            context.Database.EnsureCreated();
                        }
    
                        catch (Exception) { }
    
                        if (ShouldSeedActualData)
                        {
                            List<UserDB> entities = new List<UserDB>()
                                {
                                    new UserDB() { Id = "[email protected]", Name= "Robert Moq" },
                                    new UserDB() { Id = "[email protected]", Name= "Test User" }
                                };
    
                            context.Users.AddRange(entities);
                            context.SaveChanges();
    
                            List<IdentityRole> roles = new List<IdentityRole>()
                                {
                                    new IdentityRole(){Id = "ADMIN",Name = nameof(FercamDefaultRoles.Admin), NormalizedName = nameof(FercamDefaultRoles.Admin)},
                                    new IdentityRole(){Id = "FINANCE",Name = nameof(FercamDefaultRoles.Finance), NormalizedName = nameof(FercamDefaultRoles.Finance)}
                                };
    
                            context.Roles.AddRange(roles);
                            context.SaveChanges();
    
                        }
                    }
    
                }
            }

        
        public void ForceDestroyDatabase()
        {
            DestroyDatabaseRawSQL();
        }

        public void Dispose()
        {
            Connection.Close();
            Connection.Dispose();
        }
    }

Пример тестового класса:

public class DailyTransitDBRepositoryTests : IDisposable
    {
        private readonly SharedDatabaseFixture fixture;
        private readonly ApplicationDbContext context;

        private IMapper _mapper;

        public DailyTransitDBRepositoryTests()
        {
            this.fixture = new SharedDatabaseFixture(Guid.NewGuid().ToString("N"));
            this.context = this.fixture.CreateContext();
            this.context.Database.OpenConnection();

            var config = new MapperConfiguration(opts =>
            {
                opts.AddProfile<DailyTransitDBMapper>();
                opts.AddProfile<EmployeeDBMapper>();
                opts.AddProfile<EmployeeAccountDBMapper>();
                opts.AddProfile<CountriesDBMapper>();
            });

            _mapper = config.CreateMapper();
        }


        ...other code ommited for clarity

        public void Dispose()
        {
            this.context.Database.CloseConnection();
            this.context.Dispose();

            this.fixture.ForceDestroyDatabase();
            this.fixture.Dispose();
        }

        [Fact]
        public async Task GetTransitsForYearAndMonthOnlyReturnsValidItems()
        {
            var employees = await PopulateEmployeesAndReturnThemAsList(context);
            var countries = await PopulateCountriesAndReturnThemAsList(context);

            var transitRepository = new DailyTransitDBRepository(context, _mapper);

            var transitItems = new List<DailyTransit>() {
                    new DailyTransit()
                    {
                        Country = countries.First(),
                        Employee = employees.First(),
                        Date = DateTime.Now,
                        TransitionDurationType = DailyTransitDurationEnum.FullDay
                    },
                    new DailyTransit()
                    {
                        Country = countries.First(),
                        Employee = employees.Last(),
                        Date = DateTime.Now.AddDays(1),
                        TransitionDurationType = DailyTransitDurationEnum.FullDay
                    },
                    new DailyTransit()
                    {
                        Country = countries.First(),
                        Employee = employees.Last(),
                        Date = DateTime.Now.AddMonths(1),
                        TransitionDurationType = DailyTransitDurationEnum.FullDay
                    }
                    };

            //Act
            await transitRepository.CreateRangeAsync(transitItems);

            //retrieve all items
            using (var context2 = fixture.CreateContext())
            {
                var transitRepository2 = new DailyTransitDBRepository(context2, _mapper);
                var items = await transitRepository2.GetEmployeeTransitsForYearAndMonth(DateTime.Now.Year, DateTime.Now.Month);

                Assert.Equal(2, items.Count());
                Assert.Equal("Janko", items.First().Employee.Name);
                Assert.Equal("John", items.Last().Employee.Name);
            }
        }
       
    }

В качестве пояснения: по определению, если вы тестируете что-то за пределами вашего непосредственного класса, это интеграционный тест, а не модульный тест, особенно в тех случаях, когда вы имеете дело с внешними зависимостями.

David L 18.12.2020 17:59

Спасибо, я обновил заголовок, чтобы назвать его интеграционным тестом.

Robert J. 18.12.2020 18:03

Я немного ошеломлен, увидев, что ссылка, на которую вы ссылаетесь, предлагает начать транзакцию в каждом отдельном тесте. Вы должны сделать это в общем методе настройки теста (я уверен, что XUnit будет иметь такую ​​​​концепцию) и откатить его в общем тесте. Но тогда вы должны использовать TransactionScope с асинхронным потоком, потому что соединение не будет доступно в этих методах.

Gert Arnold 20.12.2020 20:06

Кроме того, смотрите здесь некоторые старые бредни в этой области.

Gert Arnold 20.12.2020 20:15

Я переработал код, чтобы использовать Transaction Scope, а также CommitableTransaction (см. правки кода), но, к сожалению, ни один из методов не сработал. Опять же, по отдельности эти методы работали отлично, но когда я запускал все тесты из класса сразу, это не удавалось, поскольку они каким-то образом использовали один и тот же контекст.

Robert J. 21.12.2020 10:37

Вы должны откатить TransactionScope, что происходит, если не вызывать Complete(). Кроме того, не используйте один и тот же объект репозитория для фазы действия и утверждения.

Gert Arnold 21.12.2020 16:07

Я пробовал как с Complete(), так и без Complete(), но результат был таким же. Спасибо, что заметили это, я также исправил свои ссылки в коде.

Robert J. 21.12.2020 16:13

О, мальчик, я страдал от этой ***** проблемы. Просто для простоты, пожалуйста, подумайте о том, чтобы не делиться контекстом базы данных с помощью IClassFixture, он еще недостаточно зрел, или я слишком глуп, чтобы сделать это правильно. Вместо этого вы можете попробовать этот простой подход => stackoverflow.com/a/64845859/9667085

Rod Ramírez 21.12.2020 23:19

@RodRamírez Ну, контекст для каждого тестового класса - это не то, что вам нужно. Контекст для каждого теста должен быть в порядке.

Gert Arnold 22.12.2020 09:46

@RodRamírez, пожалуйста, сделайте это ответом, поскольку ваша ссылка дала мне идею реорганизовать мой код так, чтобы он работал.

Robert J. 22.12.2020 11:40

Я обновил свой исходный пост, включив в него ответ в самом конце.

Robert J. 22.12.2020 15:48
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
4
11
1 553
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Роберт, рад, что это помогло! По вашему запросу Я повторно отправляю ответ для всех, кто может найти этот ответ полезным, как и вы.

Я узнал на собственном опыте, что попытка поделиться контекстом базы данных фреймворка сущностей через IClassFixture или CollectionFixtures в конечном итоге приведет к тому, что тесты будут загрязнены другими тестовыми данными или условиями взаимоблокировки/гонки из-за параллельного выполнения xUnit, фреймворк сущностей выбрасывает исключения, потому что он уже отслеживал этот объект с заданным идентификатором и другими подобными головными болями. Лично я настоятельно рекомендую для вашей конкретной цели использовать создание/очистку контекста базы данных в альтернативе constructor/dispose, например:

    public class TestClass : IDisposable
    {
        DatabaseContext DatabaseContext;

        public TestClass()
        {
            var options = new DbContextOptionsBuilder<DatabaseContext>()
              .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
              .Options;

            DatabaseContext = new DatabaseContext(options);

            //insert the data that you want to be seeded for each test method:
            DatabaseContext.Set<Product>().Add(new Product() { Id = 1, Name = Guid.NewGuid().ToString() });
            DatabaseContext.SaveChanges();
        }

        [Fact]
        public void FirstTest()
        {
            var product = DatabaseContext.Set<Product>().FirstOrDefault(x => x.Id == 1).Name;
            //product evaluates to => 0f25a10b-1dfd-4b4b-a69d-4ec587fb465b
        }

        [Fact]
        public void SecondTest()
        {
            var product = DatabaseContext.Set<Product>().FirstOrDefault(x => x.Id == 1).Name;
            //product evaluates to => eb43d382-40a5-45d2-8da9-236d49b68c7a
            //It's different from firstTest because is another object
        }

        public void Dispose()
        {
            DatabaseContext.Dispose();
        }
    }

Конечно, всегда можно доработать, но идея есть.

Другие вопросы по теме