Entity Framework CoreのConcurrencyCheckを単体テストする


EntityFrameworkでConcurrencyCheckを使用した楽観的排他制御とそのリカバリロジックを書いた場合に単体テストを行う方法が分からなかったので考えてみた。

例えばこんなEntityとContextを作成した場合


    /// <summary>
    /// 在庫データ
    /// </summary>
    public class ProductStock
    {
        public long Id { get; set; }
        /// <summary>
        /// 仕入れ数
        /// </summary>
        [ConcurrencyCheck]
        public int PurchaseQuantity { get; set; }        
        /// <summary>
        /// 販売済み数
        /// </summary>
        [ConcurrencyCheck]
        public int SoldQuantity { get; set; }
        /// <summary>
        /// 残販売可能数
        /// </summary>
        [ConcurrencyCheck]
        public int RemainingQuantity { get; set; }
    }

public class StockContext : DbContext
    {
        public StockContext(DbContextOptions<StockContext> options) : base(options)
        {

        }

        public DbSet<ProductStock> ProductStocks { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<ProductStock>().HasData(new []
            {
                new ProductStock{ Id = 1, PurchaseQuantity = 5, SoldQuantity = 0, RemainingQuantity = 5}
            });
        }
    }

在庫を更新するロジックを書いてみる

    public static class Logic
    {
        public static async Task<bool> StockUpdateLogic(long id, StockContext ctx, int saleQuantity)

        {
            var stock = await ctx.ProductStocks.SingleOrDefaultAsync(x => x.Id == id);

            if (stock == null)
                return false;

            if (stock.RemainingQuantity < saleQuantity)
                return false;

            stock.SoldQuantity += saleQuantity;
            stock.RemainingQuantity -= saleQuantity;
            ctx.Update(stock);

            while (true)
            {
                try
                {                 
                    await ctx.SaveChangesAsync();
                    break;
                }
                catch (DbUpdateConcurrencyException e)
                {
                    var entityEntry = e.Entries.Single(x => x.Entity is ProductStock);
                    var proposedValues = entityEntry.CurrentValues;
                    var databaseValues = await entityEntry.GetDatabaseValuesAsync();

                    var dbSoldQuantity = databaseValues.GetValue<int>(nameof(ProductStock.SoldQuantity));
                    var dbRemainingQuantity = databaseValues.GetValue<int>(nameof(ProductStock.RemainingQuantity));

                    if (dbRemainingQuantity < saleQuantity)
                        return false;

                    var newSoldQuantity = dbSoldQuantity + saleQuantity;
                    var newRemainingQuantity = dbRemainingQuantity - saleQuantity;
                    proposedValues[nameof(ProductStock.SoldQuantity)] = newSoldQuantity;
                    proposedValues[nameof(ProductStock.RemainingQuantity)] = newRemainingQuantity;

                    entityEntry.OriginalValues.SetValues(databaseValues);
                }
            }

            return true;
        }
    }

ここでDbUpdateConcurrencyExceptionをキャッチした時の中を単体テストでチェックしたいなぁって思った時に、MoqのCallBackCallBaseを利用してテストを書くことができる。

単体テストはこんな感じ


    public class UnitTest1
    {
        private DbContextOptions<StockContext> GetOptions()
        {
            // In-memory database only exists while the connection is open
            var connection = new SqliteConnection("DataSource=:memory:");
            connection.Open();
            var options = new DbContextOptionsBuilder<StockContext>()
                .UseSqlite(connection)
                .Options;
            return options;            
        }

        [Theory]
        [InlineData(3, true)]
        [InlineData(4, true)]
        [InlineData(5, false)]
        [InlineData(6, false)]
        public async Task ConcurrencyCheckTest(int saleCount, bool expectedResult)
        {
            var options = GetOptions();
            var conflictCtx = new StockContext(options);
            conflictCtx.Database.EnsureCreated();
            var moq = new Mock<StockContext>(options){ CallBase = true};
            bool conflictedPatch = false;
            moq.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
                .Callback(() =>
                {                    
                    if (!conflictedPatch)
                    {
                        var stock = conflictCtx.ProductStocks.Single();
                        stock.SoldQuantity += 1;
                        stock.RemainingQuantity -= 1;
                        conflictCtx.Update(stock);
                        conflictCtx.SaveChanges();
                        conflictedPatch = true;
                    }
                })
                .CallBase();

            var ctx = moq.Object;

            var result = await Logic.StockUpdateLogic(1, ctx, saleCount);

            Assert.Equal(expectedResult, result);
        }
    }

単体テストではSqliteのオンメモリーDBを使用した。

MoqのCallBaseをtrueにすることでラッパー的な動作が可能になる。
SetupのチェインにてCallBaseを呼ぶことでベースクラスのSetupで指定した関数を呼ぶことが出来る。
Callbackを使用することで関数の実行前や実行後に処理を挟むことができる。

これら2つを組み合わせてSaveChangesAsync()が呼ばれる前に在庫データを別Contextにて更新してしまえば、DbUpdateConcurrencyExceptionを発生させることが出来る。

もちろんmoqにてSaveChangesAsyncを乗っ取って、全ての模擬動作を書くこともできるけど、ぶっちゃけめんどい。