ASP.NET Core 中的集成测试(.NET 10)

ASP.NET Core 中的集成测试(.NET 10)

集成测试可在包含应用支持基础结构(如数据库、文件系统和网络)的级别上确保应用组件功能正常。 ASP.NET Core 通过将单元测试框架与测试 Web 主机和内存中测试服务器结合使用来支持集成测试。

本文假定读者对单元测试有基本了解。 如果不熟悉测试概念,请参阅 .NET 中的测试 文章及其链接内容。

查看或下载示例代码(如何下载)

示例应用是 Razor Pages 应用,假设读者基本了解 Razor Pages。 如果不熟悉 Razor Pages,请参阅以下文章:

对于 SPA 的测试,我们建议使用能够自动化浏览器的工具,例如 Playwright for .NET。

集成测试简介

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

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

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

单元测试使用 fake(替身)或 mock(模拟对象)代替基础结构组件。

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

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

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

在集成测试讨论中,被测项目通常称为“被测系统”(system under test,SUT)。本文用 SUT 指代要测试的 ASP.NET Core 应用。

不要为通过数据库和文件系统进行的数据和文件访问的每种可能组合编写集成测试。 无论应用中有多少位置与数据库和文件系统交互,一组集中式读取、写入、更新和删除集成测试通常能够充分测试数据库和文件系统组件。 将单元测试用于与这些组件交互的方法逻辑的例程测试。 在单元测试中,使用基础设施的捏造(fake)或模拟(mock)可以更快地执行测试。

ASP.NET Core 集成测试

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

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

集成测试遵循一系列事件序列,包括常规的“准备”、“执行”和“断言”测试步骤。

  1. 已配置 SUT 的 Web 主机。
  2. 创建测试服务器客户端以向应用提交请求。
  3. 执行“准备”(Arrange)步骤:测试应用准备请求。
  4. 执行“执行”(Act)步骤:客户端提交请求并接收响应。
  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 环境 ,则环境默认为 Development。

使用默认 WebApplicationFactory 的基本测试

使用 WebApplicationFactory<TEntryPoint> 创建用于集成测试的 TestServer。TEntryPoint 是 SUT 的入口点类,在这里通常是 Program.cs 中的 Program 类。

测试类实现类级固定装置接口 IClassFixture<TFixture>,使类中的所有测试共享一个对象实例。

下面的 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());
    }
}
[TestClass]
public class BasicTests
{
    private static CustomWebApplicationFactory<Program> _factory;

    [ClassInitialize]
    public static void AssemblyInitialize(TestContext _)
    {
        _factory = new CustomWebApplicationFactory<Program>();
    }

    [ClassCleanup(ClassCleanupBehavior.EndOfClass)]
    public static void AssemblyCleanup(TestContext _)
    {
        _factory.Dispose();
    }

    [TestMethod]
    [DataRow("/")]
    [DataRow("/Index")]
    [DataRow("/About")]
    [DataRow("/Privacy")]
    [DataRow("/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.AreEqual("text/html; charset=utf-8",
            response.Content.Headers.ContentType.ToString());
    }
}
public class BasicTests
{
    private CustomWebApplicationFactory<Program>
        _factory;

    [SetUp]
    public void SetUp()
    {
        _factory = new CustomWebApplicationFactory<Program>();
    }

    [TearDown]
    public void TearDown()
    {
        _factory.Dispose();
    }

    [DatapointSource]
    public string[] values = ["/", "/Index", "/About", "/Privacy", "/Contact"];

    [Theory]
    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.That(response.Content.Headers.ContentType.ToString(), Is.EqualTo("text/html; charset=utf-8"));
    }
}

默认情况下,当启用一般数据保护条例同意策略时,不会在请求中保留非必要 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 并重写 ConfigureWebHost,通过 IWebHostBuilder.ConfigureServices 配置服务集合。 (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(IDbContextOptionsConfiguration<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");
        }
    }
    
    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(IDbContextOptionsConfiguration<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");
        }
    }
    
    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(IDbContextOptionsConfiguration<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 代码执行后运行。若测试需要使用不同数据库,必须在该回调中替换应用的数据库上下文。

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

    要连接到其他数据库,请更改 DbConnection。 要使用 SQL Server 的测试数据库,请参考以下步骤:

    • 在项目文件中引用 Microsoft.EntityFrameworkCore.SqlServer NuGet 包。
    • 配置 SQL Server 测试数据库时应调用 UseSqlServer。原中文页面此项写作 UseInMemoryDatabase,它对应 EF Core 内存中提供程序,不适用于 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
            });
        }
    
    [TestClass]
    public class IndexPageTests
    {
        private static HttpClient _client;
        private static CustomWebApplicationFactory<Program>
            _factory;
    
        [ClassInitialize]
        public static void AssemblyInitialize(TestContext _)
        {
            _factory = new CustomWebApplicationFactory<Program>();
            _client = _factory.CreateClient(new WebApplicationFactoryClientOptions
            {
                AllowAutoRedirect = false
            });
        }
    
        [ClassCleanup(ClassCleanupBehavior.EndOfClass)]
        public static void AssemblyCleanup(TestContext _)
        {
            _factory.Dispose();
        }
    
    public class IndexPageTests
    {
    
        private HttpClient _client;
        private CustomWebApplicationFactory<Program>
            _factory;
    
        [SetUp]
        public void SetUp()
        {
            _factory = new CustomWebApplicationFactory<Program>();
            _client = _factory.CreateClient(new WebApplicationFactoryClientOptions
            {
                AllowAutoRedirect = false
            });
        }
    
        [TearDown]
        public void TearDown()
        {
            _factory.Dispose();
            _client.Dispose();
        }
    

    示例应用的客户端配置为阻止 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);
    }
    
    [TestMethod]
    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.AreEqual(HttpStatusCode.OK, defaultPage.StatusCode);
        Assert.AreEqual(HttpStatusCode.Redirect, response.StatusCode);
        Assert.AreEqual("/", response.Headers.Location.OriginalString);
    }
    
    [Test]
    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.That(defaultPage.StatusCode, Is.EqualTo(HttpStatusCode.OK));
        Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Redirect));
        Assert.That(response.Headers.Location.OriginalString, Is.EqualTo("/"));
    }
    

对 SUT 发出的任何 POST 请求都必须满足防伪检查,该检查由应用的数据保护防伪系统自动执行。 若要安排测试的 POST 请求,测试应用必须:

  1. 对页面发出请求。
  2. 分析来自响应的防伪 cookie 和请求验证令牌。
  3. 发出放置了防伪 cookie 和请求验证令牌的 POST 请求。

Helpers/HttpClientExtensions.cs 中的 SendAsync 扩展方法与 Helpers/HtmlHelpers.cs 中的 GetDocumentAsync 方法使用 AngleSharp 解析器处理防伪检查:

  • GetDocumentAsync 接收 HttpResponseMessage 并返回 IHtmlDocument,使用根据原始 HttpResponseMessage 准备的工厂。详情参阅 AngleSharp 文档。
  • SendAsync 的 HttpClient 扩展方法组成 HttpRequestMessage 并调用 SendAsync(HttpRequestMessage) 以提交对 SUT 的请求。
    SendAsync 的重载接受 HTML 窗体 (IHtmlFormElement) 和以下内容:

    • 窗体的“提交”按钮 (IHtmlElement)
    • 表单值集合 (IEnumerable<KeyValuePair<string, string>>)
    • 提交按钮 (IHtmlElement) 和窗体值 (IEnumerable<KeyValuePair<string, string>>)

AngleSharp 是本文和示例应用为演示而使用的第三方解析库,并不是 ASP.NET Core 集成测试的必需组件。也可使用 Html Agility Pack(HAP)等其他解析器,或直接编写代码处理请求验证令牌和防伪 Cookie。相关替代方案见本文的 AngleSharp 与 Application Parts 比较。 (Html Agility Pack (HAP)) (AngleSharp 与 Application Parts 的防伪造检查比较)

EF-Core 内存中数据库提供程序可用于有限的基本测试,然而 SQLite 提供程序是内存中测试的推荐选择。

当测试需要自定义中间件时,可以使用 IStartupFilter。 有关详细信息,请参阅 ASP.NET Core 中的应用启动。

当测试需要自定义服务时,请参阅 “注入模拟服务 ”部分。

使用测试配置自定义 WebApplicationFactory

可通过多种方式,向 WebApplicationFactory 启动的 Web 应用提供测试专用配置。

  1. 使用 ConfigureHostConfiguration:重写 CreateHost 方法并调用 builder.ConfigureHostConfiguration,如下所示:

    private static readonly KeyValuePair<string, string?>[] s_inMemorySettings = new KeyValuePair<string, string?>[]
    {
        new("TestConfigKey", "TestConfigValue"),
    };
    
    protected override IHost CreateHost(IHostBuilder builder)
    {
        builder.ConfigureHostConfiguration(builder =>
        {
            builder.AddInMemoryCollection(s_inMemorySettings);
        });
        return base.CreateHost(builder);
    }
    

    采用这种方法时,会在调用入口点之前先枚举通过 ConfigureHostConfiguration 添加的配置源,并通过 args 将配置传递给入口点。 实际配置源未被保留。

    重要

    确保将 args 传递给 WebApplication.CreateBuilder 调用。 否则,配置将不会生效。

  2. 使用 ConfigureAppConfiguration:重写 ConfigureWebHost 方法并调用 builder.ConfigureAppConfiguration,如下所示:

    private static readonly KeyValuePair<string, string?>[] s_inMemorySettings = new KeyValuePair<string, string?>[]
    {
        new("TestConfigKey", "TestConfigValue"),
    };
    
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        base.ConfigureWebHost(builder);
        builder.ConfigureAppConfiguration(builder =>
        {
            builder.AddInMemoryCollection(s_inMemorySettings);
        });
    }
    

    使用此方法时,会保留通过 ConfigureAppConfiguration 添加的配置源,但仅在生成 Web 应用程序后才可用。 如果你在调用 WebApplicationBuilder.Build 之前尝试访问配置,这些配置将不可用。

  3. 以下方法仅从 .NET 11 预览版 7 开始提供,不适用于本篇所选的 .NET 10:可以重写新的 ConfigureWebApplicationBuilder 方法。原页面在共享版本内容中同时列出了这个示例:

    private static readonly KeyValuePair<string, string?>[] s_inMemorySettings = new KeyValuePair<string, string?>[]
    {
        new("TestConfigKey", "TestConfigValue"),
    };
    
    protected override void ConfigureWebApplicationBuilder(IHostApplicationBuilder hostApplicationBuilder)
    {
        hostApplicationBuilder.Configuration.AddInMemoryCollection(s_inMemorySettings);
        base.ConfigureWebApplicationBuilder(hostApplicationBuilder);
    }
    

    采用这种方法时,通过 ConfigureWebApplicationBuilder 添加的配置源会被保留,并且在 WebApplication.CreateBuilder 返回后立即可用。

使用 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);
}
[TestMethod]
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.AreEqual(HttpStatusCode.OK, defaultPage.StatusCode);
    Assert.AreEqual(HttpStatusCode.Redirect, response.StatusCode);
    Assert.AreEqual("/", response.Headers.Location.OriginalString);
}
[Test]
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.That(defaultPage.StatusCode, Is.EqualTo(HttpStatusCode.OK));
    Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Redirect));
    Assert.That(response.Headers.Location.OriginalString, Is.EqualTo("/"));
}

客户端选项

有关创建 WebApplicationFactoryClientOptions 实例时的默认值和可用选项,请参阅 HttpClient 页面。

创建 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
        });
    }
[TestClass]
public class IndexPageTests
{
    private static HttpClient _client;
    private static CustomWebApplicationFactory<Program>
        _factory;

    [ClassInitialize]
    public static void AssemblyInitialize(TestContext _)
    {
        _factory = new CustomWebApplicationFactory<Program>();
        _client = _factory.CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });
    }

    [ClassCleanup(ClassCleanupBehavior.EndOfClass)]
    public static void AssemblyCleanup(TestContext _)
    {
        _factory.Dispose();
    }
public class IndexPageTests
{

    private HttpClient _client;
    private CustomWebApplicationFactory<Program>
        _factory;

    [SetUp]
    public void SetUp()
    {
        _factory = new CustomWebApplicationFactory<Program>();
        _client = _factory.CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });
    }

    [TearDown]
    public void TearDown()
    {
        _factory.Dispose();
        _client.Dispose();
    }

注意: 若要在使用 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.">

要测试服务及引文的注入,集成测试会用测试应用提供的 TestQuoteService 替换 SUT 的 QuoteService:

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.");
    }
}
// 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.");
    }
}
// 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);
}
[TestMethod]
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.AreEqual("Something's interfering with time, Mr. Scarman, " +
        "and time is my business.", quoteElement.Attributes["value"].Value);
}
[Test]
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.That(quoteElement.Attributes["value"].Value, Is.EqualTo(
        "Something's interfering with time, Mr. Scarman, " +
        "and time is my business."));
}

测试执行时生成的标记会包含 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);
}
[TestMethod]
public async Task Get_SecurePageRedirectsAnUnauthenticatedUser()
{
    // Arrange
    var client = _factory.CreateClient(
        new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });

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

    // Assert
    Assert.AreEqual(HttpStatusCode.Redirect, response.StatusCode);
    StringAssert.StartsWith(response.Headers.Location.OriginalString, "http://localhost/Identity/Account/Login");
}
[Test]
public async Task Get_SecurePageRedirectsAnUnauthenticatedUser()
{
    // Arrange
    var client = _factory.CreateClient(
        new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });

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

    // Assert
    Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Redirect));
    Assert.That(response.Headers.Location.OriginalString, Does.StartWith("http://localhost/Identity/Account/Login"));
}

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

  • 可以根据预期 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);
    }
}
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);
    }
}
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);
    }
}

在 ConfigureTestServices 中通过 AddAuthentication 注册 TestScheme,并为其配置 TestAuthHandler 后,测试请求会由该处理程序验证身份。TestScheme 必须与应用要求的身份验证方案匹配,否则身份验证不会正常工作。

[Fact]
public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                services.AddAuthentication(options =>
                    {
                        options.DefaultAuthenticateScheme = "TestScheme";
                        options.DefaultChallengeScheme = "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);
}
[TestMethod]
public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddAuthentication(options =>
                {
                    options.DefaultAuthenticateScheme = "TestScheme";
                    options.DefaultChallengeScheme = "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.AreEqual(HttpStatusCode.OK, response.StatusCode);
}
[Test]
public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddAuthentication(options =>
                {
                    options.DefaultAuthenticateScheme = "TestScheme";
                    options.DefaultChallengeScheme = "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.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}

有关 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(IDbContextOptionsConfiguration<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");
    }
}
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(IDbContextOptionsConfiguration<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");
    }
}
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(IDbContextOptionsConfiguration<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。若找不到匹配属性,就回退到搜索解决方案文件(.sln),并在解决方案目录后追加 TEntryPoint 程序集名称,以推断内容根路径。这个根路径用于查找视图与内容文件。

禁用卷影复制

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

使用 xUnit 时,可在测试项目目录创建 xunit.runner.json 文件,以禁用卷影复制:

{
  "shadowCopy": false
}

释放对象

使用 IClassFixture 的测试执行完毕后,xUnit 会释放固定装置 WebApplicationFactory,后者会释放其管理的 TestServer 和 HttpClient。自己实例化且需要释放的对象,也应在固定装置中妥善释放。详情见“实现 Dispose 方法”。

在 MSTest 中,测试类的测试执行完毕后,可在 ClassCleanup 方法中释放 WebApplicationFactory 及相关资源。自己创建的可释放对象,也应在相应清理方法中释放。详情见“实现 Dispose 方法”。 (TestServer) (HttpClient)

在 NUnit 中,可在 TearDown 方法中释放 WebApplicationFactory、HttpClient 及自己创建的可释放资源。示例按测试设置和清理资源;详情见“实现 Dispose 方法”。 (TestServer)

集成测试示例

示例应用包含两个应用:

应用 项目目录 说明
消息应用 (SUT) src/RazorPagesProject 允许用户添加消息、删除一个消息、删除所有消息和分析消息。
测试应用 tests/RazorPagesProject.Tests 用于集成测试 SUT。

可使用 IDE 的内置测试功能(例如 Visual Studio)运行测试。 如果使用 Visual Studio Code 或命令行,请在 tests/RazorPagesProject.Tests 目录中的命令提示符处执行以下命令:

dotnet test

消息应用 (SUT) 组织

SUT 是具有以下特征的 Razor Pages 消息系统:

  • 应用的索引页面(Pages/Index.cshtml 和 Pages/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.cs 为 SendAsync 提供重载,以将请求提交到 SUT。

测试框架为 xUnit。 使用 Microsoft.AspNetCore.TestHost(包含 TestServer)进行集成测试。 由于 Microsoft.AspNetCore.Mvc.Testing 包用于配置测试主机和测试服务器,因此 TestHost 和 TestServer 包在测试应用的项目文件或测试应用的开发者配置中不需要直接包引用。

集成测试在执行测试前通常需要数据库中的小型数据集。 例如,删除测试需要进行数据库记录删除,因此数据库必须至少有一个记录,删除请求才能成功。

示例应用使用 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 代码执行后运行。若测试使用另一数据库,就在该回调中替换应用的数据库上下文。详情见“自定义 WebApplicationFactory”。

其他资源

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容