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 (它可以自動執行瀏覽器) 之類的工具。

整合測試簡介

整合測試評估應用程式元件的層級比單元測試更廣泛。 單元測試可用來測試隔離的軟體元件 (例如個別的類別方法)。 整合測試可確認兩個或多個應用程式元件一起運作以產生預期的結果,可能包括完整處理要求所需的每個元件。

這些更廣泛的測試是用來測試應用程式的基礎結構和整個架構,通常包括下列元件:

  • Database
  • 檔案系統
  • 網路設備
  • 要求-回應管線

單元測試會使用虛構的元件 (稱為虛擬元件 (fake)模擬物件 (mock object)) 來取代基礎結構元件。

對比單元測試,整合測試會:

  • 使用應用程式在生產環境中所使用的實際元件。
  • 需要更多的程式碼和資料處理。
  • 需要更長的時間來執行。

因此,會將整合測試的使用限制在最重要的基礎結構情境中。 如果可以使用單元測試或整合測試來測試行為,請選擇單元測試。

在討論整合測試時,被測試的專案通常稱為待測系統 (或簡稱 "SUT")。 本文通篇使用 "SUT" 來指正在被測試的 ASP.NET Core 應用程式。

對於與資料庫和檔案系統進行資料和檔案存取的測試,不要為每種組合情況都撰寫整合測試。 無論應用程式中有多少部分會與資料庫和檔案系統互動,一組專注的讀取、寫入、更新和刪除整合測試通常能夠充分測試資料庫和檔案系統元件。 可以使用單元測試來對與這些元件互動的方法邏輯進行常規測試。 在單元測試中,使用基礎結構虛擬元件或模擬元件可以加快測試執行速度。

ASP.NET Core 整合測試

ASP.NET Core 中的整合測試需要下列各項:

  • 使用測試專案來包含和執行測試。 測試專案具有對 SUT 的參考。
  • 測試專案會為 SUT 建立一台測試 Web 主機,並使用一台測試伺服器用戶端來處理與 SUT 的要求和回應。
  • 使用一個測試執行器來執行測試並報告測試結果。

整合測試會遵循一系列事件的順序,包括通常的安排 (Arrange)執行 (Act)斷言 (Assert) 測試步驟:

  1. 設定 SUT 的 Web 主機。
  2. 建立測試伺服器用戶端,以將要求提交至應用程式。
  3. 執行安排測試步驟:測試應用程式準備要求。
  4. 執行執行測試步驟:用戶端提交要求並接收回應。
  5. 執行斷言測試步驟:根據預期的回應,驗證實際的回應是通過還是失敗
  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 用於測試索引頁面的元件整合)。 在 MVC 應用程式中,測試通常由控制器類別組織,並以它們測試的控制器命名 (例如,HomeControllerTests 用於測試 Home 控制器的元件整合)。

測試應用程式的必要條件

測試專案必須:

這些必要條件可以在範例應用程式中看到。 檢查 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 { }
    

    範例應用程式使用 Program 部分類別方法。

WebApplicationFactory<TEntryPoint> 可用來建立用於整合測試的 TestServerTEntryPoint 是 SUT 的進入點類別,通常為 Program.cs

測試類別會實作一個類別固件介面 (IClassFixture) 以指示該類別包含測試並提供跨類別中的測試的共用物件實例。

下列測試類別 (BasicTests) 會使用 WebApplicationFactory 來引導 SUT 並向測試方法 Get_EndpointsReturnSuccessAndCorrectContentType 提供 HttpClient。 此方法會驗證回應狀態碼是否成功 (200-299),而對於數個應用程式頁面,Content-Type 標頭為 text/html; charset=utf-8

CreateClient() 會建立一個 HttpClient 的實例 (該實例會自動遵循重新導向並處理 cookie)。

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。 若要保留非必要的 cookie (例如 TempData 提供者所使用的 cookie),請在測試中將它們標示為必要的。 如需將 cookie 標示為必要的指示,請參閱必要的 cookie

AngleSharp 與 Application Parts 的防偽檢查

本文使用 AngleSharp 剖析器,藉由載入頁面和剖析 HTML 來處理防偽檢查。 若要在較低層級測試控制器和 Razor Pages 檢視的端點,而不需關心它們在瀏覽器中呈現的方式,請考慮使用 Application PartsApplication Parts 方法會將控制器或 Razor Page 注入到應用程式中 (該應用程式可用來發出 JS ON 要求以取得所需的值)。 如需詳細資訊,請參閱 Martin Costello 撰寫的部落格 Integration Testing ASP.NET Core Resources Protected with Antiforgery Using Application Parts,以及相關聯的 GitHub 存放庫

自訂 WebApplicationFactory

透過繼承自 WebApplicationFactory<TEntryPoint> 來建立一或多個自訂 Factory,可以不需要依賴測試類別來建立 Web 主機組態:

  1. 繼承自 WebApplicationFactory 並覆寫 ConfigureWebHostIWebHostBuilder 允許使用 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 中取代應用程式的資料庫內容。

    範例應用程式會尋找資料庫內容的服務描述項,並使用該描述項來移除服務註冊。 然後,Factory 會新增一個新的 ApplicationDbContext (它會使用記憶體內部資料庫來進行測試)。

    若要連線到不同的資料庫,請變更 DbConnection。 若要使用 SQL Server 測試資料庫:

  1. 使用測試類別中的自訂 CustomWebApplicationFactory。 下列範例使用 IndexPageTests 類別中的 Factory:

    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 標頭的重新導向。

  2. 一般的測試會使用 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 並傳回 IHtmlDocumentGetDocumentAsync 會使用一個根據原始 HttpResponseMessage 準備虛擬回應 的 Factory。 如需詳細資訊,請參閱 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)。 另一種方法是撰寫程式碼,以直接處理防偽系統的要求驗證權杖和防偽 cookie。 如需詳細資訊,請參閱本文中的 AngleSharp 與 Application Parts 的防偽檢查

EF-Core 記憶體內部資料庫提供者可用於有限且基本的測試,不過 SQLite 提供者 是記憶體內部測試的建議選項

請參閱使用啟動篩選條件來擴充啟動,其中示範如何使用 IStartupFilter (這在測試需要自訂服務或中介軟體時很有用) 來設定中介軟體。

使用 WithWebHostBuilder 自訂用戶端

當測試方法內需要其他設定時,WithWebHostBuilder 會建立一個新的 WebApplicationFactory,其中包含透過設定進一步自訂的 IWebHostBuilder

範例程式碼會呼叫 WithWebHostBuilder 以將已設定服務取代為測試虛設常式。 如需詳細資訊和範例使用方式,請參閱本文中的插入模擬服務

範例應用程式Post_DeleteMessageHandler_ReturnsRedirectToRoot 測試方法會示範如何使用 WithWebHostBuilder。 此測試會藉由觸發在 SUT 中提交表單,以在資料庫中執行記錄刪除動作。

因為 IndexPageTests 類別中的另一個測試會執行刪除資料庫中所有記錄的作業,並且可能在 Post_DeleteMessageHandler_ReturnsRedirectToRoot 方法之前執行,所以會在此測試方法中重新植入資料庫,以確保存在可供 SUT 刪除的記錄。 選取 SUT 中 messages 表單的第一個刪除按鈕,會在對 SUT 的要求中模擬:

[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
        });
    }

注意: 若要避免在使用 HTTPS 重新導向中介軟體時記錄中出現 HTTPS 重新導向警告,請設定 BaseAddress = new Uri("https://localhost")

注入模擬服務

可以透過呼叫主機建置器上的 ConfigureTestServices 在測試中覆寫服務。 若要將覆寫的服務範圍設定為測試本身,WithWebHostBuilder 方法可用來擷取主機建立器。 這項設定如下列測試所示:

範例 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 測試中,WebApplicationFactoryClientOptions 會設為不允許重新導向 (方法是將 AllowAutoRedirect 設為 false):

[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.Redirect 結果來進行檢查,而不是重新導向到登入頁面之後的最終狀態碼 (也就是 HttpStatusCode.OK)。
  • 會檢查回應標頭中的 Location 標頭值,以確認它以 http://localhost/Identity/Account/Login 開頭,而不是最終登入頁面回應 (其中 Location 標頭不會出現)。

測試應用程式可以在 ConfigureTestServices 中模擬 AuthenticationHandler<TOptions>,以測試驗證和授權的各個層面。 最小案例會傳回 AuthenticateResult.Success

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

    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);
    }
}

當驗證配置設定為 TestScheme (其中會為 ConfigureTestServices 註冊 AddAuthentication) 時,會呼叫 TestAuthHandler 來驗證使用者。 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 存放庫。 它包含測試案例特定的測試伺服器

設定環境

在自訂應用程式 Factory 中設定環境

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 建構函式會透過在包含整合測試的組件上搜尋 WebApplicationFactoryContentRootAttribute (其索引鍵等於 TEntryPoint 組件 System.Reflection.Assembly.FullName) 來推斷應用程式內容根路徑。 如果找不到具有正確索引鍵的屬性,WebApplicationFactory 將轉而搜尋方案檔 (.sln),並將 TEntryPoint 組件名稱附加到方案目錄。 應用程式根目錄 (內容根路徑) 會用來探索檢視和內容檔案。

停用陰影複製

陰影複製會導致測試在與輸出目錄不同的目錄中執行。 如果您的測試依賴載入與 Assembly.Location 相關的檔案且您遇到問題,則您可能必須停用陰影複製。

若要在使用 xUnit 時停用卷影複製,請在測試專案目錄中建立一個 xunit.runner.json 檔案 (使用正確的組態設定):

{
  "shadowCopy": false
}

處置物件

在執行 IClassFixture 實作的測試之後,當 xUnit 處置 WebApplicationFactory 時,TestServerHttpClient 也會被處置。 如果開發人員具現化的物件需要處置,請在 IClassFixture 實作中處置它們。 如需詳細資訊,請參閱實作處置方法

整合測試範例

範例應用程式由兩個應用程式所組成:

App 專案目錄 描述
訊息應用程式 (SUT) src/RazorPagesProject 允許使用者新增、刪除一個、刪除所有訊息及分析訊息。
測試應用程式 tests/RazorPagesProject.Tests 用來對 SUT 進行整合測試。

您可以使用 IDE (例如 Visual Studio) 的內建測試功能來執行測試。 如果使用 Visual Studio Code 或命令列,請在命令提示字元下的 tests/RazorPagesProject.Tests 目錄中執行以下命令:

dotnet test

訊息應用程式 (SUT) 組織

SUT 是一個具有下列特性的 Razor Pages 訊息系統:

  • 應用程式的 [索引] 頁面 (Pages/Index.cshtmlPages/Index.cshtml.cs) 提供了 UI 和頁面模型方法來控制訊息的新增、刪除和分析 (每則訊息的平均字數)。
  • 訊息由 Message 類別 (Data/Message.cs) 來描述,其中包含兩個屬性:Id (索引鍵) 和 Text (訊息)。 Text 屬性為必要屬性,且限制為 200 個字元。
  • 訊息會使用 Entity Framework 的記憶體內部資料庫來儲存†。
  • 應用程式在其資料庫內容類別 AppDbContext (Data/AppDbContext.cs) 中包含資料存取層 (DAL)。
  • 如果應用程式啟動時資料庫是空的,訊息存放區會以三則訊息初始化。
  • 應用程式包含一個只能由已驗證使用者存取的 /SecurePage

†EF 文章 (使用 InMemory 測試) 說明如何使用記憶體內部資料庫搭配 MSTest 進行測試。 本主題使用 xUnit 測試架構。 不同測試架構的測試概念和測試實作彼此相似但並不相同。

雖然應用程式不使用存放庫模式,也不是工作單位 (UoW) 模式的有效範例,但 Razor Pages 支援此類開發模式。 如需詳細資訊,請參閱設計基礎結構持續性層測試控制器邏輯 (範例會實作存放庫模式)。

測試應用程式組織

測試應用程式是 tests/RazorPagesProject.Tests 目錄內的主控台應用程式。

測試應用程式目錄 描述
AuthTests 包含用於動作的測試方法:
  • 由未經驗證的使用者存取安全頁面。
  • 由已驗證的使用者透過模擬 AuthenticationHandler<TOptions> 存取安全頁面。
  • 取得 GitHub 使用者設定檔,並檢查設定檔的使用者登入。
BasicTests 包含用於路由傳送和內容類型的測試方法。
IntegrationTests 包含使用自訂 WebApplicationFactory 類別的索引頁面的整合測試。
Helpers/Utilities
  • Utilities.cs 包含用來以測試資料植入資料庫的 InitializeDbForTests 方法。
  • HtmlHelpers.cs 提供一個方法來傳回 AngleSharp IHtmlDocument,以供測試方法使用。
  • HttpClientExtensions.csSendAsync 提供多載,以將要求提交至 SUT。

測試架構為 xUnit。 整合測試是使用 Microsoft.AspNetCore.TestHost (其中包括 TTestServer) 進行的。 因為 Microsoft.AspNetCore.Mvc.Testing 套件會用來設定測試主機和測試伺服器,所以 TestHostTestServer 套件不需要在測試應用程式的專案檔中直接參考套件,也不需要在測試應用中進行開發人員設定。

在測試執行之前,整合測試在資料庫中通常需要一個小型的資料集。 例如,刪除測試會要求刪除資料庫記錄,因此資料庫至少必須有一筆記錄才能使刪除要求成功。

範例應用程式在 Utilities.cs 中使用三則訊息來植入資料庫 (測試在執行時可以使用這些訊息):

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,請參閱下列主題:

注意

若要測試 SPA,建議您使用像是 Playwright for .NET (它可以自動執行瀏覽器) 之類的工具。

整合測試簡介

整合測試評估應用程式元件的層級比單元測試更廣泛。 單元測試可用來測試隔離的軟體元件 (例如個別的類別方法)。 整合測試可確認兩個或多個應用程式元件一起運作以產生預期的結果,可能包括完整處理要求所需的每個元件。

這些更廣泛的測試是用來測試應用程式的基礎結構和整個架構,通常包括下列元件:

  • Database
  • 檔案系統
  • 網路設備
  • 要求-回應管線

單元測試會使用虛構的元件 (稱為虛擬元件 (fake)模擬物件 (mock object)) 來取代基礎結構元件。

對比單元測試,整合測試會:

  • 使用應用程式在生產環境中所使用的實際元件。
  • 需要更多的程式碼和資料處理。
  • 需要更長的時間來執行。

因此,會將整合測試的使用限制在最重要的基礎結構情境中。 如果可以使用單元測試或整合測試來測試行為,請選擇單元測試。

在討論整合測試時,被測試的專案通常稱為待測系統 (或簡稱 "SUT")。 本文通篇使用 "SUT" 來指正在被測試的 ASP.NET Core 應用程式。

對於與資料庫和檔案系統進行資料和檔案存取的測試,不要為每種組合情況都撰寫整合測試。 無論應用程式中有多少部分會與資料庫和檔案系統互動,一組專注的讀取、寫入、更新和刪除整合測試通常能夠充分測試資料庫和檔案系統元件。 可以使用單元測試來對與這些元件互動的方法邏輯進行常規測試。 在單元測試中,使用基礎結構虛擬元件或模擬元件可以加快測試執行速度。

ASP.NET Core 整合測試

ASP.NET Core 中的整合測試需要下列各項:

  • 使用測試專案來包含和執行測試。 測試專案具有對 SUT 的參考。
  • 測試專案會為 SUT 建立一台測試 Web 主機,並使用一台測試伺服器用戶端來處理與 SUT 的要求和回應。
  • 使用一個測試執行器來執行測試並報告測試結果。

整合測試會遵循一系列事件的順序,包括通常的安排 (Arrange)執行 (Act)斷言 (Assert) 測試步驟:

  1. 設定 SUT 的 Web 主機。
  2. 建立測試伺服器用戶端,以將要求提交至應用程式。
  3. 執行安排測試步驟:測試應用程式準備要求。
  4. 執行執行測試步驟:用戶端提交要求並接收回應。
  5. 執行斷言測試步驟:根據預期的回應,驗證實際的回應是通過還是失敗
  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 用於測試索引頁面的元件整合)。 在 MVC 應用程式中,測試通常由控制器類別組織,並以它們測試的控制器命名 (例如,HomeControllerTests 用於測試 Home 控制器的元件整合)。

測試應用程式的必要條件

測試專案必須:

這些必要條件可以在範例應用程式中看到。 檢查 tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj 檔案。 範例應用程式會使用 xUnit 測試架構和 AngleSharp 剖析器程式庫,因此範例應用程式也會參考:

在使用 xunit.runner.visualstudio 2.4.2 版或更新版本的應用程式中,測試專案必須參考 Microsoft.NET.Test.Sdk 套件。

Entity Framework Core 也會用於測試中。 應用程式參考:

SUT 環境

如果未設定 SUT 的環境,則環境會預設為 [開發]。

使用預設 WebApplicationFactory 的基本測試

WebApplicationFactory<TEntryPoint> 可用來建立用於整合測試的 TestServerTEntryPoint 是 SUT 的進入點類別,通常為 Startup 類別。

測試類別會實作一個類別固件介面 (IClassFixture) 以指示該類別包含測試並提供跨類別中的測試的共用物件實例。

下列測試類別 (BasicTests) 會使用 WebApplicationFactory 來引導 SUT 並向測試方法 Get_EndpointsReturnSuccessAndCorrectContentType 提供 HttpClient。 此方法會檢查回應狀態代碼是否成功 (狀態碼範圍為 200-299) 以及數個應用程式頁面的 Content-Type 標頭是否為 text/html; charset=utf-8

CreateClient() 會建立一個 HttpClient 的實例 (該實例會自動遵循重新導向並處理 cookie)。

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。 若要保留非必要的 cookie (例如 TempData 提供者所使用的 cookie),請在測試中將它們標示為必要的。 如需將 cookie 標示為必要的指示,請參閱必要的 cookie

自訂 WebApplicationFactory

透過繼承自 WebApplicationFactory 來建立一或多個自訂 Factory,可以不需要依賴測試類別來建立 Web 主機組態:

  1. 繼承自 WebApplicationFactory 並覆寫 ConfigureWebHostIWebHostBuilder 允許使用 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 回呼會在之後 執行。

    範例應用程式會尋找資料庫內容的服務描述項,並使用該描述項來移除服務註冊。 接下來,Factory 會新增一個新的 ApplicationDbContext (它會使用記憶體內部資料庫來進行測試)。

    若要連接到與記憶體內部資料庫不同的資料庫,請變更 UseInMemoryDatabase 呼叫以將內容連接到不同的資料庫。 若要使用 SQL Server 測試資料庫:

    services.AddDbContext<ApplicationDbContext>((options, context) => 
    {
        context.UseSqlServer(
            Configuration.GetConnectionString("TestingDbConnectionString"));
    });
    
  2. 使用測試類別中的自訂 CustomWebApplicationFactory。 下列範例使用 IndexPageTests 類別中的 Factory:

    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 並傳回 IHtmlDocumentGetDocumentAsync 會使用一個根據原始 HttpResponseMessage 準備虛擬回應 的 Factory。 如需詳細資訊,請參閱 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)。 另一種方法是撰寫程式碼,以直接處理防偽系統的要求驗證權杖和防偽 cookie。

注意

EF-Core 記憶體內部資料庫提供者可用於有限且基本的測試,不過 SQLite 提供者 是記憶體內部測試的建議選項。

使用 WithWebHostBuilder 自訂用戶端

當測試方法內需要其他設定時,WithWebHostBuilder 會建立一個新的 WebApplicationFactory,其中包含透過設定進一步自訂的 IWebHostBuilder

範例應用程式Post_DeleteMessageHandler_ReturnsRedirectToRoot 測試方法會示範如何使用 WithWebHostBuilder。 此測試會藉由觸發在 SUT 中提交表單,以在資料庫中執行記錄刪除動作。

因為 IndexPageTests 類別中的另一個測試會執行刪除資料庫中所有記錄的作業,並且可能在 Post_DeleteMessageHandler_ReturnsRedirectToRoot 方法之前執行,所以會在此測試方法中重新植入資料庫,以確保存在可供 SUT 刪除的記錄。 選取 SUT 中 messages 表單的第一個刪除按鈕,會在對 SUT 的要求中模擬:

[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);
}

用戶端選項

下表顯示建立 WebApplicationFactoryClientOptions 實例時可用的預設值 HttpClient

選項 描述 預設
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.ConfigureServices 方法的 Startup 類別。

範例 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 測試中,WebApplicationFactoryClientOptions 會設為不允許重新導向 (方法是將 AllowAutoRedirect 設為 false):

[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.Redirect 結果來進行檢查,而不是重新導向到登入頁面之後的最終狀態碼 (也就是 HttpStatusCode.OK)。
  • 會檢查回應標頭中的 Location 標頭值,以確認它以 http://localhost/Identity/Account/Login 開頭,而不是最終登入頁面回應 (其中 Location 標頭不會出現)。

測試應用程式可以在 ConfigureTestServices 中模擬 AuthenticationHandler<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);
    }
}

當驗證配置設定為 Test (其中會為 ConfigureTestServices 註冊 AddAuthentication) 時,會呼叫 TestAuthHandler 來驗證使用者。 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 建構函式會透過在包含整合測試的組件上搜尋 WebApplicationFactoryContentRootAttribute (其索引鍵等於 TEntryPoint 組件 System.Reflection.Assembly.FullName) 來推斷應用程式內容根路徑。 如果找不到具有正確索引鍵的屬性,WebApplicationFactory 將轉而搜尋方案檔 (.sln),並將 TEntryPoint 組件名稱附加到方案目錄。 應用程式根目錄 (內容根路徑) 會用來探索檢視和內容檔案。

停用陰影複製

陰影複製會導致測試在與輸出目錄不同的目錄中執行。 如果您的測試依賴載入與 Assembly.Location 相關的檔案且您遇到問題,則您可能必須停用陰影複製。

若要在使用 xUnit 時停用卷影複製,請在測試專案目錄中建立一個 xunit.runner.json 檔案 (使用正確的組態設定):

{
  "shadowCopy": false
}

處置物件

在執行 IClassFixture 實作的測試之後,當 xUnit 處置 WebApplicationFactory 時,TestServerHttpClient 也會被處置。 如果開發人員具現化的物件需要處置,請在 IClassFixture 實作中處置它們。 如需詳細資訊,請參閱實作處置方法

整合測試範例

範例應用程式由兩個應用程式所組成:

App 專案目錄 描述
訊息應用程式 (SUT) src/RazorPagesProject 允許使用者新增、刪除一個、刪除所有訊息及分析訊息。
測試應用程式 tests/RazorPagesProject.Tests 用來對 SUT 進行整合測試。

您可以使用 IDE (例如 Visual Studio) 的內建測試功能來執行測試。 如果使用 Visual Studio Code 或命令列,請在命令提示字元下的 tests/RazorPagesProject.Tests 目錄中執行以下命令:

dotnet test

訊息應用程式 (SUT) 組織

SUT 是一個具有下列特性的 Razor Pages 訊息系統:

  • 應用程式的 [索引] 頁面 (Pages/Index.cshtmlPages/Index.cshtml.cs) 提供了 UI 和頁面模型方法來控制訊息的新增、刪除和分析 (每則訊息的平均字數)。
  • 訊息由 Message 類別 (Data/Message.cs) 來描述,其中包含兩個屬性:Id (索引鍵) 和 Text (訊息)。 Text 屬性為必要屬性,且限制為 200 個字元。
  • 訊息會使用 Entity Framework 的記憶體內部資料庫來儲存†。
  • 應用程式在其資料庫內容類別 AppDbContext (Data/AppDbContext.cs) 中包含資料存取層 (DAL)。
  • 如果應用程式啟動時資料庫是空的,訊息存放區會以三則訊息初始化。
  • 應用程式包含一個只能由已驗證使用者存取的 /SecurePage

†EF 主題 使用 InMemory 測試,說明如何使用記憶體內部資料庫搭配 MSTest 進行測試。 本主題使用 xUnit 測試架構。 不同測試架構的測試概念和測試實作彼此相似但並不相同。

雖然應用程式不使用存放庫模式,也不是工作單位 (UoW) 模式的有效範例,但 Razor Pages 支援此類開發模式。 如需詳細資訊,請參閱設計基礎結構持續性層測試控制器邏輯 (範例會實作存放庫模式)。

測試應用程式組織

測試應用程式是 tests/RazorPagesProject.Tests 目錄內的主控台應用程式。

測試應用程式目錄 描述
AuthTests 包含用於動作的測試方法:
  • 由未經驗證的使用者存取安全頁面。
  • 由已驗證的使用者透過模擬 AuthenticationHandler<TOptions> 存取安全頁面。
  • 取得 GitHub 使用者設定檔,並檢查設定檔的使用者登入。
BasicTests 包含用於路由傳送和內容類型的測試方法。
IntegrationTests 包含使用自訂 WebApplicationFactory 類別的索引頁面的整合測試。
Helpers/Utilities
  • Utilities.cs 包含用來以測試資料植入資料庫的 InitializeDbForTests 方法。
  • HtmlHelpers.cs 提供一個方法來傳回 AngleSharp IHtmlDocument,以供測試方法使用。
  • HttpClientExtensions.csSendAsync 提供多載,以將要求提交至 SUT。

測試架構為 xUnit。 整合測試是使用 Microsoft.AspNetCore.TestHost (其中包括 TTestServer) 進行的。 因為 Microsoft.AspNetCore.Mvc.Testing 套件會用來設定測試主機和測試伺服器,所以 TestHostTestServer 套件不需要在測試應用程式的專案檔中直接參考套件,也不需要在測試應用中進行開發人員設定。

在測試執行之前,整合測試在資料庫中通常需要一個小型的資料集。 例如,刪除測試會要求刪除資料庫記錄,因此資料庫至少必須有一筆記錄才能使刪除要求成功。

範例應用程式在 Utilities.cs 中使用三則訊息來植入資料庫 (測試在執行時可以使用這些訊息):

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 回呼會在之後 執行。

其他資源

本文假設您對單元測試有基本的了解。 如果不熟悉測試概念,請參閱 .NET Core 和 .NET Standard 中的單元測試一文及其連結內容。

檢視或下載範例程式碼 \(英文\) (如何下載)

範例應用程式是一個 Razor Pages 應用程式,並假設您對 Razor Pages 有基本的了解。 如果您不熟悉 Razor Pages,請參閱下列文章:

若要測試 SPA,建議您使用像是 Playwright for .NET (它可以自動執行瀏覽器) 之類的工具。

整合測試簡介

整合測試評估應用程式元件的層級比單元測試更廣泛。 單元測試可用來測試隔離的軟體元件 (例如個別的類別方法)。 整合測試可確認兩個或多個應用程式元件一起運作以產生預期的結果,可能包括完整處理要求所需的每個元件。

這些更廣泛的測試是用來測試應用程式的基礎結構和整個架構,通常包括下列元件:

  • Database
  • 檔案系統
  • 網路設備
  • 要求-回應管線

單元測試會使用虛構的元件 (稱為虛擬元件 (fake)模擬物件 (mock object)) 來取代基礎結構元件。

對比單元測試,整合測試會:

  • 使用應用程式在生產環境中所使用的實際元件。
  • 需要更多的程式碼和資料處理。
  • 需要更長的時間來執行。

因此,會將整合測試的使用限制在最重要的基礎結構情境中。 如果可以使用單元測試或整合測試來測試行為,請選擇單元測試。

在討論整合測試時,被測試的專案通常稱為待測系統 (或簡稱 "SUT")。 本文通篇使用 "SUT" 來指正在被測試的 ASP.NET Core 應用程式。

對於與資料庫和檔案系統進行資料和檔案存取的測試,不要為每種組合情況都撰寫整合測試。 無論應用程式中有多少部分會與資料庫和檔案系統互動,一組專注的讀取、寫入、更新和刪除整合測試通常能夠充分測試資料庫和檔案系統元件。 可以使用單元測試來對與這些元件互動的方法邏輯進行常規測試。 在單元測試中,使用基礎結構虛擬元件或模擬元件可以加快測試執行速度。

ASP.NET Core 整合測試

ASP.NET Core 中的整合測試需要下列各項:

  • 使用測試專案來包含和執行測試。 測試專案具有對 SUT 的參考。
  • 測試專案會為 SUT 建立一台測試 Web 主機,並使用一台測試伺服器用戶端來處理與 SUT 的要求和回應。
  • 使用一個測試執行器來執行測試並報告測試結果。

整合測試會遵循一系列事件的順序,包括通常的安排 (Arrange)執行 (Act)斷言 (Assert) 測試步驟:

  1. 設定 SUT 的 Web 主機。
  2. 建立測試伺服器用戶端,以將要求提交至應用程式。
  3. 執行安排測試步驟:測試應用程式準備要求。
  4. 執行執行測試步驟:用戶端提交要求並接收回應。
  5. 執行斷言測試步驟:根據預期的回應,驗證實際的回應是通過還是失敗
  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 用於測試索引頁面的元件整合)。 在 MVC 應用程式中,測試通常由控制器類別組織,並以它們測試的控制器命名 (例如,HomeControllerTests 用於測試 Home 控制器的元件整合)。

測試應用程式的必要條件

測試專案必須:

這些必要條件可以在範例應用程式中看到。 檢查 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 { }
    

    範例應用程式使用 Program 部分類別方法。

WebApplicationFactory<TEntryPoint> 可用來建立用於整合測試的 TestServerTEntryPoint 是 SUT 的進入點類別,通常為 Program.cs

測試類別會實作一個類別固件介面 (IClassFixture) 以指示該類別包含測試並提供跨類別中的測試的共用物件實例。

下列測試類別 (BasicTests) 會使用 WebApplicationFactory 來引導 SUT 並向測試方法 Get_EndpointsReturnSuccessAndCorrectContentType 提供 HttpClient。 此方法會驗證回應狀態碼是否成功 (200-299),而對於數個應用程式頁面,Content-Type 標頭為 text/html; charset=utf-8

CreateClient() 會建立一個 HttpClient 的實例 (該實例會自動遵循重新導向並處理 cookie)。

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。 若要保留非必要的 cookie (例如 TempData 提供者所使用的 cookie),請在測試中將它們標示為必要的。 如需將 cookie 標示為必要的指示,請參閱必要的 cookie

AngleSharp 與 Application Parts 的防偽檢查

本文使用 AngleSharp 剖析器,藉由載入頁面和剖析 HTML 來處理防偽檢查。 若要在較低層級測試控制器和 Razor Pages 檢視的端點,而不需關心它們在瀏覽器中呈現的方式,請考慮使用 Application PartsApplication Parts 方法會將控制器或 Razor Page 注入到應用程式中 (該應用程式可用來發出 JS ON 要求以取得所需的值)。 如需詳細資訊,請參閱 Martin Costello 撰寫的部落格 Integration Testing ASP.NET Core Resources Protected with Antiforgery Using Application Parts,以及相關聯的 GitHub 存放庫

自訂 WebApplicationFactory

透過繼承自 WebApplicationFactory<TEntryPoint> 來建立一或多個自訂 Factory,可以不需要依賴測試類別來建立 Web 主機組態:

  1. 繼承自 WebApplicationFactory 並覆寫 ConfigureWebHostIWebHostBuilder 允許使用 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 中取代應用程式的資料庫內容。

    範例應用程式會尋找資料庫內容的服務描述項,並使用該描述項來移除服務註冊。 然後,Factory 會新增一個新的 ApplicationDbContext (它會使用記憶體內部資料庫來進行測試)。

    若要連線到不同的資料庫,請變更 DbConnection。 若要使用 SQL Server 測試資料庫:

  1. 使用測試類別中的自訂 CustomWebApplicationFactory。 下列範例使用 IndexPageTests 類別中的 Factory:

    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 標頭的重新導向。

  2. 一般的測試會使用 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 並傳回 IHtmlDocumentGetDocumentAsync 會使用一個根據原始 HttpResponseMessage 準備虛擬回應 的 Factory。 如需詳細資訊,請參閱 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)。 另一種方法是撰寫程式碼,以直接處理防偽系統的要求驗證權杖和防偽 cookie。 如需詳細資訊,請參閱本文中的 AngleSharp 與 Application Parts 的防偽檢查

EF-Core 記憶體內部資料庫提供者可用於有限且基本的測試,不過 SQLite 提供者 是記憶體內部測試的建議選項

請參閱使用啟動篩選條件來擴充啟動,其中示範如何使用 IStartupFilter (這在測試需要自訂服務或中介軟體時很有用) 來設定中介軟體。

使用 WithWebHostBuilder 自訂用戶端

當測試方法內需要其他設定時,WithWebHostBuilder 會建立一個新的 WebApplicationFactory,其中包含透過設定進一步自訂的 IWebHostBuilder

範例程式碼會呼叫 WithWebHostBuilder 以將已設定服務取代為測試虛設常式。 如需詳細資訊和範例使用方式,請參閱本文中的插入模擬服務

範例應用程式Post_DeleteMessageHandler_ReturnsRedirectToRoot 測試方法會示範如何使用 WithWebHostBuilder。 此測試會藉由觸發在 SUT 中提交表單,以在資料庫中執行記錄刪除動作。

因為 IndexPageTests 類別中的另一個測試會執行刪除資料庫中所有記錄的作業,並且可能在 Post_DeleteMessageHandler_ReturnsRedirectToRoot 方法之前執行,所以會在此測試方法中重新植入資料庫,以確保存在可供 SUT 刪除的記錄。 選取 SUT 中 messages 表單的第一個刪除按鈕,會在對 SUT 的要求中模擬:

[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
        });
    }

注意: 若要避免在使用 HTTPS 重新導向中介軟體時記錄中出現 HTTPS 重新導向警告,請設定 BaseAddress = new Uri("https://localhost")

注入模擬服務

可以透過呼叫主機建置器上的 ConfigureTestServices 在測試中覆寫服務。 若要將覆寫的服務範圍設定為測試本身,WithWebHostBuilder 方法可用來擷取主機建立器。 這項設定如下列測試所示:

範例 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 測試中,WebApplicationFactoryClientOptions 會設為不允許重新導向 (方法是將 AllowAutoRedirect 設為 false):

[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.Redirect 結果來進行檢查,而不是重新導向到登入頁面之後的最終狀態碼 (也就是 HttpStatusCode.OK)。
  • 會檢查回應標頭中的 Location 標頭值,以確認它以 http://localhost/Identity/Account/Login 開頭,而不是最終登入頁面回應 (其中 Location 標頭不會出現)。

測試應用程式可以在 ConfigureTestServices 中模擬 AuthenticationHandler<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);
    }
}

當驗證配置設定為 TestScheme (其中會為 ConfigureTestServices 註冊 AddAuthentication) 時,會呼叫 TestAuthHandler 來驗證使用者。 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 存放庫。 它包含測試案例特定的測試伺服器

設定環境

在自訂應用程式 Factory 中設定環境

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 建構函式會透過在包含整合測試的組件上搜尋 WebApplicationFactoryContentRootAttribute (其索引鍵等於 TEntryPoint 組件 System.Reflection.Assembly.FullName) 來推斷應用程式內容根路徑。 如果找不到具有正確索引鍵的屬性,WebApplicationFactory 將轉而搜尋方案檔 (.sln),並將 TEntryPoint 組件名稱附加到方案目錄。 應用程式根目錄 (內容根路徑) 會用來探索檢視和內容檔案。

停用陰影複製

陰影複製會導致測試在與輸出目錄不同的目錄中執行。 如果您的測試依賴載入與 Assembly.Location 相關的檔案且您遇到問題,則您可能必須停用陰影複製。

若要在使用 xUnit 時停用卷影複製,請在測試專案目錄中建立一個 xunit.runner.json 檔案 (使用正確的組態設定):

{
  "shadowCopy": false
}

處置物件

在執行 IClassFixture 實作的測試之後,當 xUnit 處置 WebApplicationFactory 時,TestServerHttpClient 也會被處置。 如果開發人員具現化的物件需要處置,請在 IClassFixture 實作中處置它們。 如需詳細資訊,請參閱實作處置方法

整合測試範例

範例應用程式由兩個應用程式所組成:

App 專案目錄 描述
訊息應用程式 (SUT) src/RazorPagesProject 允許使用者新增、刪除一個、刪除所有訊息及分析訊息。
測試應用程式 tests/RazorPagesProject.Tests 用來對 SUT 進行整合測試。

您可以使用 IDE (例如 Visual Studio) 的內建測試功能來執行測試。 如果使用 Visual Studio Code 或命令列,請在命令提示字元下的 tests/RazorPagesProject.Tests 目錄中執行以下命令:

dotnet test

訊息應用程式 (SUT) 組織

SUT 是一個具有下列特性的 Razor Pages 訊息系統:

  • 應用程式的 [索引] 頁面 (Pages/Index.cshtmlPages/Index.cshtml.cs) 提供了 UI 和頁面模型方法來控制訊息的新增、刪除和分析 (每則訊息的平均字數)。
  • 訊息由 Message 類別 (Data/Message.cs) 來描述,其中包含兩個屬性:Id (索引鍵) 和 Text (訊息)。 Text 屬性為必要屬性,且限制為 200 個字元。
  • 訊息會使用 Entity Framework 的記憶體內部資料庫來儲存†。
  • 應用程式在其資料庫內容類別 AppDbContext (Data/AppDbContext.cs) 中包含資料存取層 (DAL)。
  • 如果應用程式啟動時資料庫是空的,訊息存放區會以三則訊息初始化。
  • 應用程式包含一個只能由已驗證使用者存取的 /SecurePage

†EF 文章 (使用 InMemory 測試) 說明如何使用記憶體內部資料庫搭配 MSTest 進行測試。 本主題使用 xUnit 測試架構。 不同測試架構的測試概念和測試實作彼此相似但並不相同。

雖然應用程式不使用存放庫模式,也不是工作單位 (UoW) 模式的有效範例,但 Razor Pages 支援此類開發模式。 如需詳細資訊,請參閱設計基礎結構持續性層測試控制器邏輯 (範例會實作存放庫模式)。

測試應用程式組織

測試應用程式是 tests/RazorPagesProject.Tests 目錄內的主控台應用程式。

測試應用程式目錄 描述
AuthTests 包含用於動作的測試方法:
  • 由未經驗證的使用者存取安全頁面。
  • 由已驗證的使用者透過模擬 AuthenticationHandler<TOptions> 存取安全頁面。
  • 取得 GitHub 使用者設定檔,並檢查設定檔的使用者登入。
BasicTests 包含用於路由傳送和內容類型的測試方法。
IntegrationTests 包含使用自訂 WebApplicationFactory 類別的索引頁面的整合測試。
Helpers/Utilities
  • Utilities.cs 包含用來以測試資料植入資料庫的 InitializeDbForTests 方法。
  • HtmlHelpers.cs 提供一個方法來傳回 AngleSharp IHtmlDocument,以供測試方法使用。
  • HttpClientExtensions.csSendAsync 提供多載,以將要求提交至 SUT。

測試架構為 xUnit。 整合測試是使用 Microsoft.AspNetCore.TestHost (其中包括 TTestServer) 進行的。 因為 Microsoft.AspNetCore.Mvc.Testing 套件會用來設定測試主機和測試伺服器,所以 TestHostTestServer 套件不需要在測試應用程式的專案檔中直接參考套件,也不需要在測試應用中進行開發人員設定。

在測試執行之前,整合測試在資料庫中通常需要一個小型的資料集。 例如,刪除測試會要求刪除資料庫記錄,因此資料庫至少必須有一筆記錄才能使刪除要求成功。

範例應用程式在 Utilities.cs 中使用三則訊息來植入資料庫 (測試在執行時可以使用這些訊息):

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 一節。

其他資源