目次
概要
- xUnit等でデータベース(EFCore)を使ったテストケースを作成する際、エンティティやテーブル定義に基づいてテストデータを作成したい場合があります。そのようなユースケースをサポートするためのサンプルを紹介します。
- 使用環境は次の通りです。
OS Windows 10(64ビット) IDE Microsoft Visual Studio Community 2022(17.6.0) 言語 C#(10.0) + .NET6 パッケージ Microsoft.EntityFrameworkCore (7.0.10)
Microsoft.EntityFrameworkCore.Design (7.0.10)
Microsoft.EntityFrameworkCore.SqlServer (7.0.10)データベース SQL Server 2022 Developer (16.0) - 完全なソースコードはgithubで公開しています。
- サンプルコードの全てはEfCoreExampleTests.csに含まれています。想定しているテーブル定義はddl_dml.sqlで定義されています。
- サンプルのコードは全て「null 許容未指定」にしており、これは.csprojファイルのNullableディレクティブで設定しています。お使いの環境でサンプルを使用するとnull許容関連の警告が出力される場合がありますが、適宜変更してください。
- ここで掲載しているサンプルコードは、可読性を高めるために一部コメントを消しています。
基本サンプル
テーブル名、カラム名、型の一覧の取得
- MEmployeeに対応するテーブル名・カラム名、カラム型、NULL許容をダンプするサンプルです。12345678910111213141516171819public void DumpTableColumnsTest(){using var context = new AppDbContext();var type = typeof(MEmployee);var entityType = context.Model.FindEntityType(type);var tableName = entityType.GetTableName();var columns = entityType.GetProperties();foreach (var prop in columns){var colName = prop.GetColumnName();var colType = prop.GetColumnType();var nullable = prop.IsNullable ? "NULL" : "NOT NULL";Console.WriteLine($"{tableName}.{colName}: {colType}, {nullable}");}}
- EFCoreでは、テーブル定義の情報はXXXDBContext.OnModelCreating()で実装します。これらの情報は、XXXDBContext.Modelから取得できます。
- エンティティ・テーブルの情報はIEntityTypeで表現されます。
XXXDBContext.ModelのFindEntityType()やGetEntityTypes()を使って、特定のエンティティや全てのエンティティ・テーブルのIEntityTypeを取得できます。 - プロパティ・カラムの情報はIPropertyで表現されます。
IEntityTypeのGetProperties()やGetProperties(string)を使って、特定プロパティや全てのプロパティのIPropertyを取得できます。 - カラム名の取得方法に関して、EFCore6のGetColumnName()は非推奨(Obsolete)になっていますが、EFCore7のGetColumnName()は非推奨になっていません。
EFCore6でカラム名を取得する場合、GetColumnName(StoreObjectIdentifier.Table(tableName)等のように取得できます。
- 実行結果は次の通りです。12345678910111213m_employee.user_id: int, NOT NULLm_employee.address: nvarchar(2048), NULLm_employee.birthday: datetime2, NOT NULLm_employee.created_by: varchar(8), NOT NULLm_employee.created_on: datetime2, NOT NULLm_employee.employee_no: varchar(10), NOT NULLm_employee.gender: tinyint, NULLm_employee.internal_id: uniqueidentifier, NULLm_employee.name: nvarchar(256), NOT NULLm_employee.retired: bit, NOT NULLm_employee.updated_by: varchar(8), NOT NULLm_employee.updated_on: datetime2, NOT NULLm_employee.version: rowversion, NOT NULL
主キーの取得
- TSalesエンティティ・テーブルの主キープロパティ名・カラム名を出力するサンプルです。12345678910111213141516171819202122public void DumpPrimaryKeysTest(){using var context = new AppDbContext();var entityType = context.Model.FindEntityType(typeof(TSales));// エンティティからの主キー情報の取得var pkProps = entityType.FindPrimaryKey()?.Properties;foreach (var p in pkProps){var colName = p.GetColumnName();var colType = p.GetColumnType();Console.WriteLine($"{p.Name}[{p.ClrType}]: {colName}[{colType}]");}// 各プロパティを主キーか判定var props = entityType.GetProperties();foreach(var p in props){var isPk = p.IsPrimaryKey();Console.WriteLine($"{p.Name}[{p.ClrType}]: {isPk}");}}
- 主キーの情報はIKeyで表現され、IEntityType.FindPrimaryKey()を実行して取得できます。
- エンティティのプロパティ(IProperty)毎に主キーかどうかを判定したい場合、IPropertyのIsPrimaryKey()を使用します。
- レアケースだと思いますが、主キーが設定されていないテーブルの場合、FindPrimaryKey()がnullを返却することに注意が必要です。
- 実行結果は次の通りです。12345678910RegionId[System.Byte]: region_id[tinyint]Year[System.Int16]: year[smallint]Month[System.Byte]: month[tinyint]---RegionId[System.Byte]: TrueYear[System.Int16]: TrueMonth[System.Byte]: TrueCreatedBy[System.String]: FalseCreatedOn[System.DateTime]: False...
外部キーの取得
- MOrderDetailの各カラムの外部キー参照先を出力するサンプルです。12345678910111213141516171819202122232425262728293031public void DumpForeignKeysTest(){using var context = new AppDbContext();var entityType = context.Model.FindEntityType(typeof(MOrderDetail));// エンティティからの外部キー情報の取得var fksFromEntity = entityType.GetForeignKeys();foreach (var fk in fksFromEntity) DumpForeignKey(fk);// プロパティを指定した外部キー情報の取得var prop = entityType.FindProperty(nameof(MOrderDetail.OrderId));var fksFromProp = entityType.FindForeignKeys(prop);foreach (var fk in fksFromProp) DumpForeignKey(fk);// 各プロパティからの外部キー情報の取得var props = entityType.GetProperties();foreach (var p in props){var fks = p.GetContainingForeignKeys();foreach (var fk in fks) DumpForeignKey(fk);}}private void DumpForeignKey(IForeignKey fk){var fromKeys = fk.Properties.Select(e => e.GetColumnName());var toTable = fk.PrincipalEntityType.GetTableName();var toKeys = fk.PrincipalKey.Properties.Select(e => e.GetColumnName());var fromKeysStr = string.Join(", ", fromKeys);var toKeysStr = string.Join(", ", toKeys);Console.WriteLine($"[{fromKeysStr}] -> {toTable}[{toKeysStr}]");}
- 外部キーの情報はIForeignKeyで表現されます。
- エンティティ・テーブルに設定された全ての外部キー情報や特定プロパティの外部キー情報を取得したい場合、IEntityType.GetForeignKeys()を使用します。
- 参照元となるプロパティ・カラム情報の一覧はIForeignKey.Propertiesプロパティから取得できます。参照先となるエンティティは同様にIEntityTypeで表現され、IForeignKey.PrincipalEntityTypeプロパティから取得できます。(参照先となるエンティティ・キーはPrincipalXXXというネーミングのプロパティです。)
- これらの情報の取得方法は、EFCoreソースコードIReadOnlyForeignKey.csのToDebugString()を参考にしています。
- 実行結果は次の通りです。サンプルのMOrderDetailテーブルでは、order_id列からm_orderテーブルのorder_id列に外部キーを設定しています。また、product_type, product_id列からm_productテーブルのtype, id列に外部キーを設定しています。12345678[order_id] -> m_order[order_id][product_type, product_id] -> m_product[type, id]---[order_id] -> m_order[order_id]---[order_id] -> m_order[order_id][product_type, product_id] -> m_product[type, id][product_type, product_id] -> m_product[type, id]12345678910111213141516CREATE TABLE [dbo].[m_order_detail]([order_id] [int] NOT NULL,[product_type] [smallint] NOT NULL,[product_id] [int] NOT NULL,[created_by] [varchar](8) NOT NULL,[created_on] [datetime2] NOT NULL,[updated_by] [varchar](8) NOT NULL,[updated_on] [datetime2] NOT NULL,[version] [timestamp] NOT NULL,CONSTRAINT [pk_m_order_detail] PRIMARY KEY CLUSTERED ([order_id], [product_id]),CONSTRAINT [fk_m_order_detail_order_id] FOREIGN KEY ([order_id])REFERENCES [m_order] ([order_id]),CONSTRAINT [fk_m_order_detail_product_id] FOREIGN KEY ([product_type], [product_id])REFERENCES [m_product] ([type], [id])) ON [PRIMARY]GO
応用サンプル
全テーブルデータの削除
- データベース上の全てのテーブルからデータを削除するサンプルです。
- データベース上の全てのテーブルに対してtruncate文を実行します。外部キーの参照先テーブルはtruncateできないので、deleteします。
- 単体テスト実行時、最新のエンティティに対応するDB環境を準備するために、DbContext.DatabaseのEnsureCreated(), EnsureDeleted()を使って都度DBを再構築する方法が考えられます。都度のデータベースの再作成はコストが高いので、2回目以降はこのようなユーティリティメソッドを使ってデータベースを初期状態に戻す使い方を想定しています。
123456789101112131415161718192021222324public async Task TruncateTablesTest(){using var context = new AppDbContext();var entities = context.Model.GetEntityTypes();// 外部キー参照先テーブル一覧(truncate不可テーブル)var fkToTables = entities.SelectMany(e => e.GetForeignKeys().Select(k => k.PrincipalEntityType.GetTableName())).Where(e => !string.IsNullOrEmpty(e)).Distinct();// 外部キーなしテーブル一覧(truncate可テーブル)var noFkTables = entities.Select(e => e.GetTableName()).Where(e => !string.IsNullOrEmpty(e)).Except(fkToTables);var truncates = noFkTables.Select(e => $"truncate table [{e}]");var deletes = fkToTables.Select(e => $"delete from [{e}]");foreach (var sql in truncates.Concat(deletes)){Console.WriteLine(sql);await context.Database.ExecuteSqlRawAsync(sql);}} - 実行結果の例は次の通りです。123456truncate table [m_employee]truncate table [m_order_detail]truncate table [t_sales]truncate table [z_test_nopk]delete from [m_order]delete from [m_product]
- なお、外部キーの参照先テーブルをtruncateすると次のエラー(MSG: 4712)になります。
SqlException : FOREIGN KEY 制約でテーブル 'テーブル名' が参照されているので、このテーブルは切り捨てられません。(Cannot truncate table 'table-name' because it is being referenced by a FOREIGN KEY constraint.)
既定値ありカラムをnullに更新
- 既定値が設定されたカラムをnullに更新するサンプルです。
- 既定値の設定があるプロパティ・カラムでnull値のテストを行いたい場合に使用する想定です。
- 既定値が設定された項目にnullを設定する場合、データ登録後にUPDATE文でnullに更新する必要があります。そのUPDATE文を自動的に生成するサンプルです。
- “update XXX set c1={0}, c2={0}, … where k1={1} and k2={2}, …”等のように、エンティティに設定されたキー値を条件に、該当項目をnullに更新するクエリとなります。
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253public async Task UpdateNullColumnsTest(){using var context = new AppDbContext();var e = new TSales(){RegionId = 1,Year = 2023,Revenue = null, // default: 0Expense = 500, // default: 0Profit = -500 // default: 0};await context.AddAsync(e);// 既定値指定がありnullが設定されているプロパティをnullにするSQLを生成する。var (nullUpdateSql, nullUpdatePs) = CreateNullUpdateSql(context, e);await context.SaveChangesAsync();var affected = await context.Database.ExecuteSqlRawAsync(nullUpdateSql, nullUpdatePs);Assert.Equal(1, affected);}private (string, object[]) CreateNullUpdateSql(DbContext context, object entity){var modelType = context.Model.FindEntityType(entity.GetType());var modelProps = modelType.GetProperties();var piDic = modelType.ClrType.GetProperties().ToDictionary(k => k.Name, v => v);// null更新対象カラムの特定var nullCols = modelProps.Where(e => !string.IsNullOrEmpty(e.GetDefaultValueSql())) // 既定値がある列.Where(e => piDic[e.Name].GetValue(entity) == null) // 値がnullのプロパティ.Select(e => e.GetColumnName());if (!nullCols.Any()) return (null, null);// キーカラム・値の抽出var keyProps = modelType.FindPrimaryKey()?.Properties;var keyCols = keyProps.Select(p => p.GetColumnName());var keyVals = keyProps.Select(p => piDic[p.Name].GetValue(entity));// SQL文とパラメータの構築var table = modelType.GetTableName();var setCols = nullCols.Select(c => $"{c} = {{0}}");var whrCols = keyCols.Select((k, i) => $"{k}={{{i + 1}}}");var sql =$"update {table} " +$"set {string.Join(", ", setCols)} " +$"where {string.Join(" AND ", whrCols)}";var prms = new object[] { null }.Concat(keyVals).ToArray();Console.WriteLine($"sql=\"{sql}\"");Console.WriteLine($"params={{{string.Join(",", prms)}}}");return (sql, prms);}- CreateNullUpdateSql()では、既定値が設定されており、その値がnullのプロパティ・カラムをnull更新対象としたSET句を生成します。エンティティの主キーとなっているプロパティからwhere句を生成します。
- SaveChangesAsync()を実行すると、既定値がエンティティに反映されてしまうので、どのプロパティをnullにするのか判別が困難になります。そのため、CreateNullUpdateSql()の呼び出しはSaveChangesAsync()前にしています。
- 実行結果の例は次の通りです。1sql="update t_sales set revenue = {0} where region_id={1} AND year={2} AND month={3}"
INSERT可能なエンティティの自動生成
- エンティティ・テーブル定義に基づいてINSERT可能なEntityを自動的に生成するサンプルです。
- NOT NULLカラムが多数あるようなテーブル用のテストデータを作成するのは結構な手間がかかります。業務システムだと数百のカラムがある(設計が良くない)テーブルもあり、手動で値を設定するのは現実的ではありません。
- このような問題を解決するためのユーティリティメソッドです。このメソッドで生成したエンティティを雛形として、テストに必要なカラムに値を設定する使い方を想定しています。
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647public async void CreateInsertableTest(){using var context = new AppDbContext();var e = CreateInsertableEntity<MEmployee>(context);var p = CreateInsertableEntity<MProduct>(context);await context.AddRangeAsync(e, p);await context.SaveChangesAsync();}public T CreateInsertableEntity<T>(DbContext context) where T : class{// 本来はクラス変数等で別定義var excludes = "createdby,createdon,updatedby,updatedon,version".Split(",").ToHashSet(StringComparer.OrdinalIgnoreCase);var type = typeof(T);var target = (T)Activator.CreateInstance(type);var modelType = context.Model.FindEntityType(type);var vgs = new[] { ValueGenerated.OnAdd, ValueGenerated.OnAddOrUpdate };foreach (var mp in modelType.GetProperties()){// null許容、ValueGenerated(Identity, rowversion等の値指定不可列)、// 監査用情報を格納する業務独自列等は対象外var propName = mp.Name;if (mp.IsNullable ||vgs.Contains(mp.ValueGenerated) || excludes.Contains(propName)) continue;// NotNull列は、その型に応じて適当な値を設定var propInfo = type.GetProperty(propName);var propType = propInfo.PropertyType;object value;if (propType == typeof(string)) value = "str";else if (propType == typeof(byte)) value = (byte)1;else if (propType == typeof(short)) value = (short)2;else if (propType == typeof(int)) value = 3;else if (propType == typeof(long)) value = 4L;else if (propType == typeof(float)) value = 5f;else if (propType == typeof(double)) value = 6d;else if (propType == typeof(decimal)) value = 7m;else if (propType == typeof(DateTime)) value = new DateTime(2000, 1, 1);else if (propType == typeof(bool)) value = true;else throw new NotImplementedException($"unknown type: {propType.FullName}");Console.WriteLine($"{propName}({propType.Name}) = {value}");propInfo.SetValue(target, value);}return target;} - 実行結果の例は次の通りです。123456789Birthday(DateTime) = 2000/01/01 0:00:00EmployeeNo(String) = strName(String) = strRetired(Boolean) = TrueType(Int16) = 2Id(Int32) = 3Price(Decimal) = 7ProductCode(String) = strProductName(String) = str
実行対象テーブルの動的な変更
- 動的にDBContextのテーブル(DBSet)を切り替えてレコード件数を表示するサンプルです。123456789101112public async Task DynamicDbSetTest(){using var context = new AppDbContext();await DumpCountAsync<MEmployee>(context);await DumpCountAsync<MOrder>(context);}private async Task DumpCountAsync<T>(AppDbContext context) where T : class{var count = await context.Set<T>().CountAsync();Console.WriteLine($"{typeof(T).Name}: {count}");}