次の方法で共有


運用データベース システムに対するテスト

このページでは、アプリケーションが運用環境で実行されるデータベース システムを含む自動テストを記述する手法について説明します。 代替のテスト方法が存在します。運用データベース システムはテスト ダブルによってスワップアウトされます。詳細については、 テストの概要ページ を参照してください。 運用環境で使用されているもの (Sqlite など) とは異なるデータベースに対するテストについては、ここでは説明しません。これは、異なるデータベースがテスト ダブルとして使用されるためです。この方法については、 実稼働データベース システムを使用しないテストで説明します。

実際のデータベースを含むテストの主な問題は、並列 (またはシリアル) で実行されているテストが相互に干渉しないように、適切なテスト分離を確保することです。 以下の完全なサンプル コード については、こちらをご覧ください

ヒント

このページ xUnit 手法を示しますが、NUnitを含む他のテスト フレームワークにも同様の概念 存在します。

データベース システムのセットアップ

今日のほとんどのデータベース システムは、CI 環境と開発者マシンの両方に簡単にインストールできます。 通常のインストール メカニズムを使用してデータベースをインストールするのは簡単ですが、ほとんどの主要なデータベースではすぐに使用できる Docker イメージを使用でき、CI でのインストールが特に簡単になります。 開発者環境の場合、 GitHub ワークスペースDev Container では、必要なすべてのサービスと依存関係 (データベースを含む) を設定できます。 これにはセットアップへの初期投資が必要ですが、完了すると、作業テスト環境が得られ、より重要なことに集中できます。

場合によっては、データベースに特別なエディションまたはバージョンがあり、テストに役立ちます。 SQL Server を使用する場合、 LocalDB を使用すると、ほとんどセットアップなしでローカルでテストを実行し、必要に応じてデータベース インスタンスをスピンアップし、より強力でない開発者マシンにリソースを節約できます。 ただし、LocalDB には問題はありません。

  • SQL Server Developer Edition が行うすべてをサポートしているわけではありません。
  • Windows でのみ使用できます。
  • サービスがスピンアップされると、最初のテスト実行でラグが発生する可能性があります。

一般に、LocalDB ではなく SQL Server Developer エディションをインストールすることをお勧めします。これは、完全な SQL Server 機能セットを提供し、一般的に非常に簡単に実行できるためです。

クラウド データベースを使用する場合は、通常、速度を向上させ、コストを削減するために、ローカル バージョンのデータベースに対してテストすることが適切です。 たとえば、運用環境で SQL Azure を使用する場合は、ローカルにインストールされた SQL Server に対してテストできます。この 2 つは非常に似ています (ただし、運用環境に入る前に SQL Azure 自体に対してテストを実行することは賢明です)。 Azure Cosmos DB を使用する場合、 Azure Cosmos DB エミュレーター は、ローカルでの開発とテストの実行の両方に役立つツールです。

テスト データベースの作成、シード処理、および管理

データベースがインストールされたら、テストでデータベースの使用を開始する準備が整います。 ほとんどの場合、テスト スイートには、複数のテスト クラス間で複数のテスト間で共有される 1 つのデータベースがあるため、テスト実行の有効期間中にデータベースが 1 回だけ作成およびシード処理されるようにするためのロジックが必要です。

Xunit を使用する場合、これは、データベースを表し、複数のテスト実行間で共有される クラス フィクスチャを介して行うことができます。

public class TestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    private static readonly object _lock = new();
    private static bool _databaseInitialized;

    public TestDatabaseFixture()
    {
        lock (_lock)
        {
            if (!_databaseInitialized)
            {
                using (var context = CreateContext())
                {
                    context.Database.EnsureDeleted();
                    context.Database.EnsureCreated();

                    context.AddRange(
                        new Blog { Name = "Blog1", Url = "http://blog1.com" },
                        new Blog { Name = "Blog2", Url = "http://blog2.com" });
                    context.SaveChanges();
                }

                _databaseInitialized = true;
            }
        }
    }

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);
}

上記のフィクスチャがインスタンス化されると、 EnsureDeleted() を使用してデータベースを削除し (前の実行から存在する場合)、最新のモデル構成で作成 EnsureCreated() します (これらの API のドキュメントを参照)。 データベース作成後、テストで使用できるシード データがフィクスチャによって設定されます。 新しいテストのために後で変更すると既存のテストが失敗する可能性があるため、シード データについて考えるのに時間を費やす価値があります。

テスト クラスでフィクスチャを使用するには、フィクスチャの種類に対して IClassFixture を実装するだけで、xUnit はそれをコンストラクターに挿入します。

public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
    public BloggingControllerTest(TestDatabaseFixture fixture)
        => Fixture = fixture;

    public TestDatabaseFixture Fixture { get; }

これで、テスト クラスに Fixture プロパティが追加されました。このプロパティをテストで使用して、完全に機能するコンテキスト インスタンスを作成できます。

[Fact]
public async Task GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

    var blog = (await controller.GetBlog("Blog2")).Value;

    Assert.Equal("http://blog2.com", blog.Url);
}

最後に、前述のフィクスチャ作成ロジックでロックが発生していることに気付いたかもしれません。 フィクスチャが 1 つのテスト クラスでのみ使用される場合、xUnit によって 1 回だけインスタンス化することが保証されます。ただし、複数のテスト クラスで同じデータベース フィクスチャを使用するのが一般的です。 xUnit は コレクション フィクスチャを提供しますが、そのメカニズムにより、テスト クラスが並列で実行されるのを防ぐことができます。これはテスト パフォーマンスにとって重要です。 これを xUnit クラスフィクスチャで安全に管理するために、データベースの作成とシード処理に関する単純なロックを取り、静的フラグを使用して 2 回行う必要がないことを確認します。

データを変更するテスト

上の例では、読み取り専用テストが示されています。これは、テスト分離の観点から見ると簡単なケースです。何も変更されていないため、テストの干渉は不可能です。 これに対し、データを変更するテストは、相互に干渉する可能性があるため、より問題になります。 テストの作成を分離する一般的な手法の一つは、テストをトランザクションで囲み、そのトランザクションをテスト終了時にロールバックすることです。 実際にはデータベースにコミットされないため、他のテストでは変更が表示されないため、干渉は回避されます。

データベースにブログを追加するコントローラー メソッドを次に示します。

[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    await _context.SaveChangesAsync();

    return Ok();
}

このメソッドは、次の方法でテストできます。

[Fact]
public async Task AddBlog()
{
    using var context = Fixture.CreateContext();
    context.Database.BeginTransaction();

    var controller = new BloggingController(context);
    await controller.AddBlog("Blog3", "http://blog3.com");

    context.ChangeTracker.Clear();

    var blog = await context.Blogs.SingleAsync(b => b.Name == "Blog3");
    Assert.Equal("http://blog3.com", blog.Url);

}

上記のテスト コードに関するいくつかの注意事項:

  • トランザクションを開始し、以下の変更がデータベースにコミットされないことを確認し、他のテストに干渉しないようにします。 トランザクションはコミットされないので、コンテキスト インスタンスが破棄されると、テストの最後に暗黙的にロールバックされます。
  • 必要な更新を行った後、コンテキスト インスタンスの変更トラッカーを ChangeTracker.Clear でクリアし、以下のデータベースからブログを実際に読み込むようにします。 代わりに 2 つのコンテキスト インスタンスを使用することもできますが、両方のインスタンスで同じトランザクションが使用されていることを確認する必要があります。
  • フィクスチャの CreateContextでトランザクションを開始して、既にトランザクションに存在し、更新の準備ができているコンテキスト インスタンスをテストで受け取るようにすることもできます。 これにより、トランザクションが誤って忘れ、デバッグが困難なテスト干渉が発生する場合を防ぐことができます。 また、異なるテスト クラスで読み取り専用テストと書き込みテストを分離することもできます。

トランザクションを明示的に管理するテスト

追加の難しさを示すテストには、データを変更し、トランザクションを明示的に管理するテストという 1 つの最終的なカテゴリがあります。 データベースは通常、入れ子になったトランザクションをサポートしていないため、実際の製品コードで使用する必要があるため、上記のような分離にトランザクションを使用することはできません。 これらのテストはまれになる傾向にありますが、特別な方法で処理する必要があります。各テストの後にデータベースを元の状態にクリーンアップし、これらのテストが相互に干渉しないように並列化を無効にする必要があります。

次のコントローラー メソッドを例として調べてみましょう。

[HttpPost]
public async Task<ActionResult> UpdateBlogUrl(string name, string url)
{
    // Note: it isn't usually necessary to start a transaction for updating. This is done here for illustration purposes only.
    await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);

    var blog = await _context.Blogs.FirstOrDefaultAsync(b => b.Name == name);
    if (blog is null)
    {
        return NotFound();
    }

    blog.Url = url;
    await _context.SaveChangesAsync();

    await transaction.CommitAsync();
    return Ok();
}

何らかの理由で、メソッドでシリアル化可能なトランザクションを使用する必要があるとします (通常はそうではありません)。 その結果、テストの分離を保証するためにトランザクションを使用することはできません。 テストは実際にデータベースに変更をコミットするため、前に示した他のテストに干渉しないように、独自の個別のデータベースを使用して別のフィクスチャを定義します。

public class TransactionalTestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);

    public TransactionalTestDatabaseFixture()
    {
        using var context = CreateContext();
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        Cleanup();
    }

    public void Cleanup()
    {
        using var context = CreateContext();

        context.Blogs.RemoveRange(context.Blogs);

        context.AddRange(
            new Blog { Name = "Blog1", Url = "http://blog1.com" },
            new Blog { Name = "Blog2", Url = "http://blog2.com" });
        context.SaveChanges();
    }
}

このフィクスチャは上記で使用したものと似ていますが、特に Cleanup メソッドが含まれています。これをすべてのテストの後に呼び出して、データベースが開始状態にリセットされることを確認します。

このフィクスチャが単一のテストクラスでのみ使用される場合は、上記のようにクラスフィクスチャとして参照できます。xUnitは同じクラス内のテストを並列化しません( xUnitドキュメントのテストコレクションと並列化の詳細を参照してください)。 ただし、複数のクラス間でこのフィクスチャを共有する場合は、干渉を回避するために、これらのクラスが並列で実行されないようにする必要があります。 これを行うには、クラスフィクスチャとしてではなく、xUnitコレクションフィクスチャとしてこれを使用します。

まず、テストコレクションを定義します。これはフィクスチャを参照し、それを必要とするすべてのトランザクション テスト クラスで使用されます。

[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}

次に、テスト クラスのテスト コレクションを参照し、先ほどと同様にコンストラクターにフィクスチャを取り込みます。

[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
    public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
        => Fixture = fixture;

    public TransactionalTestDatabaseFixture Fixture { get; }

最後に、各テストの後に呼び出されるフィクスチャの Cleanup メソッドを配置して、テスト クラスを破棄可能にします。

public void Dispose()
    => Fixture.Cleanup();

xUnit はコレクション フィクスチャを 1 回だけインスタンス化するため、上記のようにデータベースの作成とシード処理に関するロックを使用する必要はありません。

上記の完全なサンプル コード は、ここで確認できます。

ヒント

データベースを変更するテストを含む複数のテスト クラスがある場合でも、それぞれ独自のデータベースを参照する異なるフィクスチャを使用して並列で実行できます。 多くのテスト データベースの作成と使用は問題ではなく、役に立つたびに実行する必要があります。

効率的なデータベース作成

上記のサンプルでは、テストを実行する前に EnsureDeleted()EnsureCreated() を使用して、up-to-date テスト データベースがあることを確認しました。 これらの操作は、特定のデータベースでは少し遅くなる可能性があります。これは、コードの変更を繰り返し繰り返しテストを繰り返し実行するときに問題になる可能性があります。 その場合は、フィクスチャのコンストラクターで EnsureDeleted を一時的にコメントアウトすることができます。これにより、テストの実行間で同じデータベースが再利用されます。

この方法の欠点は、EF Core モデルを変更すると、データベース スキーマが最新でなくなり、テストが失敗する可能性があることです。 その結果、開発サイクル中に一時的に行うことをお勧めします。

効率的なデータベース のクリーンアップ

上記では、変更が実際にデータベースにコミットされるときに、干渉を避けるためにすべてのテストの間でデータベースをクリーンアップする必要があることを確認しました。 上記のトランザクション テスト サンプルでは、EF Core API を使用してテーブルの内容を削除することでこれを行いました。

using var context = CreateContext();

context.Blogs.RemoveRange(context.Blogs);

context.AddRange(
    new Blog { Name = "Blog1", Url = "http://blog1.com" },
    new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();

これは通常、テーブルをクリアする最も効率的な方法ではありません。 テスト速度に問題がある場合は、生の SQL を使用してテーブルを削除することをお勧めします。

DELETE FROM [Blogs];

また、データベースを効率的にクリアする respawn パッケージの使用を検討することもできます。 さらに、クリアするテーブルを指定する必要がないため、テーブルがモデルに追加される際にクリーンアップ コードを更新する必要はありません。

概要

  • 実際のデータベースに対してテストする場合は、次のテスト カテゴリを区別する必要があります。
    • 読み取り専用テストは比較的単純であり、分離を気にすることなく、同じデータベースに対して常に並列で実行できます。
    • 書き込みテストの方が問題になりますが、トランザクションを使用して、適切に分離されていることを確認できます。
    • トランザクション テストは最も問題が多く、データベースを元の状態にリセットするロジックと並列化を無効にするロジックが必要です。
  • これらのテスト カテゴリを個別のクラスに分離すると、テスト間の混乱や偶発的な干渉を回避できます。
  • シード処理されたテスト データを事前に考え、そのシード データが変更されてもあまり頻繁に中断されない方法でテストを記述してみてください。
  • 複数のデータベースを使用して、データベースを変更するテストを並列化し、場合によっては異なるシード データ構成を許可します。
  • テストの速度が問題である場合は、テスト データベースを作成し、実行の間にデータをクリーニングするためのより効率的な手法を調べたい場合があります。
  • 常にテストの並列化と分離に留意してください。