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

集成测试简介

单元测试相比,集成测试可在更广泛的级别上评估应用的组件。 单元测试用于测试独立软件组件,如单独的类方法。 集成测试确认两个或更多应用组件一起工作以生成预期结果,可能包括完整处理请求所需的每个组件。

这些更广泛的测试用于测试应用的基础结构和整个框架,通常包括以下组件:

  • 数据库
  • 文件系统
  • 网络设备
  • 请求-响应管道

单元测试使用称为 fake 或 mock 对象的制造组件,而不是基础结构组件。

与单元测试相比,集成测试:

  • 使用应用在生产环境中使用的实际组件。
  • 需要进行更多代码和数据处理。
  • 需要更长时间来运行。

因此,将集成测试的使用限制为最重要的基础结构方案。 如果可以使用单元测试或集成测试来测试行为,请选择单元测试。

在集成测试的讨论中,测试的项目经常称为“测试中的系统”,简称“SUT”。 本文中使用“SUT”来指代要测试的 ASP.NET Core 应用。

请勿为通过数据库和文件系统进行的数据和文件访问的每个排列编写集成测试。 无论应用中有多少位置与数据库和文件系统交互,一组集中式读取、写入、更新和删除集成测试通常能够充分测试数据库和文件系统组件。 将单元测试用于与这些组件交互的方法逻辑的例程测试。 在单元测试中,使用基础结构 fake 或 mock 会导致更快地执行测试。

ASP.NET Core 集成测试

ASP.NET Core 中的集成测试需要以下内容:

  • 测试项目用于包含和执行测试。 测试项目具有对 SUT 的引用。
  • 测试项目为 SUT 创建测试 Web 主机,并使用测试服务器客户端处理 SUT 的请求和响应。
  • 测试运行程序用于执行测试并报告测试结果。

集成测试后跟一系列事件,包括常规“排列”、“操作”和“断言”测试步骤:

  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 类,以简化 SUT 在 TestServer 中的启动过程。

单元测试文档介绍如何设置测试项目和测试运行程序,以及有关如何运行测试的详细说明与有关如何命名测试和测试类的建议。

将单元测试与集成测试分隔到不同的项目中。 分隔测试:

  • 有助于确保不会意外地将基础结构测试组件包含在单元测试中。
  • 允许控制运行的测试集。

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> 创建 TestServer 以进行集成测试。 TEntryPoint 是 SUT 的入口点类,通常是 Program.cs

测试类实现一个类固定例程接口 (IClassFixture),以指示类包含测试,并在类中的所有测试间提供共享对象实例。

以下测试类 BasicTests 使用 WebApplicationFactory 启动 SUT,并向测试方法 Get_EndpointsReturnSuccessAndCorrectContentType 提供 HttpClient。 该方法验证响应状态代码是否成功 (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。 若要保留非必要 cookie(如 TempData 提供程序使用的 cookie),请在测试中将它们标记为必要。 有关将 cookie 标记为必要的说明,请参阅必要的 Cookie

AngleSharp 与 Application Parts 用于防伪造检查

本文使用 AngleSharp 分析器通过加载页面和分析 HTML 来处理防伪造检查。 若要在较低级别测试控制器和 Razor Pages 视图的终结点,而不关心它们在浏览器中的呈现方式,请考虑使用 Application Parts应用程序部件方法将控制器或 Razor 页注入到应用中,可用于发出 JSON 请求以获取所需的值。 有关详细信息,请参阅博客文章集成测试 ASP.NET Core 资源使用应用程序部件进行防伪造保护关联的 GitHub 存储库,作者 Martin Costello

自定义 WebApplicationFactory

通过从 WebApplicationFactory<TEntryPoint> 来创建一个或多个自定义工厂,可以独立于测试类创建 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 中替换应用的数据库上下文。

    示例应用会查找数据库上下文的服务描述符,并使用该描述符删除服务注册。 然后,工厂会添加一个新 ApplicationDbContext,它使用内存中数据库进行测试。

    要连接到其他数据库,请更改 DbConnection。 使用 SQL Server 测试数据库:

  1. 在测试类中使用自定义 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 标头的测试中进行重定向。

  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 准备虚拟响应的工厂。 有关详细信息,请参阅 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 提供程序是内存中测试的推荐选择。

请参阅使用 Startup 筛选器扩展 Startup,其中显示了如何使用 IStartupFilter 配置中间件,这在测试需要自定义服务或中间件时非常有用。

使用 WithWebHostBuilder 自定义客户端

当测试方法中需要其他配置时,WithWebHostBuilder 可创建新 WebApplicationFactory,其中包含通过配置进一步自定义的 IWebHostBuilder

示例代码会调用 WithWebHostBuilder 将配置的服务替换为测试存根。 有关详细信息和示例用法,请参阅本文中的 Inject mock 服务

示例应用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 测试中,通过将 AllowAutoRedirect 设置为 false,将 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);
}

通过禁止客户端追随重定向,可以执行以下检查:

  • 可以根据预期 HttpStatusCode.Redirect 结果检查 SUT 返回的状态代码,而不是在重定向到登录页面之后的最终状态代码(这会是 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 存储库。 它包含特定于测试方案的测试服务器

设置环境

在自定义应用程序工厂中设置环境

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.FullNameWebApplicationFactoryContentRootAttribute,来推断应用内容根路径。 如果找不到具有正确键的属性,则 WebApplicationFactory 会回退到搜索解决方案文件 (.sln) 并将 TEntryPoint 程序集名称追加到解决方案目录。 应用根目录(内容根路径)用于发现视图和内容文件。

禁用卷影复制

卷影复制会导致在与输出目录不同的目录中执行测试。 如果测试需要加载相对于 Assembly.Location 的文件,而你遇到问题,那么你可能需要禁用卷影复制。

若要在使用 xUnit 时禁用卷影复制,请通过正确的配置设置在测试项目目录中创建 xunit.runner.json 文件:

{
  "shadowCopy": false
}

对象的处置

执行 IClassFixture 实现的测试之后,当 xUnit 处置 时,TestServerWebApplicationFactoryHttpClient 会进行处置。 如果开发者实例化的对象需要处置,请在 IClassFixture 实现中处置它们。 有关详细信息,请参阅实现 Dispose 方法

集成测试示例

示例应用包含两个应用:

应用 项目目录 描述
消息应用 (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 个字符。
  • 消息使用实体框架的内存中数据库†存储。
  • 应用在其数据库上下文类 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(包含 TestServer)进行集成测试。 由于 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

集成测试简介

单元测试相比,集成测试可在更广泛的级别上评估应用的组件。 单元测试用于测试独立软件组件,如单独的类方法。 集成测试确认两个或更多应用组件一起工作以生成预期结果,可能包括完整处理请求所需的每个组件。

这些更广泛的测试用于测试应用的基础结构和整个框架,通常包括以下组件:

  • 数据库
  • 文件系统
  • 网络设备
  • 请求-响应管道

单元测试使用称为 fake 或 mock 对象的制造组件,而不是基础结构组件。

与单元测试相比,集成测试:

  • 使用应用在生产环境中使用的实际组件。
  • 需要进行更多代码和数据处理。
  • 需要更长时间来运行。

因此,将集成测试的使用限制为最重要的基础结构方案。 如果可以使用单元测试或集成测试来测试行为,请选择单元测试。

在集成测试的讨论中,测试的项目经常称为“测试中的系统”,简称“SUT”。 本文中使用“SUT”来指代要测试的 ASP.NET Core 应用。

请勿为通过数据库和文件系统进行的数据和文件访问的每个排列编写集成测试。 无论应用中有多少位置与数据库和文件系统交互,一组集中式读取、写入、更新和删除集成测试通常能够充分测试数据库和文件系统组件。 将单元测试用于与这些组件交互的方法逻辑的例程测试。 在单元测试中,使用基础结构 fake 或 mock 会导致更快地执行测试。

ASP.NET Core 集成测试

ASP.NET Core 中的集成测试需要以下内容:

  • 测试项目用于包含和执行测试。 测试项目具有对 SUT 的引用。
  • 测试项目为 SUT 创建测试 Web 主机,并使用测试服务器客户端处理 SUT 的请求和响应。
  • 测试运行程序用于执行测试并报告测试结果。

集成测试后跟一系列事件,包括常规“排列”、“操作”和“断言”测试步骤:

  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 类,以简化 SUT 在 TestServer 中的启动过程。

单元测试文档介绍如何设置测试项目和测试运行程序,以及有关如何运行测试的详细说明与有关如何命名测试和测试类的建议。

将单元测试与集成测试分隔到不同的项目中。 分隔测试:

  • 有助于确保不会意外地将基础结构测试组件包含在单元测试中。
  • 允许控制运行的测试集。

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> 创建 TestServer 以进行集成测试。 TEntryPoint 是 SUT 的入口点类,通常是 Startup 类。

测试类实现一个类固定例程接口 (IClassFixture),以指示类包含测试,并在类中的所有测试间提供共享对象实例。

以下测试类 BasicTests 使用 WebApplicationFactory 启动 SUT,并向测试方法 Get_EndpointsReturnSuccessAndCorrectContentType 提供 HttpClient。 该方法检查响应状态代码是否成功(处于范围 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。 若要保留非必要 cookie(如 TempData 提供程序使用的 cookie),请在测试中将它们标记为必要。 有关将 cookie 标记为必要的说明,请参阅必要的 Cookie

自定义 WebApplicationFactory

通过从 WebApplicationFactory 来创建一个或多个自定义工厂,可以独立于测试类创建 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 回调。

    示例应用会查找数据库上下文的服务描述符,并使用该描述符删除服务注册。 接下来,工厂会添加一个新 ApplicationDbContext,它使用内存中数据库进行测试。

    若要连接到与内存中数据库不同的数据库,请更改 UseInMemoryDatabase 调用以将上下文连接到不同数据库。 使用 SQL Server 测试数据库:

    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 并返回 IHtmlDocumentGetDocumentAsync 使用一个基于原始 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)。 另一种方法是编写代码来直接处理防伪系统的请求验证令牌和防伪 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);
}

客户端选项

下表显示在创建 HttpClient 实例时可用的默认 WebApplicationFactoryClientOptions

选项 说明 默认
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 测试中,通过将 AllowAutoRedirect 设置为 false,将 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);
}

通过禁止客户端追随重定向,可以执行以下检查:

  • 可以根据预期 HttpStatusCode.Redirect 结果检查 SUT 返回的状态代码,而不是在重定向到登录页面之后的最终状态代码(这会是 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 构造函数通过在包含集成测试的程序集中搜索键等于 TEntryPoint 程序集 System.Reflection.Assembly.FullNameWebApplicationFactoryContentRootAttribute,来推断应用内容根路径。 如果找不到具有正确键的属性,则 WebApplicationFactory 会回退到搜索解决方案文件 (.sln) 并将 TEntryPoint 程序集名称追加到解决方案目录。 应用根目录(内容根路径)用于发现视图和内容文件。

禁用卷影复制

卷影复制会导致在与输出目录不同的目录中执行测试。 如果测试需要加载相对于 Assembly.Location 的文件,而你遇到问题,那么你可能需要禁用卷影复制。

若要在使用 xUnit 时禁用卷影复制,请通过正确的配置设置在测试项目目录中创建 xunit.runner.json 文件:

{
  "shadowCopy": false
}

对象的处置

执行 IClassFixture 实现的测试之后,当 xUnit 处置 时,TestServerWebApplicationFactoryHttpClient 会进行处置。 如果开发者实例化的对象需要处置,请在 IClassFixture 实现中处置它们。 有关详细信息,请参阅实现 Dispose 方法

集成测试示例

示例应用包含两个应用:

应用 项目目录 描述
消息应用 (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 个字符。
  • 消息使用实体框架的内存中数据库†存储。
  • 应用在其数据库上下文类 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(包含 TestServer)进行集成测试。 由于 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

集成测试简介

单元测试相比,集成测试可在更广泛的级别上评估应用的组件。 单元测试用于测试独立软件组件,如单独的类方法。 集成测试确认两个或更多应用组件一起工作以生成预期结果,可能包括完整处理请求所需的每个组件。

这些更广泛的测试用于测试应用的基础结构和整个框架,通常包括以下组件:

  • 数据库
  • 文件系统
  • 网络设备
  • 请求-响应管道

单元测试使用称为 fake 或 mock 对象的制造组件,而不是基础结构组件。

与单元测试相比,集成测试:

  • 使用应用在生产环境中使用的实际组件。
  • 需要进行更多代码和数据处理。
  • 需要更长时间来运行。

因此,将集成测试的使用限制为最重要的基础结构方案。 如果可以使用单元测试或集成测试来测试行为,请选择单元测试。

在集成测试的讨论中,测试的项目经常称为“测试中的系统”,简称“SUT”。 本文中使用“SUT”来指代要测试的 ASP.NET Core 应用。

请勿为通过数据库和文件系统进行的数据和文件访问的每个排列编写集成测试。 无论应用中有多少位置与数据库和文件系统交互,一组集中式读取、写入、更新和删除集成测试通常能够充分测试数据库和文件系统组件。 将单元测试用于与这些组件交互的方法逻辑的例程测试。 在单元测试中,使用基础结构 fake 或 mock 会导致更快地执行测试。

ASP.NET Core 集成测试

ASP.NET Core 中的集成测试需要以下内容:

  • 测试项目用于包含和执行测试。 测试项目具有对 SUT 的引用。
  • 测试项目为 SUT 创建测试 Web 主机,并使用测试服务器客户端处理 SUT 的请求和响应。
  • 测试运行程序用于执行测试并报告测试结果。

集成测试后跟一系列事件,包括常规“排列”、“操作”和“断言”测试步骤:

  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 类,以简化 SUT 在 TestServer 中的启动过程。

单元测试文档介绍如何设置测试项目和测试运行程序,以及有关如何运行测试的详细说明与有关如何命名测试和测试类的建议。

将单元测试与集成测试分隔到不同的项目中。 分隔测试:

  • 有助于确保不会意外地将基础结构测试组件包含在单元测试中。
  • 允许控制运行的测试集。

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> 创建 TestServer 以进行集成测试。 TEntryPoint 是 SUT 的入口点类,通常是 Program.cs

测试类实现一个类固定例程接口 (IClassFixture),以指示类包含测试,并在类中的所有测试间提供共享对象实例。

以下测试类 BasicTests 使用 WebApplicationFactory 启动 SUT,并向测试方法 Get_EndpointsReturnSuccessAndCorrectContentType 提供 HttpClient。 该方法验证响应状态代码是否成功 (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。 若要保留非必要 cookie(如 TempData 提供程序使用的 cookie),请在测试中将它们标记为必要。 有关将 cookie 标记为必要的说明,请参阅必要的 Cookie

AngleSharp 与 Application Parts 用于防伪造检查

本文使用 AngleSharp 分析器通过加载页面和分析 HTML 来处理防伪造检查。 若要在较低级别测试控制器和 Razor Pages 视图的终结点,而不关心它们在浏览器中的呈现方式,请考虑使用 Application Parts应用程序部件方法将控制器或 Razor 页注入到应用中,可用于发出 JSON 请求以获取所需的值。 有关详细信息,请参阅博客文章集成测试 ASP.NET Core 资源使用应用程序部件进行防伪造保护关联的 GitHub 存储库,作者 Martin Costello

自定义 WebApplicationFactory

通过从 WebApplicationFactory<TEntryPoint> 来创建一个或多个自定义工厂,可以独立于测试类创建 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 中替换应用的数据库上下文。

    示例应用会查找数据库上下文的服务描述符,并使用该描述符删除服务注册。 然后,工厂会添加一个新 ApplicationDbContext,它使用内存中数据库进行测试。

    要连接到其他数据库,请更改 DbConnection。 使用 SQL Server 测试数据库:

  1. 在测试类中使用自定义 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 标头的测试中进行重定向。

  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 准备虚拟响应的工厂。 有关详细信息,请参阅 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 提供程序是内存中测试的推荐选择。

请参阅使用 Startup 筛选器扩展 Startup,其中显示了如何使用 IStartupFilter 配置中间件,这在测试需要自定义服务或中间件时非常有用。

使用 WithWebHostBuilder 自定义客户端

当测试方法中需要其他配置时,WithWebHostBuilder 可创建新 WebApplicationFactory,其中包含通过配置进一步自定义的 IWebHostBuilder

示例代码会调用 WithWebHostBuilder 将配置的服务替换为测试存根。 有关详细信息和示例用法,请参阅本文中的 Inject mock 服务

示例应用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 测试中,通过将 AllowAutoRedirect 设置为 false,将 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);
}

通过禁止客户端追随重定向,可以执行以下检查:

  • 可以根据预期 HttpStatusCode.Redirect 结果检查 SUT 返回的状态代码,而不是在重定向到登录页面之后的最终状态代码(这会是 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 存储库。 它包含特定于测试方案的测试服务器

设置环境

在自定义应用程序工厂中设置环境

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.FullNameWebApplicationFactoryContentRootAttribute,来推断应用内容根路径。 如果找不到具有正确键的属性,则 WebApplicationFactory 会回退到搜索解决方案文件 (.sln) 并将 TEntryPoint 程序集名称追加到解决方案目录。 应用根目录(内容根路径)用于发现视图和内容文件。

禁用卷影复制

卷影复制会导致在与输出目录不同的目录中执行测试。 如果测试需要加载相对于 Assembly.Location 的文件,而你遇到问题,那么你可能需要禁用卷影复制。

若要在使用 xUnit 时禁用卷影复制,请通过正确的配置设置在测试项目目录中创建 xunit.runner.json 文件:

{
  "shadowCopy": false
}

对象的处置

执行 IClassFixture 实现的测试之后,当 xUnit 处置 时,TestServerWebApplicationFactoryHttpClient 会进行处置。 如果开发者实例化的对象需要处置,请在 IClassFixture 实现中处置它们。 有关详细信息,请参阅实现 Dispose 方法

集成测试示例

示例应用包含两个应用:

应用 项目目录 描述
消息应用 (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 个字符。
  • 消息使用实体框架的内存中数据库†存储。
  • 应用在其数据库上下文类 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(包含 TestServer)进行集成测试。 由于 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 部分。

其他资源