ASP.NET Core での統合テスト

作成者: Jos van der TilMartin CostelloJavier Calvarro Nelson

統合テストでは、データベース、ファイル システム、ネットワークなど、アプリのサポート インフラストラクチャを含むレベルで、アプリのコンポーネントが正しく機能していることを確認します。 ASP.NET Core では、単体テスト フレームワークとテスト Web ホストおよびメモリ内テスト サーバーを使用した統合テストがサポートされています。

この記事を読むには、単体テストの基本的な知識があることが前提となります。 テストの概念に慣れていない場合は、.NET Core と .NET Standard の単体テストに関する記事とそのリンクされたコンテンツを参照してください。

サンプル コードを表示またはダウンロードします (ダウンロード方法)。

このサンプル アプリは Razor Pages アプリであり、Razor Pages の基本を理解していることを前提としています。 Razor Pages に慣れていない場合は、次の記事を参照してください。

SPA をテストする場合は、ブラウザーを自動化できる Playwright for .NET などのツールをお勧めします。

統合テストの概要

統合テストでは 単体テストよりも広範なレベルでアプリのコンポーネントを評価します。 単体テストは、個々のクラス メソッドなど、分離されたソフトウェア コンポーネントをテストするために使用されます。 統合テストでは、2 つ以上のアプリ コンポーネントが、連携して予想される結果を生成することを確認します。これは、要求を完全に処理するために必要なすべてのコンポーネントを含む可能性があります。

これらの広範なテストは、アプリのインフラストラクチャとフレームワーク全体をテストするために使用されます。多くの場合、次のコンポーネントが含まれます。

  • データベース
  • ファイル システム
  • ネットワーク アプライアンス
  • 要求 - 応答パイプライン

単体テストでは、インフラストラクチャ コンポーネントの代わりに、フェイクまたはモック オブジェクトと呼ばれる、作成済みのコンポーネントを使用します。

単体テストと比較すると、統合テストは次のようになります。

  • アプリが運用環境で使用する実際のコンポーネントを使用します。
  • より多くのコードとデータ処理が必要です。
  • 実行に時間がかかります。

そのため、統合テストの使用は最も重要なインフラストラクチャ シナリオに限定します。 単体テストと統合テストのどちらを使用しても動作をテストできる場合は、単体テストを選択します。

統合テストの説明では、テスト対象のプロジェクトのことをよく "テスト対象システム"、または短縮して "SUT" と呼びます。 この記事全体を通して、テスト対象の ASP.NET Core アプリを指すために "SUT" を使用します。

データベースとファイル システムを使用するデータおよびファイル アクセスの "すべての順列として統合テストを記述してはいけません"。 通常は、アプリ全体でデータベースやファイル システムを操作する場所がいくつあったとしても、的を絞った一連の読み取り、書き込み、更新、削除の統合テストを行うことで、データベースおよびファイル システム コンポーネントを適切にテストすることができます。 これらのコンポーネントと連携するメソッドのロジックのルーチン テストには、単体テストを使用します。 単体テストでは、インフラストラクチャのフェイクまたはモックを使用することにより、テストの実行時間が短縮されます。

ASP.NET Core 統合テスト

ASP.NET Core の統合テストには、次のものが必要です。

  • テスト プロジェクトは、テストを格納して実行するために使用します。 テスト プロジェクトは SUT への参照を含みます。
  • テスト プロジェクトは、SUT のテスト Web ホストを作成し、テスト サーバー クライアントを使用して SUT との要求と応答を処理します。
  • テスト ランナーは、テストを実行し、テスト結果を報告するために使用されます。

統合テストでは、通常の Arrange (配置)Act (実行) 、および Assert (確認) のテスト ステップを含む一連のイベントに従います。

  1. SUT の Web ホストが構成されます。
  2. アプリに要求を送信するためのテスト サーバー クライアントが作成されます。
  3. Arrange (配置) テスト ステップが実行されます。テスト アプリが要求を準備します。
  4. Act (実行) テスト ステップが実行されます。クライアントは要求を送信し、応答を受信します。
  5. Assert (確認) テスト ステップが実行されます。実際の応答は、予測される応答に基づき、成功または失敗として検証されます。
  6. このプロセスは、すべてのテストが実行されるまで続行されます。
  7. テスト結果が報告されます。

通常、テスト Web ホストは、アプリの通常のテスト用の Web ホストとは異なる方法で構成されています。 たとえば、テスト用に別のデータベースまたは異なるアプリ設定を使用する場合があります。

テスト Web ホストやメモリ内テスト サーバー (TestServer) などのインフラストラクチャ コンポーネントは、Microsoft.AspNetCore.Mvc.Testing パッケージによって提供または管理されます。 このパッケージを使用すると、テストの作成と実行を効率化できます。

Microsoft.AspNetCore.Mvc.Testing パッケージは、次のタスクを処理します。

  • 依存関係ファイル ( .deps) を SUT からテスト プロジェクトの bin ディレクトリにコピーします。
  • テストを実行したときに、静的なファイルとページ/ビューが検出されるように、コンテンツ ルートを SUT のプロジェクト ルートに設定します。
  • WebApplicationFactory クラスを提供し、TestServer を使用して SUT のブートストラップを効率化します。

単体テストのドキュメントでは、テスト プロジェクトとテスト ランナーを設定する方法、テストを実行する方法の詳細な手順、テストおよびテスト クラスの命名方法に関する推奨事項について説明します。

単体テストを統合テストから分離し、異なるプロジェクトにします。 テストの分離は、次の面で役立ちます。

  • インフラストラクチャ テスト コンポーネントが誤って単体テストに含まれないようにすることができる。
  • 実行されるテストのセットを制御することができる。

Razor Pages アプリと MVC アプリのテストの構成には、ほぼ違いがありません。 唯一の違いは、テストの命名方法です。 Razor Pages アプリでは、ページ エンドポイントのテストは通常、ページ モデル クラスにちなんだ名前が付けられます (たとえば、IndexPageTests では Index ページのコンポーネントの統合テストが行われます)。 MVC アプリでは、テストは通常、コントローラー クラス別に編成され、テストするコントローラーにちなんだ名前が付けられます (たとえば、HomeControllerTests は Home コントローラーのコンポーネントの統合テストを行います)。

テスト アプリの前提条件

テスト プロジェクトは、次の条件を満たす必要があります。

  • Microsoft.AspNetCore.Mvc.Testing パッケージを参照します。
  • プロジェクト ファイル (<Project Sdk="Microsoft.NET.Sdk.Web">) で Web SDK を指定しています。

これらの前提条件は、サンプル アプリで確認できます。 tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj ファイルを確認します。 このサンプル アプリでは、xUnit テスト フレームワークと AngleSharp パーサー ライブラリを使用するので、サンプル アプリは以下も参照します。

xunit.runner.visualstudio バージョン 2.4.2 以降を使用するアプリでは、テスト プロジェクトによって Microsoft.NET.Test.Sdk パッケージが参照される必要があります。

テストでは Entity Framework Core も使用します。 GitHub のプロジェクト ファイルを参照してください。

SUT 環境

SUT の 環境 が設定されていない場合、環境は既定で開発になります。

既定の WebApplicationFactory を使用した基本的なテスト

次のいずれかの操作を行って、暗黙的に定義された Program クラスをテスト プロジェクトに公開します。

  • Web アプリからテスト プロジェクトに内部型を公開します。 これは、SUT プロジェクトのファイル (.csproj) 内で実行できます。

    <ItemGroup>
         <InternalsVisibleTo Include="MyTestProject" />
    </ItemGroup>
    
  • 部分クラス宣言を使用して、Program クラスをパブリックにします

    var builder = WebApplication.CreateBuilder(args);
    // ... Configure services, routes, etc.
    app.Run();
    + public partial class Program { }
    
    public class BasicTests 
        : IClassFixture<WebApplicationFactory<Program>>
    {
        private readonly WebApplicationFactory<Program> _factory;
    
        public BasicTests(WebApplicationFactory<Program> factory)
        {
            _factory = factory;
        }
    
        [Theory]
        [InlineData("/")]
        [InlineData("/Index")]
        [InlineData("/About")]
        [InlineData("/Privacy")]
        [InlineData("/Contact")]
        public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
        {
            // Arrange
            var client = _factory.CreateClient();
    
            // Act
            var response = await client.GetAsync(url);
    
            // Assert
            response.EnsureSuccessStatusCode(); // Status Code 200-299
            Assert.Equal("text/html; charset=utf-8", 
                response.Content.Headers.ContentType.ToString());
        }
    }
    

    サンプル アプリでは、Program 部分クラスの方法を使用しています。

WebApplicationFactory<TEntryPoint> は、統合テスト用の TestServer を作成するために使用します。 TEntryPoint は SUT のエントリ ポイント クラスであり、通常は Program.cs です。

テスト クラスでは、クラスにテストが含まれていることを示すために "クラス フィクスチャ" インターフェイス (IClassFixture) を実装し、クラス内のテストの共有オブジェクト インスタンスを提供します。

次のテスト クラス BasicTests では、WebApplicationFactory を使用して SUT をブートストラップし、テスト メソッド Get_EndpointsReturnSuccessAndCorrectContentTypeHttpClient を提供します。 このメソッドは、複数のアプリ ページで応答状態コードが成功かどうか (200-299) と、Content-Type ヘッダーが text/html; charset=utf-8 であるかどうかを確認します。

CreateClient() では、自動的にリダイレクトに従い、cookie を処理する、HttpClient のインスタンスを作成します。

public class BasicTests 
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public BasicTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Theory]
    [InlineData("/")]
    [InlineData("/Index")]
    [InlineData("/About")]
    [InlineData("/Privacy")]
    [InlineData("/Contact")]
    public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
    {
        // Arrange
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync(url);

        // Assert
        response.EnsureSuccessStatusCode(); // Status Code 200-299
        Assert.Equal("text/html; charset=utf-8", 
            response.Content.Headers.ContentType.ToString());
    }
}

既定では、一般データ保護規則同意ポリシーが有効になっている場合、要求間で必須でない cookie は保持されません。 TempData プロバイダーで使用されているような必須ではない cookie を保持するには、テストに必須であることをマークします。 cookie を必須としてマークする手順については、必須 cookie に関する記事をご覧ください。

偽造防止チェックのための AngleSharp と Application Parts

この記事では、AngleSharp パーサーを使い、ページを読み込んで HTML を解析することで、偽造防止チェックを処理します。 コントローラーと Razor Pages ビューのエンドポイントを低レベルで、ブラウザーでどのようにレンダリングされるかを気にせずテストする場合は、Application Parts の使用を検討してください。 Application Parts の方法では、コントローラーまたは Razor Page をアプリに挿入します。これは必要な値を取得する JSON 要求を行うために使用できます。 詳細については、Martin Costello によるブログ「Application Parts を使用した偽造防止によって保護される ASP.NET Core リソースの統合テスト」と関連する GitHub リポジトリを参照してください。

WebApplicationFactory のカスタマイズ

Web ホストの構成は、WebApplicationFactory<TEntryPoint> から継承して 1 つ以上のカスタム ファクトリを作成することで、テスト クラスとは別に作成できます。

  1. WebApplicationFactory から継承し、ConfigureWebHost をオーバーライドします。 IWebHostBuilder では、IWebHostBuilder.ConfigureServices を使用してサービス コレクションを構成できます。

    public class CustomWebApplicationFactory<TProgram>
        : WebApplicationFactory<TProgram> where TProgram : class
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                var dbContextDescriptor = services.SingleOrDefault(
                    d => d.ServiceType ==
                        typeof(DbContextOptions<ApplicationDbContext>));
    
                services.Remove(dbContextDescriptor);
    
                var dbConnectionDescriptor = services.SingleOrDefault(
                    d => d.ServiceType ==
                        typeof(DbConnection));
    
                services.Remove(dbConnectionDescriptor);
    
                // Create open SqliteConnection so EF won't automatically close it.
                services.AddSingleton<DbConnection>(container =>
                {
                    var connection = new SqliteConnection("DataSource=:memory:");
                    connection.Open();
    
                    return connection;
                });
    
                services.AddDbContext<ApplicationDbContext>((container, options) =>
                {
                    var connection = container.GetRequiredService<DbConnection>();
                    options.UseSqlite(connection);
                });
            });
    
            builder.UseEnvironment("Development");
        }
    }
    

    サンプル アプリでのデータベースのシード処理は、InitializeDbForTests メソッドによって実行されます。 このメソッドについては、統合テストのサンプル: テスト アプリの構成に関するセクションをご覧ください。

    SUT のデータベース コンテキストは、Program.cs に登録されます。 テスト アプリの builder.ConfigureServices コールバックは、アプリの Program.cs コードが実行されたに実行されます。 アプリのデータベースとは異なるデータベースをテストに使用するには、builder.ConfigureServices でアプリのデータベース コンテキストを置き換える必要があります。

    サンプル アプリでは、データベース コンテキストのサービス記述子を検索し、記述子を使用してサービス登録を削除しています。 次に、ファクトリは、テストにメモリ内データベースを使用する新しい ApplicationDbContext を追加します。

    メモリ内データベースではないデータベースに接続するには、UseInMemoryDatabase 呼び出しを変更して、コンテキストを別のデータベースに接続します。 SQL Server テスト データベースを使用するには、次のようにします。

    public class CustomWebApplicationFactory<TProgram>
        : WebApplicationFactory<TProgram> where TProgram : class
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                var dbContextDescriptor = services.SingleOrDefault(
                    d => d.ServiceType ==
                        typeof(DbContextOptions<ApplicationDbContext>));
    
                services.Remove(dbContextDescriptor);
    
                var dbConnectionDescriptor = services.SingleOrDefault(
                    d => d.ServiceType ==
                        typeof(DbConnection));
    
                services.Remove(dbConnectionDescriptor);
    
                // Create open SqliteConnection so EF won't automatically close it.
                services.AddSingleton<DbConnection>(container =>
                {
                    var connection = new SqliteConnection("DataSource=:memory:");
                    connection.Open();
    
                    return connection;
                });
    
                services.AddDbContext<ApplicationDbContext>((container, options) =>
                {
                    var connection = container.GetRequiredService<DbConnection>();
                    options.UseSqlite(connection);
                });
            });
    
            builder.UseEnvironment("Development");
        }
    }
    
  2. テスト クラスでカスタム CustomWebApplicationFactory を使用します。 次の例では、IndexPageTests クラスでファクトリを使用しています。

    public class IndexPageTests :
        IClassFixture<CustomWebApplicationFactory<Program>>
    {
        private readonly HttpClient _client;
        private readonly CustomWebApplicationFactory<Program>
            _factory;
    
        public IndexPageTests(
            CustomWebApplicationFactory<Program> factory)
        {
            _factory = factory;
            _client = factory.CreateClient(new WebApplicationFactoryClientOptions
            {
                AllowAutoRedirect = false
            });
        }
    

    サンプル アプリのクライアントは、HttpClient がリダイレクトに従わないように構成されています。 モック認証のセクションで後述するように、これによってアプリの最初の応答結果を確認するテストが可能になります。 これらのテストでは、多くの場合、最初の応答は Location ヘッダーを持つリダイレクトです。

  3. 一般的なテストでは、HttpClient およびヘルパー メソッドを使用して、要求と応答を処理します。

    [Fact]
    public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot()
    {
        // Arrange
        var defaultPage = await _client.GetAsync("/");
        var content = await HtmlHelpers.GetDocumentAsync(defaultPage);
    
        //Act
        var response = await _client.SendAsync(
            (IHtmlFormElement)content.QuerySelector("form[id='messages']"),
            (IHtmlButtonElement)content.QuerySelector("button[id='deleteAllBtn']"));
    
        // Assert
        Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode);
        Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
        Assert.Equal("/", response.Headers.Location.OriginalString);
    }
    

SUT に対する POST 要求は、アプリの偽造防止データ保護システムによって自動的に行われる偽造防止チェックを満たす必要があります。 テストで POST 要求を実行するには、テスト アプリで次のことを行う必要があります。

  1. ページに対して要求を行います。
  2. 応答の偽造防止 cookie と要求検証トークンを解析します。
  3. 偽造防止 cookie と要求検証トークンを使用して POST 要求を行います。

サンプル アプリSendAsync ヘルパー拡張メソッド (Helpers/HttpClientExtensions.cs) と GetDocumentAsync ヘルパー メソッド (Helpers/HtmlHelpers.cs) は、次のメソッドで AngleSharp パーサーを使用して偽造防止チェック処理を行います。

  • GetDocumentAsync: HttpResponseMessage を受け取り、IHtmlDocument を返します。 GetDocumentAsync は、元の HttpResponseMessage に基づいて仮想応答を準備するファクトリを使用します。 詳しくは、AngleSharp のドキュメントをご覧ください。
  • HttpClientSendAsync 拡張メソッドを使って HttpRequestMessage を作成し、SendAsync(HttpRequestMessage) を呼び出して SUT に要求を送信します。 SendAsync のオーバーロードは、HTML フォーム (IHtmlFormElement) と次のものを受け入れます。
    • フォームの送信ボタン (IHtmlElement)
    • フォームの値コレクション (IEnumerable<KeyValuePair<string, string>>)
    • 送信ボタン (IHtmlElement) とフォームの値 (IEnumerable<KeyValuePair<string, string>>)

AngleSharp は、この記事とサンプル アプリのデモンストレーションのために使用するサードパーティ製の解析ライブラリです。 ASP.NET Core アプリの統合テストでは、AngleSharp はサポートされていないか、必要ありません。 Html Agility Pack (HAP) などの他のパーサーを使用することもできます。 もう 1 つの方法として、偽造防止システムの要求検証トークンを処理するコードを記述し、偽造防止 cookie を直接処理する方法もあります。 詳細については、この記事の「偽造防止チェックのための AngleSharp と Application Parts」を参照してください。

EF-Core インメモリ データベース プロバイダーは、限定された基本的なテストに使用できます。一方、"SQLite プロバイダーは、インメモリ テストに推奨される選択肢です"。

WithWebHostBuilder を使用したクライアントのカスタマイズ

テスト メソッド内で追加の構成が必要な場合、WithWebHostBuilder では、IWebHostBuilder を使用して新しい WebApplicationFactory を作成できます。

サンプル アプリPost_DeleteMessageHandler_ReturnsRedirectToRoot テスト メソッドは、WithWebHostBuilder の使用方法を示しています。 このテストでは、SUT からフォーム送信をトリガーすることによって、データベース内のレコードの削除を実行します。

IndexPageTests クラス内の別のテストは、データベース内のすべてのレコードを削除する操作を実行します。この操作が Post_DeleteMessageHandler_ReturnsRedirectToRoot メソッドの前に実行される可能性があるため、このテスト メソッド内でデータベースの再シード処理を行い、確実に SUT が削除するレコードが存在するようにしています。 SUT に対する要求では、SUT の messages フォームの最初の [削除] ボタンの選択がシミュレートされます。

[Fact]
public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot()
{
    // Arrange
    using (var scope = _factory.Services.CreateScope())
    {
        var scopedServices = scope.ServiceProvider;
        var db = scopedServices.GetRequiredService<ApplicationDbContext>();

        Utilities.ReinitializeDbForTests(db);
    }

    var defaultPage = await _client.GetAsync("/");
    var content = await HtmlHelpers.GetDocumentAsync(defaultPage);

    //Act
    var response = await _client.SendAsync(
        (IHtmlFormElement)content.QuerySelector("form[id='messages']"),
        (IHtmlButtonElement)content.QuerySelector("form[id='messages']")
            .QuerySelector("div[class='panel-body']")
            .QuerySelector("button"));

    // Assert
    Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode);
    Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
    Assert.Equal("/", response.Headers.Location.OriginalString);
}

クライアントのオプション

HttpClient インスタンスを作成するときの既定値と使用可能なオプションについては、WebApplicationFactoryClientOptions に関するページを参照してください。

WebApplicationFactoryClientOptions クラスを作成し、CreateClient() メソッドに渡します。

public class IndexPageTests :
    IClassFixture<CustomWebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    private readonly CustomWebApplicationFactory<Program>
        _factory;

    public IndexPageTests(
        CustomWebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _client = factory.CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });
    }

モック サービスの注入

ホスト ビルダーで ConfigureTestServices を呼び出すと、テストでサービスをオーバーライドできます。

サンプルの SUT には、引用符を返すスコープ サービスが含まれています。 インデックス ページが要求されると、インデックス ページの非表示フィールドに引用符が埋め込まれます。

Services/IQuoteService.cs:

public interface IQuoteService
{
    Task<string> GenerateQuote();
}

Services/QuoteService.cs:

// Quote ©1975 BBC: The Doctor (Tom Baker); Dr. Who: Planet of Evil
// https://www.bbc.co.uk/programmes/p00pyrx6
public class QuoteService : IQuoteService
{
    public Task<string> GenerateQuote()
    {
        return Task.FromResult<string>(
            "Come on, Sarah. We've an appointment in London, " +
            "and we're already 30,000 years late.");
    }
}

Program.cs:

services.AddScoped<IQuoteService, QuoteService>();

Pages/Index.cshtml.cs:

public class IndexModel : PageModel
{
    private readonly ApplicationDbContext _db;
    private readonly IQuoteService _quoteService;

    public IndexModel(ApplicationDbContext db, IQuoteService quoteService)
    {
        _db = db;
        _quoteService = quoteService;
    }

    [BindProperty]
    public Message Message { get; set; }

    public IList<Message> Messages { get; private set; }

    [TempData]
    public string MessageAnalysisResult { get; set; }

    public string Quote { get; private set; }

    public async Task OnGetAsync()
    {
        Messages = await _db.GetMessagesAsync();

        Quote = await _quoteService.GenerateQuote();
    }

Pages/Index.cs:

<input id="quote" type="hidden" value="@Model.Quote">

次のマークアップは、SUT アプリの実行時に生成されます。

<input id="quote" type="hidden" value="Come on, Sarah. We&#x27;ve an appointment in 
    London, and we&#x27;re already 30,000 years late.">

統合テストでサービスと引用符の注入をテストするため、テストは SUT にモック サービスを注入します。 モック サービスは、アプリの QuoteService をテスト アプリが提供する TestQuoteService と呼ばれるサービスに置き換えます。

IntegrationTests.IndexPageTests.cs:

// Quote ©1975 BBC: The Doctor (Tom Baker); Pyramids of Mars
// https://www.bbc.co.uk/programmes/p00pys55
public class TestQuoteService : IQuoteService
{
    public Task<string> GenerateQuote()
    {
        return Task.FromResult(
            "Something's interfering with time, Mr. Scarman, " +
            "and time is my business.");
    }
}

ConfigureTestServices が呼び出され、スコープ サービスが登録されます。

[Fact]
public async Task Get_QuoteService_ProvidesQuoteInPage()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                services.AddScoped<IQuoteService, TestQuoteService>();
            });
        })
        .CreateClient();

    //Act
    var defaultPage = await client.GetAsync("/");
    var content = await HtmlHelpers.GetDocumentAsync(defaultPage);
    var quoteElement = content.QuerySelector("#quote");

    // Assert
    Assert.Equal("Something's interfering with time, Mr. Scarman, " +
        "and time is my business.", quoteElement.Attributes["value"].Value);
}

TestQuoteService によって指定された引用符テキストがテストの実行中に生成されたマークアップに反映されるため、アサーションは成功します。

<input id="quote" type="hidden" value="Something&#x27;s interfering with time, 
    Mr. Scarman, and time is my business.">

モック認証

AuthTests クラスのテストは、セキュリティで保護されたエンドポイントであることを確認します。

  • 認証されていないユーザーは、アプリのサインイン ページにリダイレクトされます。
  • 認証されたユーザーには、コンテンツを返します。

SUT の /SecurePage ページでは、AuthorizePage 規約を使用してページに AuthorizeFilter を適用します。 詳細については、Razor Pages の承認規則に関するページを参照してください。

services.AddRazorPages(options =>
{
    options.Conventions.AuthorizePage("/SecurePage");
});

Get_SecurePageRedirectsAnUnauthenticatedUser テストでは、AllowAutoRedirectfalse に設定することで、WebApplicationFactoryClientOptions がリダイレクトを許可しないように設定しています。

[Fact]
public async Task Get_SecurePageRedirectsAnUnauthenticatedUser()
{
    // Arrange
    var client = _factory.CreateClient(
        new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });

    // Act
    var response = await client.GetAsync("/SecurePage");

    // Assert
    Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
    Assert.StartsWith("http://localhost/Identity/Account/Login",
        response.Headers.Location.OriginalString);
}

クライアントがリダイレクトに従うことを許可しないことで、次のチェックを行うことができます。

  • SUT によって返される状態コードを確認するには、サインイン ページにリダイレクトした後の最終的な状態コード (これは HttpStatusCode.OK になります) ではなく、予期される HttpStatusCode.Redirect の結果と照らし合わせます。
  • 最終的なサインイン ページ応答 (ここでは、Location ヘッダーは存在しません) ではなく、応答ヘッダーの Location ヘッダー値が http://localhost/Identity/Account/Login で始まることを確認できます。

テスト アプリでは、認証と承認の側面をテストするために ConfigureTestServicesAuthenticationHandler<TOptions> をモックすることができます。 最小のシナリオでは、AuthenticateResult.Success が返されます。

public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[] { new Claim(ClaimTypes.Name, "Test user") };
        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "TestScheme");

        var result = AuthenticateResult.Success(ticket);

        return Task.FromResult(result);
    }
}

TestAuthHandler は、認証スキームが TestScheme に設定されている場合 (この場合、AddAuthenticationConfigureTestServices に登録されています) に、ユーザーを認証するために呼び出されます。 アプリから要求されるスキームと TestScheme スキームを一致させることが重要です。 そうでない場合は、認証が機能しません。

[Fact]
public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                services.AddAuthentication(defaultScheme: "TestScheme")
                    .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                        "TestScheme", options => { });
            });
        })
        .CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false,
        });

    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue(scheme: "TestScheme");

    //Act
    var response = await client.GetAsync("/SecurePage");

    // Assert
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

WebApplicationFactoryClientOptions の詳細については、「クライアントのオプション」セクションを参照してください。

認証ミドルウェアの基本的なテスト

認証ミドルウェアの基本的なテストについては、こちらの GitHub リポジトリを参照してください。 これには、テスト シナリオに固有のテスト サーバーが含まれています。

環境を設定する

カスタム アプリケーション ファクトリで環境を設定します。

public class CustomWebApplicationFactory<TProgram>
    : WebApplicationFactory<TProgram> where TProgram : class
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            var dbContextDescriptor = services.SingleOrDefault(
                d => d.ServiceType ==
                    typeof(DbContextOptions<ApplicationDbContext>));

            services.Remove(dbContextDescriptor);

            var dbConnectionDescriptor = services.SingleOrDefault(
                d => d.ServiceType ==
                    typeof(DbConnection));

            services.Remove(dbConnectionDescriptor);

            // Create open SqliteConnection so EF won't automatically close it.
            services.AddSingleton<DbConnection>(container =>
            {
                var connection = new SqliteConnection("DataSource=:memory:");
                connection.Open();

                return connection;
            });

            services.AddDbContext<ApplicationDbContext>((container, options) =>
            {
                var connection = container.GetRequiredService<DbConnection>();
                options.UseSqlite(connection);
            });
        });

        builder.UseEnvironment("Development");
    }
}

テスト インフラストラクチャがアプリ コンテンツのルート パスを推測する方法

WebApplicationFactory コンストラクターを使用すると、TEntryPoint アセンブリの System.Reflection.Assembly.FullName と同じキーを持つ統合テストを含むアセンブリで WebApplicationFactoryContentRootAttribute を検索することによって、アプリのコンテンツ ルート パスを推測できます。 正しいキーを持つ属性が見つからない場合、WebApplicationFactory はフォールバックしてソリューション ファイル ( .sln) を検索し、TEntryPoint アセンブリ名をソリューション ディレクトリに追加します。 アプリのルート ディレクトリ (コンテンツ ルート パス) は、ビューやコンテンツのファイルを検出するために使用されます。

シャドウ コピーの無効化

シャドウ コピーを行うと、テストが出力ディレクトリとは異なるディレクトリで実行されます。 テストが Assembly.Location に関連するファイルの読み込みに依存していて、問題が発生した場合、シャドウ コピーを無効にする必要がある場合があります。

xUnit を使用しているときにシャドウ コピーを無効にするには、正しい構成設定を使用して、テスト プロジェクト ディレクトリに xunit.runner.json ファイルを作成します。

{
  "shadowCopy": false
}

オブジェクトの破棄

IClassFixture 実装のテストを実行した後、xUnit によって を破棄すると、TestServerWebApplicationFactoryHttpClient も破棄されます。 開発者がインスタンス化したオブジェクトを破棄する必要がある場合は、IClassFixture の実装で破棄します。 詳細については、「Dispose メソッドの実装」を参照してください。

統合テストのサンプル

サンプル アプリは、次の 2 つのアプリで構成されています。

アプリ プロジェクト ディレクトリ 説明
メッセージ アプリ (SUT) src/RazorPagesProject ユーザーは、メッセージの追加、1 つ削除、すべて削除、および分析を行うことができます。
アプリをテストする tests/RazorPagesProject.Tests SUT の統合テストに使用されます。

テストは、Visual Studio などの IDE に組み込まれているテスト機能を使用して実行できます。 Visual Studio Code またはコマンド ラインを使用している場合は、コマンド プロンプトで tests/RazorPagesProject.Tests ディレクトリを開き、次のコマンドを実行します。

dotnet test

メッセージ アプリ (SUT) の構成

SUT は、次の特性を持つ Razor Pages メッセージ システムです。

  • アプリのインデックス ページ (Pages/Index.cshtmlPages/Index.cshtml.cs) には、メッセージの追加、削除、および分析 (メッセージあたりの平均単語数) を制御する UI およびページ モデル メソッドが用意されています。
  • メッセージは、Id (キー) と Text (メッセージ) の 2 つのプロパティを持つ Message クラス (Data/Message.cs) によって記述されます。 Text プロパティは必須であり、200 文字までに制限されています。
  • メッセージは、Entity Framework のメモリ内データベース† を使用して格納されます。
  • アプリのデータベース コンテキスト クラスである AppDbContext (Data/AppDbContext.cs) には、データアクセス層 (DAL) が含まれています。
  • アプリの起動時にデータベースが空の場合、メッセージ ストアが 3 つのメッセージで初期化されます。
  • アプリには、認証されたユーザーのみがアクセスできる /SecurePage が含まれています。

InMemory を使用したテストに関する EF 記事では、MSTest を使用したテストでメモリ内データベースを使用する方法について説明します。 このトピックでは、xUnit テスト フレームワークを使用します。 テストの概念とテストの実装は、異なるテスト フレームワークでも類似していますが、同一ではありません。

アプリはリポジトリ パターンを使用しておらず、Unit of Work (UoW) パターンの有効な例ではありませんが、Razor Pages ではこれらの開発パターンがサポートされています。 詳細については、インフラストラクチャの永続化レイヤーの設計およびコントローラー ロジックのテストに関する記事を参照してください (このサンプルはリポジトリ パターンを実装しています)。

テスト アプリの構成

テスト アプリは、tests/RazorPagesProject.Tests ディレクトリにあるコンソール アプリです。

テスト アプリのディレクトリ 説明
AuthTests 次のテスト メソッドを含みます。
  • 認証されていないユーザーがセキュリティで保護されたページにアクセスする。
  • モック AuthenticationHandler<TOptions> で認証されたユーザーがセキュリティで保護されたページにアクセスする。
  • GitHub ユーザー プロファイルを取得し、プロファイルのユーザー ログインを確認する。
BasicTests ルーティングおよびコンテンツ タイプのテスト メソッドを含みます。
IntegrationTests カスタム WebApplicationFactory クラスを使用したインデックス ページの統合テストを含みます。
Helpers/Utilities
  • Utilities.cs には、データベースにテスト データをシードする InitializeDbForTests メソッドが含まれています。
  • HtmlHelpers.cs には、テスト メソッドで使用する AngleSharp IHtmlDocument を返すメソッドが用意されています。
  • HttpClientExtensions.cs は、SUT に要求を送信する SendAsync のオーバーロードを提供します。

テスト フレームワークは、xUnit です。 統合テストは、TestServer を含む Microsoft.AspNetCore.TestHost を使用して実行されます。 Microsoft.AspNetCore.Mvc.Testing パッケージはテスト ホストとテスト サーバーを構成するために使用されるため、テスト アプリのプロジェクト ファイルまたはテスト アプリの開発者構成で直接 TestHost および TestServer パッケージを参照する必要はありません。

統合テストでは、通常、テストを実行する前に、データベース内に小さなデータセットが必要です。 たとえば、削除テストでは、データベース レコードの削除を呼び出します。そのため、削除要求を成功させるには、データベースに少なくとも 1 つのレコードが必要です。

このサンプル アプリでは、Utilities.cs で 3 つのメッセージを使用してデータベースをシードします。このメッセージは、テストを実行する際に使用することができます。

public static void InitializeDbForTests(ApplicationDbContext db)
{
    db.Messages.AddRange(GetSeedingMessages());
    db.SaveChanges();
}

public static void ReinitializeDbForTests(ApplicationDbContext db)
{
    db.Messages.RemoveRange(db.Messages);
    InitializeDbForTests(db);
}

public static List<Message> GetSeedingMessages()
{
    return new List<Message>()
    {
        new Message(){ Text = "TEST RECORD: You're standing on my scarf." },
        new Message(){ Text = "TEST RECORD: Would you like a jelly baby?" },
        new Message(){ Text = "TEST RECORD: To the rational mind, " +
            "nothing is inexplicable; only unexplained." }
    };
}

SUT のデータベース コンテキストは、Program.cs に登録されます。 テスト アプリの builder.ConfigureServices コールバックは、アプリの Program.cs コードが実行されたに実行されます。 テストに異なるデータベースを使用するには、アプリのデータベース コンテキストを builder.ConfigureServicesで置き換える必要があります。 詳細については、「WebApplicationFactory のカスタマイズ」セクションをご覧ください。

その他の技術情報

このトピックを読むには、単体テストの基本的な知識があることが前提となります。 テストの概念を理解していない場合は、「.NET Core の単体テストと .NET Standard」 のトピックとリンクされたコンテンツを参照してください。

サンプル コードを表示またはダウンロードします (ダウンロード方法)。

このサンプル アプリは Razor Pages アプリであり、Razor Pages の基本を理解していることを前提としています。 Razor Pages に慣れていない場合は、次のトピックを参照してください。

Note

SPA をテストする場合は、ブラウザーを自動化できる Playwright for .NET などのツールを推奨します。

統合テストの概要

統合テストでは 単体テストよりも広範なレベルでアプリのコンポーネントを評価します。 単体テストは、個々のクラス メソッドなど、分離されたソフトウェア コンポーネントをテストするために使用されます。 統合テストでは、2 つ以上のアプリ コンポーネントが、連携して予想される結果を生成することを確認します。これは、要求を完全に処理するために必要なすべてのコンポーネントを含む可能性があります。

これらの広範なテストは、アプリのインフラストラクチャとフレームワーク全体をテストするために使用されます。多くの場合、次のコンポーネントが含まれます。

  • データベース
  • ファイル システム
  • ネットワーク アプライアンス
  • 要求 - 応答パイプライン

単体テストでは、インフラストラクチャ コンポーネントの代わりに、フェイクまたはモック オブジェクトと呼ばれる、作成済みのコンポーネントを使用します。

単体テストと比較すると、統合テストは次のようになります。

  • アプリが運用環境で使用する実際のコンポーネントを使用します。
  • より多くのコードとデータ処理が必要です。
  • 実行に時間がかかります。

そのため、統合テストの使用は最も重要なインフラストラクチャ シナリオに限定します。 単体テストと統合テストのどちらを使用しても動作をテストできる場合は、単体テストを選択します。

統合テストの説明では、テスト対象のプロジェクトのことをよく "テスト対象システム"、または短縮して "SUT" と呼びます。 この記事全体を通して、テスト対象の ASP.NET Core アプリを指すために "SUT" を使用します。

データベースとファイル システムを使用するデータおよびファイル アクセスの "すべての順列として統合テストを記述してはいけません"。 通常は、アプリ全体でデータベースやファイル システムを操作する場所がいくつあったとしても、的を絞った一連の読み取り、書き込み、更新、削除の統合テストを行うことで、データベースおよびファイル システム コンポーネントを適切にテストすることができます。 これらのコンポーネントと連携するメソッドのロジックのルーチン テストには、単体テストを使用します。 単体テストでは、インフラストラクチャのフェイクまたはモックを使用することにより、テストの実行時間が短縮されます。

ASP.NET Core 統合テスト

ASP.NET Core の統合テストには、次のものが必要です。

  • テスト プロジェクトは、テストを格納して実行するために使用します。 テスト プロジェクトは SUT への参照を含みます。
  • テスト プロジェクトは、SUT のテスト Web ホストを作成し、テスト サーバー クライアントを使用して SUT との要求と応答を処理します。
  • テスト ランナーは、テストを実行し、テスト結果を報告するために使用されます。

統合テストでは、通常の Arrange (配置)Act (実行) 、および Assert (確認) のテスト ステップを含む一連のイベントに従います。

  1. SUT の Web ホストが構成されます。
  2. アプリに要求を送信するためのテスト サーバー クライアントが作成されます。
  3. Arrange (配置) テスト ステップが実行されます。テスト アプリが要求を準備します。
  4. Act (実行) テスト ステップが実行されます。クライアントは要求を送信し、応答を受信します。
  5. Assert (確認) テスト ステップが実行されます。実際の応答は、予測される応答に基づき、成功または失敗として検証されます。
  6. このプロセスは、すべてのテストが実行されるまで続行されます。
  7. テスト結果が報告されます。

通常、テスト Web ホストは、アプリの通常のテスト用の Web ホストとは異なる方法で構成されています。 たとえば、テスト用に別のデータベースまたは異なるアプリ設定を使用する場合があります。

テスト Web ホストやメモリ内テスト サーバー (TestServer) などのインフラストラクチャ コンポーネントは、Microsoft.AspNetCore.Mvc.Testing パッケージによって提供または管理されます。 このパッケージを使用すると、テストの作成と実行を効率化できます。

Microsoft.AspNetCore.Mvc.Testing パッケージは、次のタスクを処理します。

  • 依存関係ファイル ( .deps) を SUT からテスト プロジェクトの bin ディレクトリにコピーします。
  • テストを実行したときに、静的なファイルとページ/ビューが検出されるように、コンテンツ ルートを SUT のプロジェクト ルートに設定します。
  • WebApplicationFactory クラスを提供し、TestServer を使用して SUT のブートストラップを効率化します。

単体テストのドキュメントでは、テスト プロジェクトとテスト ランナーを設定する方法、テストを実行する方法の詳細な手順、テストおよびテスト クラスの命名方法に関する推奨事項について説明します。

単体テストを統合テストから分離し、異なるプロジェクトにします。 テストの分離は、次の面で役立ちます。

  • インフラストラクチャ テスト コンポーネントが誤って単体テストに含まれないようにすることができる。
  • 実行されるテストのセットを制御することができる。

Razor Pages アプリと MVC アプリのテストの構成には、ほぼ違いがありません。 唯一の違いは、テストの命名方法です。 Razor Pages アプリでは、ページ エンドポイントのテストは通常、ページ モデル クラスにちなんだ名前が付けられます (たとえば、IndexPageTests では Index ページのコンポーネントの統合テストが行われます)。 MVC アプリでは、テストは通常、コントローラー クラス別に編成され、テストするコントローラーにちなんだ名前が付けられます (たとえば、HomeControllerTests は Home コントローラーのコンポーネントの統合テストを行います)。

テスト アプリの前提条件

テスト プロジェクトは、次の条件を満たす必要があります。

  • Microsoft.AspNetCore.Mvc.Testing パッケージを参照します。
  • プロジェクト ファイル (<Project Sdk="Microsoft.NET.Sdk.Web">) で Web SDK を指定しています。

これらの前提条件は、サンプル アプリで確認できます。 tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj ファイルを確認します。 このサンプル アプリでは、xUnit テスト フレームワークと AngleSharp パーサー ライブラリを使用するので、サンプル アプリは以下も参照します。

xunit.runner.visualstudio バージョン 2.4.2 以降を使用するアプリでは、テスト プロジェクトによって Microsoft.NET.Test.Sdk パッケージが参照される必要があります。

テストでは Entity Framework Core も使用します。 アプリは以下を参照します。

SUT 環境

SUT の 環境 が設定されていない場合、環境は既定で開発になります。

既定の WebApplicationFactory を使用した基本的なテスト

ASP.NET Core 6 では、WebApplication が導入され、Startup クラスが不要になりました。 Startup クラスを使わずに WebApplicationFactory を使ってテストするには、ASP.NET Core 6 アプリで、次のように暗黙的に定義された Program クラスをテスト プロジェクトに公開する必要があります。

  • Web アプリからテスト プロジェクトに内部型を公開します。 これは、プロジェクト ファイル (.csproj) 内で実行できます。
    <ItemGroup>
         <InternalsVisibleTo Include="MyTestProject" />
    </ItemGroup>
    
  • 部分クラス宣言を使用して、Program クラスをパブリックにします。
    var builder = WebApplication.CreateBuilder(args);
    // ... Configure services, routes, etc.
    app.Run();
    + public partial class Program { }
    

Web アプリケーションに変更を加えた後、テスト プロジェクトで WebApplicationFactoryProgram クラスを使用できるようになります。

[Fact]
public async Task HelloWorldTest()
{
    var application = new WebApplicationFactory<Program>()
        .WithWebHostBuilder(builder =>
        {
            // ... Configure test services
        });

    var client = application.CreateClient();
    //...
}

WebApplicationFactory<TEntryPoint> は、統合テスト用の TestServer を作成するために使用します。 TEntryPoint は SUT のエントリ ポイント クラスであり、通常は Startup クラスです。

テスト クラスでは、クラスにテストが含まれていることを示すために "クラス フィクスチャ" インターフェイス (IClassFixture) を実装し、クラス内のテストの共有オブジェクト インスタンスを提供します。

次のテスト クラス BasicTests では、WebApplicationFactory を使用して SUT をブートストラップし、テスト メソッド Get_EndpointsReturnSuccessAndCorrectContentTypeHttpClient を提供します。 このメソッドは、複数のアプリ ページで応答状態コードが成功かどうか (200-299 の範囲の状態コード) と、Content-Type ヘッダーが text/html; charset=utf-8 であるかどうかを確認します。

CreateClient() では、自動的にリダイレクトに従い、cookie を処理する、HttpClient のインスタンスを作成します。

public class BasicTests 
    : IClassFixture<WebApplicationFactory<RazorPagesProject.Startup>>
{
    private readonly WebApplicationFactory<RazorPagesProject.Startup> _factory;

    public BasicTests(WebApplicationFactory<RazorPagesProject.Startup> factory)
    {
        _factory = factory;
    }

    [Theory]
    [InlineData("/")]
    [InlineData("/Index")]
    [InlineData("/About")]
    [InlineData("/Privacy")]
    [InlineData("/Contact")]
    public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
    {
        // Arrange
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync(url);

        // Assert
        response.EnsureSuccessStatusCode(); // Status Code 200-299
        Assert.Equal("text/html; charset=utf-8", 
            response.Content.Headers.ContentType.ToString());
    }
}

既定では、GDPR 同意ポリシーが有効になっている場合、要求間で必須でない cookie は保持されません。 TempData プロバイダーで使用されているような必須ではない cookie を保持するには、テストに必須であることをマークします。 cookie を必須としてマークする手順については、必須 cookie に関する記事をご覧ください。

WebApplicationFactory のカスタマイズ

Web ホストの構成は、WebApplicationFactory から継承して 1 つ以上のカスタム ファクトリを作成することで、テスト クラスとは別に作成できます。

  1. WebApplicationFactory から継承し、ConfigureWebHost をオーバーライドします。 IWebHostBuilder では、ConfigureServices を使用してサービス コレクションを構成できます。

    public class CustomWebApplicationFactory<TStartup>
        : WebApplicationFactory<TStartup> where TStartup: class
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType ==
                        typeof(DbContextOptions<ApplicationDbContext>));
    
                services.Remove(descriptor);
    
                services.AddDbContext<ApplicationDbContext>(options =>
                {
                    options.UseInMemoryDatabase("InMemoryDbForTesting");
                });
    
                var sp = services.BuildServiceProvider();
    
                using (var scope = sp.CreateScope())
                {
                    var scopedServices = scope.ServiceProvider;
                    var db = scopedServices.GetRequiredService<ApplicationDbContext>();
                    var logger = scopedServices
                        .GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>();
    
                    db.Database.EnsureCreated();
    
                    try
                    {
                        Utilities.InitializeDbForTests(db);
                    }
                    catch (Exception ex)
                    {
                        logger.LogError(ex, "An error occurred seeding the " +
                            "database with test messages. Error: {Message}", ex.Message);
                    }
                }
            });
        }
    }
    

    サンプル アプリでのデータベースのシード処理は、InitializeDbForTests メソッドによって実行されます。 このメソッドについては、統合テストのサンプル: テスト アプリの構成に関するセクションをご覧ください。

    SUT のデータベース コンテキストは、Startup.ConfigureServices メソッドに登録されます。 テスト アプリの builder.ConfigureServices コールバックは、アプリの Startup.ConfigureServices コードが実行されたに実行されます。 ASP.NET Core 3.0 リリースの汎用ホストにより、実行順序に関する互換性のない変更が行われています。 アプリのデータベースとは異なるデータベースをテストに使用するには、builder.ConfigureServices でアプリのデータベース コンテキストを置き換える必要があります。

    まだ Web ホストを使用している SUT の場合、テスト アプリの builder.ConfigureServices コールバックは、SUT の Startup.ConfigureServices コードの "前に" 実行されます。 テスト アプリの builder.ConfigureTestServices コールバックは、"後で" 実行されます。

    サンプル アプリでは、データベース コンテキストのサービス記述子を検索し、記述子を使用してサービス登録を削除しています。 次に、ファクトリは、テストにメモリ内データベースを使用する新しい ApplicationDbContext を追加します。

    メモリ内データベースではないデータベースに接続するには、UseInMemoryDatabase 呼び出しを変更して、コンテキストを別のデータベースに接続します。 SQL Server テスト データベースを使用するには、次のようにします。

    • プロジェクト ファイルで Microsoft.EntityFrameworkCore.SqlServer NuGet パッケージを参照します。
    • データベースへの接続文字列を使用して UseSqlServer を呼び出します。
    services.AddDbContext<ApplicationDbContext>((options, context) => 
    {
        context.UseSqlServer(
            Configuration.GetConnectionString("TestingDbConnectionString"));
    });
    
  2. テスト クラスでカスタム CustomWebApplicationFactory を使用します。 次の例では、IndexPageTests クラスでファクトリを使用しています。

    public class IndexPageTests : 
        IClassFixture<CustomWebApplicationFactory<RazorPagesProject.Startup>>
    {
        private readonly HttpClient _client;
        private readonly CustomWebApplicationFactory<RazorPagesProject.Startup> 
            _factory;
    
        public IndexPageTests(
            CustomWebApplicationFactory<RazorPagesProject.Startup> factory)
        {
            _factory = factory;
            _client = factory.CreateClient(new WebApplicationFactoryClientOptions
                {
                    AllowAutoRedirect = false
                });
        }
    

    サンプル アプリのクライアントは、HttpClient がリダイレクトに従わないように構成されています。 モック認証のセクションで後述するように、これによってアプリの最初の応答結果を確認するテストが可能になります。 これらのテストでは、多くの場合、最初の応答は Location ヘッダーを持つリダイレクトです。

  3. 一般的なテストでは、HttpClient およびヘルパー メソッドを使用して、要求と応答を処理します。

    [Fact]
    public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot()
    {
        // Arrange
        var defaultPage = await _client.GetAsync("/");
        var content = await HtmlHelpers.GetDocumentAsync(defaultPage);
    
        //Act
        var response = await _client.SendAsync(
            (IHtmlFormElement)content.QuerySelector("form[id='messages']"),
            (IHtmlButtonElement)content.QuerySelector("button[id='deleteAllBtn']"));
    
        // Assert
        Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode);
        Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
        Assert.Equal("/", response.Headers.Location.OriginalString);
    }
    

SUT に対する POST 要求は、アプリの偽造防止データ保護システムによって自動的に行われる偽造防止チェックを満たす必要があります。 テストで POST 要求を実行するには、テスト アプリで次のことを行う必要があります。

  1. ページに対して要求を行います。
  2. 応答の偽造防止 cookie と要求検証トークンを解析します。
  3. 偽造防止 cookie と要求検証トークンを使用して POST 要求を行います。

サンプル アプリSendAsync ヘルパー拡張メソッド (Helpers/HttpClientExtensions.cs) と GetDocumentAsync ヘルパー メソッド (Helpers/HtmlHelpers.cs) は、次のメソッドで AngleSharp パーサーを使用して偽造防止チェック処理を行います。

  • GetDocumentAsync: HttpResponseMessage を受け取り、IHtmlDocument を返します。 GetDocumentAsync は、元の HttpResponseMessage に基づいて仮想応答を準備するファクトリを使用します。 詳しくは、AngleSharp のドキュメントをご覧ください。
  • HttpClientSendAsync 拡張メソッドを使って HttpRequestMessage を作成し、SendAsync(HttpRequestMessage) を呼び出して SUT に要求を送信します。 SendAsync のオーバーロードは、HTML フォーム (IHtmlFormElement) と次のものを受け入れます。
    • フォームの送信ボタン (IHtmlElement)
    • フォームの値コレクション (IEnumerable<KeyValuePair<string, string>>)
    • 送信ボタン (IHtmlElement) とフォームの値 (IEnumerable<KeyValuePair<string, string>>)

Note

AngleSharp は、このトピックとサンプル アプリのデモンストレーションのために使用するサードパーティ製の解析ライブラリです。 ASP.NET Core アプリの統合テストでは、AngleSharp はサポートされていないか、必要ありません。 Html Agility Pack (HAP) などの他のパーサーを使用することもできます。 もう 1 つの方法として、偽造防止システムの要求検証トークンを処理するコードを記述し、偽造防止 cookie を直接処理する方法もあります。

Note

EF-Core インメモリ データベース プロバイダーは、限定された基本的なテストに使用できます。一方、SQLite プロバイダーは、インメモリ テストに推奨される選択肢です。

WithWebHostBuilder を使用したクライアントのカスタマイズ

テスト メソッド内で追加の構成が必要な場合、WithWebHostBuilder では、IWebHostBuilder を使用して新しい WebApplicationFactory を作成できます。

サンプル アプリPost_DeleteMessageHandler_ReturnsRedirectToRoot テスト メソッドは、WithWebHostBuilder の使用方法を示しています。 このテストでは、SUT からフォーム送信をトリガーすることによって、データベース内のレコードの削除を実行します。

IndexPageTests クラス内の別のテストは、データベース内のすべてのレコードを削除する操作を実行します。この操作が Post_DeleteMessageHandler_ReturnsRedirectToRoot メソッドの前に実行される可能性があるため、このテスト メソッド内でデータベースの再シード処理を行い、確実に SUT が削除するレコードが存在するようにしています。 SUT に対する要求では、SUT の messages フォームの最初の [削除] ボタンの選択がシミュレートされます。

[Fact]
public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                var serviceProvider = services.BuildServiceProvider();

                using (var scope = serviceProvider.CreateScope())
                {
                    var scopedServices = scope.ServiceProvider;
                    var db = scopedServices
                        .GetRequiredService<ApplicationDbContext>();
                    var logger = scopedServices
                        .GetRequiredService<ILogger<IndexPageTests>>();

                    try
                    {
                        Utilities.ReinitializeDbForTests(db);
                    }
                    catch (Exception ex)
                    {
                        logger.LogError(ex, "An error occurred seeding " +
                            "the database with test messages. Error: {Message}", 
                            ex.Message);
                    }
                }
            });
        })
        .CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });
    var defaultPage = await client.GetAsync("/");
    var content = await HtmlHelpers.GetDocumentAsync(defaultPage);

    //Act
    var response = await client.SendAsync(
        (IHtmlFormElement)content.QuerySelector("form[id='messages']"),
        (IHtmlButtonElement)content.QuerySelector("form[id='messages']")
            .QuerySelector("div[class='panel-body']")
            .QuerySelector("button"));

    // Assert
    Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode);
    Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
    Assert.Equal("/", response.Headers.Location.OriginalString);
}

クライアントのオプション

次の表に、HttpClient インスタンスを作成するときに使用できる既定の WebApplicationFactoryClientOptions を示します。

オプション 説明 Default
AllowAutoRedirect HttpClient インスタンスがリダイレクト応答に自動的に従うかどうかを取得または設定します。 true
BaseAddress HttpClient インスタンスのベース アドレスを取得または設定します。 http://localhost
HandleCookies HttpClient インスタンスが cookie を処理する必要があるかどうかを取得または設定します。 true
MaxAutomaticRedirections HttpClient インスタンスが従う必要があるリダイレクト応答の最大数を取得または設定します。 7

WebApplicationFactoryClientOptions クラスを作成し、それを CreateClient() メソッドに渡します (既定値については、コード例を参照してください)。

// Default client option values are shown
var clientOptions = new WebApplicationFactoryClientOptions();
clientOptions.AllowAutoRedirect = true;
clientOptions.BaseAddress = new Uri("http://localhost");
clientOptions.HandleCookies = true;
clientOptions.MaxAutomaticRedirections = 7;

_client = _factory.CreateClient(clientOptions);

モック サービスの注入

ホスト ビルダーで ConfigureTestServices を呼び出すと、テストでサービスをオーバーライドできます。 モック サービスを注入するには、SUT に Startup クラスが存在し、そこに Startup.ConfigureServices メソッドが存在している必要があります。

サンプルの SUT には、引用符を返すスコープ サービスが含まれています。 インデックス ページが要求されると、インデックス ページの非表示フィールドに引用符が埋め込まれます。

Services/IQuoteService.cs:

public interface IQuoteService
{
    Task<string> GenerateQuote();
}

Services/QuoteService.cs:

// Quote ©1975 BBC: The Doctor (Tom Baker); Dr. Who: Planet of Evil
// https://www.bbc.co.uk/programmes/p00pyrx6
public class QuoteService : IQuoteService
{
    public Task<string> GenerateQuote()
    {
        return Task.FromResult<string>(
            "Come on, Sarah. We've an appointment in London, " +
            "and we're already 30,000 years late.");
    }
}

Startup.cs:

services.AddScoped<IQuoteService, QuoteService>();

Pages/Index.cshtml.cs:

public class IndexModel : PageModel
{
    private readonly ApplicationDbContext _db;
    private readonly IQuoteService _quoteService;

    public IndexModel(ApplicationDbContext db, IQuoteService quoteService)
    {
        _db = db;
        _quoteService = quoteService;
    }

    [BindProperty]
    public Message Message { get; set; }

    public IList<Message> Messages { get; private set; }

    [TempData]
    public string MessageAnalysisResult { get; set; }

    public string Quote { get; private set; }

    public async Task OnGetAsync()
    {
        Messages = await _db.GetMessagesAsync();

        Quote = await _quoteService.GenerateQuote();
    }

Pages/Index.cs:

<input id="quote" type="hidden" value="@Model.Quote">

次のマークアップは、SUT アプリの実行時に生成されます。

<input id="quote" type="hidden" value="Come on, Sarah. We&#x27;ve an appointment in 
    London, and we&#x27;re already 30,000 years late.">

統合テストでサービスと引用符の注入をテストするため、テストは SUT にモック サービスを注入します。 モック サービスは、アプリの QuoteService をテスト アプリが提供する TestQuoteService と呼ばれるサービスに置き換えます。

IntegrationTests.IndexPageTests.cs:

// Quote ©1975 BBC: The Doctor (Tom Baker); Pyramids of Mars
// https://www.bbc.co.uk/programmes/p00pys55
public class TestQuoteService : IQuoteService
{
    public Task<string> GenerateQuote()
    {
        return Task.FromResult<string>(
            "Something's interfering with time, Mr. Scarman, " +
            "and time is my business.");
    }
}

ConfigureTestServices が呼び出され、スコープ サービスが登録されます。

[Fact]
public async Task Get_QuoteService_ProvidesQuoteInPage()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                services.AddScoped<IQuoteService, TestQuoteService>();
            });
        })
        .CreateClient();

    //Act
    var defaultPage = await client.GetAsync("/");
    var content = await HtmlHelpers.GetDocumentAsync(defaultPage);
    var quoteElement = content.QuerySelector("#quote");

    // Assert
    Assert.Equal("Something's interfering with time, Mr. Scarman, " +
        "and time is my business.", quoteElement.Attributes["value"].Value);
}

TestQuoteService によって指定された引用符テキストがテストの実行中に生成されたマークアップに反映されるため、アサーションは成功します。

<input id="quote" type="hidden" value="Something&#x27;s interfering with time, 
    Mr. Scarman, and time is my business.">

モック認証

AuthTests クラスのテストは、セキュリティで保護されたエンドポイントであることを確認します。

  • 認証されていないユーザーは、アプリのログイン ページにリダイレクトされます。
  • 認証されたユーザーには、コンテンツを返します。

SUT の /SecurePage ページでは、AuthorizePage 規約を使用してページに AuthorizeFilter を適用します。 詳細については、Razor Pages の承認規則に関するページを参照してください。

services.AddRazorPages(options =>
{
    options.Conventions.AuthorizePage("/SecurePage");
});

Get_SecurePageRedirectsAnUnauthenticatedUser テストでは、AllowAutoRedirectfalse に設定することで、WebApplicationFactoryClientOptions がリダイレクトを許可しないように設定しています。

[Fact]
public async Task Get_SecurePageRedirectsAnUnauthenticatedUser()
{
    // Arrange
    var client = _factory.CreateClient(
        new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });

    // Act
    var response = await client.GetAsync("/SecurePage");

    // Assert
    Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
    Assert.StartsWith("http://localhost/Identity/Account/Login", 
        response.Headers.Location.OriginalString);
}

クライアントがリダイレクトに従うことを許可しないことで、次のチェックを行うことができます。

  • SUT によって返される状態コードを確認するには、ログイン ページにリダイレクトした後の最終的な状態コード (これは HttpStatusCode.OK になります) ではなく、予期される HttpStatusCode.Redirect の結果と照らし合わせます。
  • 最終的なログイン ページ応答 (ここでは、Location ヘッダーは存在しません) ではなく、応答ヘッダーの Location ヘッダー値が http://localhost/Identity/Account/Login で始まることを確認できます。

テスト アプリでは、認証と承認の側面をテストするために ConfigureTestServicesAuthenticationHandler<TOptions> をモックすることができます。 最小のシナリオでは、AuthenticateResult.Success が返されます。

public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, 
        ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[] { new Claim(ClaimTypes.Name, "Test user") };
        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "Test");

        var result = AuthenticateResult.Success(ticket);

        return Task.FromResult(result);
    }
}

TestAuthHandler は、認証スキームが Test に設定されている場合 (この場合、AddAuthenticationConfigureTestServices に登録されています) に、ユーザーを認証するために呼び出されます。 アプリから要求されるスキームと Test スキームを一致させることが重要です。 そうでない場合は、認証が機能しません。

[Fact]
public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                services.AddAuthentication("Test")
                    .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                        "Test", options => {});
            });
        })
        .CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false,
        });

    client.DefaultRequestHeaders.Authorization = 
        new AuthenticationHeaderValue("Test");

    //Act
    var response = await client.GetAsync("/SecurePage");

    // Assert
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

WebApplicationFactoryClientOptions の詳細については、「クライアントのオプション」セクションを参照してください。

環境を設定する

既定では、SUT のホストとアプリ環境は、開発環境を使用するように構成されています。 IHostBuilder を使用しているときに SUT の環境をオーバーライドするには、次のようにします。

  • ASPNETCORE_ENVIRONMENT 環境変数 (たとえば、StagingProduction、または Testing などのカスタム値) を設定します。
  • テスト アプリで CreateHostBuilder をオーバーライドして、ASPNETCORE で始まる環境変数を読み取ります。
protected override IHostBuilder CreateHostBuilder() =>
    base.CreateHostBuilder()
        .ConfigureHostConfiguration(
            config => config.AddEnvironmentVariables("ASPNETCORE"));

SUT が Web ホスト (IWebHostBuilder) を使用している場合は、CreateWebHostBuilder をオーバーライドします。

protected override IWebHostBuilder CreateWebHostBuilder() =>
    base.CreateWebHostBuilder().UseEnvironment("Testing");

テスト インフラストラクチャがアプリ コンテンツのルート パスを推測する方法

WebApplicationFactory コンストラクターを使用すると、TEntryPoint アセンブリの System.Reflection.Assembly.FullName と同じキーを持つ統合テストを含むアセンブリで WebApplicationFactoryContentRootAttribute を検索することによって、アプリのコンテンツ ルート パスを推測できます。 正しいキーを持つ属性が見つからない場合、WebApplicationFactory はフォールバックしてソリューション ファイル ( .sln) を検索し、TEntryPoint アセンブリ名をソリューション ディレクトリに追加します。 アプリのルート ディレクトリ (コンテンツ ルート パス) は、ビューやコンテンツのファイルを検出するために使用されます。

シャドウ コピーの無効化

シャドウ コピーを行うと、テストが出力ディレクトリとは異なるディレクトリで実行されます。 テストが Assembly.Location に関連するファイルの読み込みに依存していて、問題が発生した場合、シャドウ コピーを無効にする必要がある場合があります。

xUnit を使用しているときにシャドウ コピーを無効にするには、正しい構成設定を使用して、テスト プロジェクト ディレクトリに xunit.runner.json ファイルを作成します。

{
  "shadowCopy": false
}

オブジェクトの破棄

IClassFixture 実装のテストを実行した後、xUnit によって を破棄すると、TestServerWebApplicationFactoryHttpClient も破棄されます。 開発者がインスタンス化したオブジェクトを破棄する必要がある場合は、IClassFixture の実装で破棄します。 詳細については、「Dispose メソッドの実装」を参照してください。

統合テストのサンプル

サンプル アプリは、次の 2 つのアプリで構成されています。

アプリ プロジェクト ディレクトリ 説明
メッセージ アプリ (SUT) src/RazorPagesProject ユーザーは、メッセージの追加、1 つ削除、すべて削除、および分析を行うことができます。
アプリをテストする tests/RazorPagesProject.Tests SUT の統合テストに使用されます。

テストは、Visual Studio などの IDE に組み込まれているテスト機能を使用して実行できます。 Visual Studio Code またはコマンド ラインを使用している場合は、コマンド プロンプトで tests/RazorPagesProject.Tests ディレクトリを開き、次のコマンドを実行します。

dotnet test

メッセージ アプリ (SUT) の構成

SUT は、次の特性を持つ Razor Pages メッセージ システムです。

  • アプリのインデックス ページ (Pages/Index.cshtmlPages/Index.cshtml.cs) には、メッセージの追加、削除、および分析 (メッセージあたりの平均単語数) を制御する UI およびページ モデル メソッドが用意されています。
  • メッセージは、Id (キー) と Text (メッセージ) の 2 つのプロパティを持つ Message クラス (Data/Message.cs) によって記述されます。 Text プロパティは必須であり、200 文字までに制限されています。
  • メッセージは、Entity Framework のメモリ内データベース† を使用して格納されます。
  • アプリのデータベース コンテキスト クラスである AppDbContext (Data/AppDbContext.cs) には、データアクセス層 (DAL) が含まれています。
  • アプリの起動時にデータベースが空の場合、メッセージ ストアが 3 つのメッセージで初期化されます。
  • アプリには、認証されたユーザーのみがアクセスできる /SecurePage が含まれています。

InMemory を使用したテストに関する EF トピックでは、MSTest を使用したテストでメモリ内データベースを使用する方法について説明しています。 このトピックでは、xUnit テスト フレームワークを使用します。 テストの概念とテストの実装は、異なるテスト フレームワークでも類似していますが、同一ではありません。

アプリはリポジトリ パターンを使用しておらず、Unit of Work (UoW) パターンの有効な例ではありませんが、Razor Pages ではこれらの開発パターンがサポートされています。 詳細については、インフラストラクチャの永続化レイヤーの設計およびコントローラー ロジックのテストに関する記事を参照してください (このサンプルはリポジトリ パターンを実装しています)。

テスト アプリの構成

テスト アプリは、tests/RazorPagesProject.Tests ディレクトリにあるコンソール アプリです。

テスト アプリのディレクトリ 説明
AuthTests 次のテスト メソッドを含みます。
  • 認証されていないユーザーがセキュリティで保護されたページにアクセスする。
  • モック AuthenticationHandler<TOptions> で認証されたユーザーがセキュリティで保護されたページにアクセスする。
  • GitHub ユーザー プロファイルを取得し、プロファイルのユーザー ログインを確認する。
BasicTests ルーティングおよびコンテンツ タイプのテスト メソッドを含みます。
IntegrationTests カスタム WebApplicationFactory クラスを使用したインデックス ページの統合テストを含みます。
Helpers/Utilities
  • Utilities.cs には、データベースにテスト データをシードする InitializeDbForTests メソッドが含まれています。
  • HtmlHelpers.cs には、テスト メソッドで使用する AngleSharp IHtmlDocument を返すメソッドが用意されています。
  • HttpClientExtensions.cs は、SUT に要求を送信する SendAsync のオーバーロードを提供します。

テスト フレームワークは、xUnit です。 統合テストは、TestServer を含む Microsoft.AspNetCore.TestHost を使用して実行されます。 Microsoft.AspNetCore.Mvc.Testing パッケージはテスト ホストとテスト サーバーを構成するために使用されるため、テスト アプリのプロジェクト ファイルまたはテスト アプリの開発者構成で直接 TestHost および TestServer パッケージを参照する必要はありません。

統合テストでは、通常、テストを実行する前に、データベース内に小さなデータセットが必要です。 たとえば、削除テストでは、データベース レコードの削除を呼び出します。そのため、削除要求を成功させるには、データベースに少なくとも 1 つのレコードが必要です。

このサンプル アプリでは、Utilities.cs で 3 つのメッセージを使用してデータベースをシードします。このメッセージは、テストを実行する際に使用することができます。

public static void InitializeDbForTests(ApplicationDbContext db)
{
    db.Messages.AddRange(GetSeedingMessages());
    db.SaveChanges();
}

public static void ReinitializeDbForTests(ApplicationDbContext db)
{
    db.Messages.RemoveRange(db.Messages);
    InitializeDbForTests(db);
}

public static List<Message> GetSeedingMessages()
{
    return new List<Message>()
    {
        new Message(){ Text = "TEST RECORD: You're standing on my scarf." },
        new Message(){ Text = "TEST RECORD: Would you like a jelly baby?" },
        new Message(){ Text = "TEST RECORD: To the rational mind, " +
            "nothing is inexplicable; only unexplained." }
    };
}

SUT のデータベース コンテキストは、Startup.ConfigureServices メソッドに登録されます。 テスト アプリの builder.ConfigureServices コールバックは、アプリの Startup.ConfigureServices コードが実行されたに実行されます。 テストに異なるデータベースを使用するには、アプリのデータベース コンテキストを builder.ConfigureServicesで置き換える必要があります。 詳細については、「WebApplicationFactory のカスタマイズ」セクションをご覧ください。

まだ Web ホストを使用している SUT の場合、テスト アプリの builder.ConfigureServices コールバックは、SUT の Startup.ConfigureServices コードの "前に" 実行されます。 テスト アプリの builder.ConfigureTestServices コールバックは、"後で" 実行されます。

その他の技術情報