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

このページでは、運用環境で実行するアプリケーション用のデータベース システムを使用する自動テストを作成する手法について説明します。 運用データベース システムをテスト ダブルに置き換える別のテスト アプローチが存在します。詳細については、テストの概要ページを参照してください。 運用環境で使用されるデータベース (Sqlite など) とは異なるデータベースに対するテストについては、ここでは説明しません。これは、異なるデータベースがテスト ダブルとして使用されるためです。このアプローチについては、「運用データベース システムを使用しないテスト」で説明しています。

実際のデータベースを使用するテストに伴う主な困難は、並列で (またはシリアルであっても) 実行されるテストが相互に干渉しないように、適切なテストの分離を確実に行うことです。 以下のサンプル コードの全体はここで確認できます。

ヒント

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

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

現在のほとんどのデータベース システムは、CI 環境と開発者マシンの両方に簡単にインストールできます。 多くの場合、通常のインストール メカニズムを使用してデータベースをインストールするのは簡単ですが、ほとんどの主要なデータベースにはすぐに使用できる Docker イメージが用意されており、CI でのインストールが特に簡単になります。 開発者環境である GitHub Workspaces 用に、データベースを含む必要なすべてのサービスと依存関係を Dev Container で設定できます。 これには最初にセットアップの手間が必要ですが、一度行えばテスト作業環境ができ上がり、より重要なことに集中できます。

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

  • これでは、SQL Server Developer エディションがサポートするものをすべてサポートしていません。
  • 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";

    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 void GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

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

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

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

データを変更するテスト

上記の例では、読み取り専用テストが示されています。これは、テストの分離の観点から見ると簡単なケースです。何も変更されないため、テストの干渉が起こり得ないからです。 これに対し、データを変更するテストは相互に干渉する可能性があるため、より問題が生じやすくなります。 書き込みテストを分離する一般的な手法の 1 つは、テストをトランザクションでラップし、テストの最後にそのトランザクションをロールバックすることです。 実際にはデータベースには何もコミットされないため、他のテストでは変更は認識されず、干渉は回避されます。

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

[HttpPost]
public ActionResult AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    _context.SaveChanges();

    return Ok();
}

このメソッドは、次のようにしてテストできます。

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

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

    context.ChangeTracker.Clear();

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

}

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

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

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

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

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

[HttpPost]
public 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.
    using var transaction = _context.Database.BeginTransaction(IsolationLevel.Serializable);

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

    blog.Url = url;
    _context.SaveChanges();

    transaction.Commit();
    return Ok();
}

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

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

    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 メソッドが含まれていることに注目してください。すべてのテストの後にこれを呼び出して、データベースが開始時の状態にリセットされるようにします。

このフィクスチャが 1 つのテスト クラスでのみ使用される場合は、上記のようにクラス フィクスチャとして参照できます。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() を使いました。 これらの操作は、特定のデータベースでは少し遅くなる可能性があります。これは、コードの変更を繰り返したり、テストを繰り返し実行したりするときに問題になる場合があります。 その場合は、フィクスチャのコンストラクターで 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 パッケージの使用を検討することもできます。 さらに、クリアする対象のテーブルを指定する必要がないため、モデルにテーブルを追加するときにクリーンアップ コードを更新する必要はありません。

まとめ

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