ASP.NET Core 中的集成测试
作者:Jos van der Til、Martin Costello 和 Javier Calvarro Nelson。
集成测试可在包含应用支持基础结构(如数据库、文件系统和网络)的级别上确保应用组件功能正常。 ASP.NET Core 通过将单元测试框架与测试 Web 主机和内存中测试服务器结合使用来支持集成测试。
本文假定读者对单元测试有基本了解。 如果不熟悉测试概念,请参阅 .NET Core 和 .NET Standard 中的单元测试文章及其链接内容。
示例应用是 Razor Pages 应用,假设读者基本了解 Razor Pages。 如果不熟悉 Razor Pages,请参阅以下文章:
对于 SPA 的测试,我们建议使用能够自动化浏览器的工具,例如 Playwright for .NET。
集成测试简介
与单元测试相比,集成测试可在更广泛的级别上评估应用的组件。 单元测试用于测试独立软件组件,如单独的类方法。 集成测试确认两个或更多应用组件一起工作以生成预期结果,可能包括完整处理请求所需的每个组件。
这些更广泛的测试用于测试应用的基础结构和整个框架,通常包括以下组件:
- 数据库
- 文件系统
- 网络设备
- 请求-响应管道
单元测试使用被称为“虚假对象”或“模拟对象”的模拟组件来代替基础结构组件。
与单元测试相比,集成测试:
- 使用应用在生产环境中使用的实际组件。
- 需要进行更多代码和数据处理。
- 需要更长时间来运行。
因此,将集成测试的使用限制为最重要的基础结构方案。 如果可以使用单元测试或集成测试来测试行为,请选择单元测试。
在集成测试的讨论中,测试的项目经常称为“测试中的系统”,简称“SUT”。 本文中使用“SUT”来指代要测试的 ASP.NET Core 应用。
不要为通过数据库和文件系统进行的数据和文件访问的每种可能组合编写集成测试。 无论应用中有多少位置与数据库和文件系统交互,一组集中式读取、写入、更新和删除集成测试通常能够充分测试数据库和文件系统组件。 将单元测试用于与这些组件交互的方法逻辑的例程测试。 在单元测试中,使用基础设施的捏造(fake)或模拟(mock)可以更快地执行测试。
ASP.NET Core 集成测试
ASP.NET Core 中的集成测试需要以下内容:
- 测试项目用于包含和执行测试。 测试项目具有对 SUT 的引用。
- 测试项目为 SUT 创建测试 Web 主机,并使用测试服务器客户端处理 SUT 的请求和响应。
- 测试运行程序用于执行测试并报告测试结果。
集成测试遵循一系列事件序列,包括常规的“准备”、“执行”和“断言”测试步骤。
- 已配置 SUT 的 Web 主机。
- 创建测试服务器客户端以向应用提交请求。
- 执行排列测试步骤:测试应用会准备请求。
- 执行“操作”测试步骤:客户端提交请求并接收响应。
- 执行“断言”测试步骤:根据预期响应验证实际响应为通过或失败。
- 该过程会一直继续,直到执行了所有测试。
- 报告测试结果。
通常,测试 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 控制器进行组件集成测试)。
测试应用先决条件
测试项目必须:
- 引用
Microsoft.AspNetCore.Mvc.Testing
包。 - 在项目文件中指定 Web SDK (
<Project Sdk="Microsoft.NET.Sdk.Web">
)。
可以在示例应用中查看这些先决条件。 检查 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
。
测试类实现一个类固定例程接口 (),以指示类包含测试,并在类中的所有测试间提供共享对象实例。
以下测试类 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 主机配置,无需依赖测试类。
从
WebApplicationFactory
继承并重写 ConfigureWebHost。 IWebHostBuilder 允许使用IWebHostBuilder.ConfigureServices
配置服务集合public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { var dbContextDescriptor = services.SingleOrDefault( d => d.ServiceType == typeof(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
代码之后执行。 若要将与应用数据库不同的数据库用于测试,必须在builder.ConfigureServices
中替换应用的数据库上下文。示例应用会查找数据库上下文的服务描述符,并使用该描述符删除服务注册。 然后,工厂会添加一个新
ApplicationDbContext
,它使用内存中数据库进行测试。要连接到其他数据库,请更改
DbConnection
。 要使用 SQL Server 的测试数据库,请参考以下步骤:- 在项目文件中引用
Microsoft.EntityFrameworkCore.SqlServer
NuGet 包。 - 调用
UseInMemoryDatabase
。
- 在项目文件中引用
在测试类中使用自定义
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
标头的重定向。典型测试使用
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 请求,测试应用必须:
- 对页面发出请求。
- 分析来自响应的防伪 cookie 和请求验证令牌。
- 发出放置了防伪 cookie 和请求验证令牌的 POST 请求。
SendAsync
中的 Helpers/HttpClientExtensions.cs
帮助程序扩展方法 (GetDocumentAsync
) 和 Helpers/HtmlHelpers.cs
帮助程序方法 () 使用 AngleSharp 分析程序,通过以下方法处理防伪检查:
GetDocumentAsync
:接收 HttpResponseMessage 并返回IHtmlDocument
。GetDocumentAsync
使用一个基于原始HttpResponseMessage
准备虚拟响应的工厂。 有关详细信息,请参阅 AngleSharp 文档。-
HttpClient
的SendAsync
扩展方法组成 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);
}
客户端选项
有关创建 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
});
}
注意:要在使用 HTTPS 重定向中间件时避免日志中出现 HTTPS 重定向警告,请设置 BaseAddress = new Uri("https://localhost")
注入模拟服务
可以通过在主机生成器上调用 ConfigureTestServices,在测试中替代服务。 若要将重写的服务范围限定为测试本身,使用 WithWebHostBuilder 方法来检索主机生成器。 这可在以下测试中看到:
- Get_QuoteService_ProvidesQuoteInPage
- Get_GithubProfilePageCanGetAGithubUser
- Get_SecurePageIsReturnedForAnAuthenticatedUser(获取安全页面以供经过身份验证的用户使用)
示例 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've an appointment in
London, and we'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'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
标头不存在)。
测试应用可以在 AuthenticationHandler<TOptions> 中模拟 ConfigureTestServices,以便测试身份验证和授权的各个方面。 最小方案返回 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(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
构造函数通过在包含集成测试的程序集中搜索键等于 TEntryPoint
程序集 System.Reflection.Assembly.FullName
的 WebApplicationFactoryContentRootAttribute,来推断应用内容根路径。 如果找不到具有正确键的属性,则 WebApplicationFactory
会回退到搜索一个解决方案文件(.sln),并将 TEntryPoint
程序集名称追加到解决方案目录。 应用根目录(内容根路径)用于发现视图和内容文件。
禁用卷影复制
卷影复制会导致在与输出目录不同的目录中执行测试。 如果测试需要加载相对于 Assembly.Location
的文件,而你遇到问题,那么你可能需要禁用卷影复制。
若要在使用 xUnit 时禁用卷影复制,请通过xunit.runner.json
在测试项目目录中创建 文件:
{
"shadowCopy": false
}
物品的处置
在执行 IClassFixture
实现的测试后,当 xUnit 处置 WebApplicationFactory
时,TestServer 和 HttpClient 也会被处置。 如果开发者实例化的对象需要处置,请在 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.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 |
包含针对以下方面的测试方法:
|
BasicTests |
包含用于路由和内容类型的测试方法。 |
IntegrationTests |
包含使用自定义 WebApplicationFactory 类的索引页面的集成测试。 |
Helpers/Utilities |
|
测试框架为 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
代码之后执行。 若要将不同的数据库用于测试,必须在 builder.ConfigureServices
中替换应用的数据库上下文。 有关详细信息,请参阅自定义 WebApplicationFactory章节。
其他资源
本主题假设读者基本了解单元测试。 如果不熟悉测试概念,请参阅 .NET Core 和 .NET Standard 中的单元测试主题及其链接内容。
示例应用是 Razor Pages 应用,假设读者基本了解 Razor Pages。 如果不熟悉 Razor Pages,请参阅以下主题:
注意
对于测试 SPA,建议使用可自动执行浏览器的工具,例如 Playwright for .NET。
集成测试简介
与单元测试相比,集成测试可在更广泛的级别上评估应用的组件。 单元测试用于测试独立软件组件,如单独的类方法。 集成测试确认两个或更多应用组件一起工作以生成预期结果,可能包括完整处理请求所需的每个组件。
这些更广泛的测试用于测试应用的基础结构和整个框架,通常包括以下组件:
- 数据库
- 文件系统
- 网络设备
- 请求-响应管道
单元测试使用伪造对象或模拟对象这些制造组件来替代基础结构组件。
与单元测试相比,集成测试:
- 使用应用在生产环境中使用的实际组件。
- 需要进行更多代码和数据处理。
- 需要更长时间来运行。
因此,将集成测试的使用限制为最重要的基础结构方案。 如果可以使用单元测试或集成测试来测试行为,请选择单元测试。
在集成测试的讨论中,测试的项目经常称为“测试中的系统”,简称“SUT”。 本文中使用“SUT”来指代要测试的 ASP.NET Core 应用。
请勿为通过数据库和文件系统进行的数据和文件访问的每个排列编写集成测试。 无论应用中有多少位置与数据库和文件系统交互,一组集中式读取、写入、更新和删除集成测试通常能够充分测试数据库和文件系统组件。 将单元测试用于与这些组件交互的方法逻辑的例程测试。 在单元测试中,使用基础设施中的伪造对象或模拟对象可以加快测试执行速度。
ASP.NET Core 集成测试
ASP.NET Core 中的集成测试需要以下内容:
- 测试项目用于包含和执行测试。 测试项目具有对 SUT 的引用。
- 测试项目为 SUT 创建测试 Web 主机,并使用测试服务器客户端处理 SUT 的请求和响应。
- 用于执行测试并报告测试结果的程序被称为测试运行程序。
集成测试遵循一系列既定步骤,包括常规的“安排”、“执行”和“断言”测试步骤:
- 已配置 SUT 的 Web 主机。
- 创建测试服务器客户端以向应用提交请求。
- 执行“设置”测试步骤:测试应用会准备一个请求。
- 执行“操作”测试步骤:客户端提交请求并接收响应。
- 执行“断言”测试步骤:实际响应根据预期响应被验证为“通过”或“失败”。
- 该过程会一直继续,直到执行了所有测试。
- 报告测试结果。
通常,测试 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 控制器的组件集成)。
测试应用先决条件
测试项目必须:
- 引用
Microsoft.AspNetCore.Mvc.Testing
包。 - 在项目文件中指定 Web SDK (
<Project Sdk="Microsoft.NET.Sdk.Web">
)。
可以在示例应用中查看这些先决条件。 检查 tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj
文件。 示例应用使用 xUnit 测试框架和 AngleSharp 分析程序库,因此示例应用还引用:
在使用 xunit.runner.visualstudio
版本 2.4.2 或更高版本的应用中,测试项目必须引用 Microsoft.NET.Test.Sdk
包。
还在测试中使用了 Entity Framework Core。 应用引用:
Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore
Microsoft.AspNetCore.Identity.EntityFrameworkCore
Microsoft.EntityFrameworkCore
Microsoft.EntityFrameworkCore.InMemory
Microsoft.EntityFrameworkCore.Tools
SUT 环境
如果未设置 SUT 的环境,则环境会默认为“开发”。
使用默认 WebApplicationFactory 的基本测试
使用 WebApplicationFactory<TEntryPoint> 创建 TestServer 以进行集成测试。 TEntryPoint
是 SUT 的入口点类,通常是 Startup
类。
测试类实现一个类固定例程接口 (),以指示类包含测试,并在类中的所有测试间提供共享对象实例。
以下测试类 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 主机配置。
从
WebApplicationFactory
继承并重写 ConfigureWebHost。 IWebHostBuilder 允许使用 ConfigureServices 配置服务集合:public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup: class { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>)); services.Remove(descriptor); services.AddDbContext<ApplicationDbContext>(options => { options.UseInMemoryDatabase("InMemoryDbForTesting"); }); var sp = services.BuildServiceProvider(); using (var scope = sp.CreateScope()) { var scopedServices = scope.ServiceProvider; var db = scopedServices.GetRequiredService<ApplicationDbContext>(); var logger = scopedServices .GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>(); db.Database.EnsureCreated(); try { Utilities.InitializeDbForTests(db); } catch (Exception ex) { logger.LogError(ex, "An error occurred seeding the " + "database with test messages. Error: {Message}", ex.Message); } } }); } }
示例应用中的数据库种子设定由
InitializeDbForTests
方法执行。 集成测试示例:测试应用组织部分中介绍了该方法。SUT 的数据库上下文在其
Startup.ConfigureServices
方法中注册。 测试应用的builder.ConfigureServices
回调在执行应用的Startup.ConfigureServices
代码之后执行。 随着 ASP.NET Core 3.0 的发布,执行顺序是针对泛型主机的一个重大更改。 若要将与应用数据库不同的数据库用于测试,必须在builder.ConfigureServices
中替换应用的数据库上下文。对于仍使用 Web 主机的 SUT,测试应用的
builder.ConfigureServices
回调先于 SUT 的 代码。 之后执行测试应用的builder.ConfigureTestServices
回调。示例应用会查找数据库上下文的服务描述符,并使用该描述符删除服务注册。 接下来,工厂会添加一个新
ApplicationDbContext
,它使用内存中数据库进行测试。若要连接到与内存中数据库不同的数据库,请更改
UseInMemoryDatabase
调用以将上下文连接到不同数据库。 要使用 SQL Server 测试数据库:- 在项目文件中引用
Microsoft.EntityFrameworkCore.SqlServer
NuGet 包。 - 使用连接到数据库的连接字符串调用
UseSqlServer
。
services.AddDbContext<ApplicationDbContext>((options, context) => { context.UseSqlServer( Configuration.GetConnectionString("TestingDbConnectionString")); });
- 在项目文件中引用
在测试类中使用自定义
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
头信息的第一个响应是重定向。典型测试使用
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 请求,测试应用必须:
- 对页面发出请求。
- 分析来自响应的防伪 cookie 和请求验证令牌。
- 发出放置了防伪 cookie 和请求验证令牌的 POST 请求。
SendAsync
中的 Helpers/HttpClientExtensions.cs
帮助程序扩展方法 (GetDocumentAsync
) 和 Helpers/HtmlHelpers.cs
帮助程序方法 () 使用 AngleSharp 分析程序,通过以下方法处理防伪检查:
GetDocumentAsync
:接收 HttpResponseMessage 并返回IHtmlDocument
。GetDocumentAsync
使用一个基于原始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 应用的集成测试不支持或不需要 AngleSharp。 可以使用其他分析程序,如 Html Agility Pack (HAP)。 另一种方法是编写代码来直接处理防伪系统的请求验证令牌和防伪 cookie。
注意
EF-Core 内存中数据库提供程序可用于有限的基本测试,然而 SQLite 提供程序是内存中测试的推荐选择。
使用 WithWebHostBuilder 自定义客户端
当测试方法中需要其他配置时,WithWebHostBuilder 可创建新 WebApplicationFactory
,其中包含通过配置进一步自定义的 IWebHostBuilder。
Post_DeleteMessageHandler_ReturnsRedirectToRoot
的 测试方法演示了 WithWebHostBuilder
的使用。 通过触发 SUT 中的表单提交,此测试在数据库中执行记录删除。
由于 IndexPageTests
类中的另一个测试会执行删除数据库中所有记录的操作,并且可能在 Post_DeleteMessageHandler_ReturnsRedirectToRoot
方法之前运行,因此数据库会在此测试方法中重新进行种子设定,以确保存在记录供 SUT 删除。 在 SUT 中选择 messages
窗体的第一个删除按钮可在向 SUT 发出的请求中进行模拟:
[Fact]
public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot()
{
// Arrange
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
var scopedServices = scope.ServiceProvider;
var db = scopedServices
.GetRequiredService<ApplicationDbContext>();
var logger = scopedServices
.GetRequiredService<ILogger<IndexPageTests>>();
try
{
Utilities.ReinitializeDbForTests(db);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred seeding " +
"the database with test messages. Error: {Message}",
ex.Message);
}
}
});
})
.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var defaultPage = await client.GetAsync("/");
var content = await HtmlHelpers.GetDocumentAsync(defaultPage);
//Act
var response = await client.SendAsync(
(IHtmlFormElement)content.QuerySelector("form[id='messages']"),
(IHtmlButtonElement)content.QuerySelector("form[id='messages']")
.QuerySelector("div[class='panel-body']")
.QuerySelector("button"));
// Assert
Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal("/", response.Headers.Location.OriginalString);
}
客户端选项
下表显示在创建 WebApplicationFactoryClientOptions 实例时可用的默认 HttpClient
。
选项 | 描述 | 默认 |
---|---|---|
AllowAutoRedirect | 获取或设置 HttpClient 实例是否应自动追随重定向响应。 |
true |
BaseAddress | 获取或设置 HttpClient 实例的基址。 |
http://localhost |
HandleCookies | 获取或设置 HttpClient 实例是否应处理 cookie。 |
true |
MaxAutomaticRedirections | 获取或设置 HttpClient 实例应追随的重定向响应的最大数量。 |
7 |
创建 WebApplicationFactoryClientOptions
类并将它传递给 CreateClient() 方法(默认值显示在代码示例中):
// Default client option values are shown
var clientOptions = new WebApplicationFactoryClientOptions();
clientOptions.AllowAutoRedirect = true;
clientOptions.BaseAddress = new Uri("http://localhost");
clientOptions.HandleCookies = true;
clientOptions.MaxAutomaticRedirections = 7;
_client = _factory.CreateClient(clientOptions);
注入模拟服务
可以通过在主机生成器上调用 ConfigureTestServices,在测试中替代服务。 若要注入模拟服务,SUT 必须具有包含 Startup
方法的 Startup.ConfigureServices
类。
示例 SUT 包含返回引用的作用域服务。 当请求索引页面时,引用会嵌入在索引页面的隐藏字段中。
Services/IQuoteService.cs
:
public interface IQuoteService
{
Task<string> GenerateQuote();
}
Services/QuoteService.cs
:
// Quote ©1975 BBC: The Doctor (Tom Baker); Dr. Who: Planet of Evil
// https://www.bbc.co.uk/programmes/p00pyrx6
public class QuoteService : IQuoteService
{
public Task<string> GenerateQuote()
{
return Task.FromResult<string>(
"Come on, Sarah. We've an appointment in London, " +
"and we're already 30,000 years late.");
}
}
Startup.cs
:
services.AddScoped<IQuoteService, QuoteService>();
Pages/Index.cshtml.cs
:
public class IndexModel : PageModel
{
private readonly ApplicationDbContext _db;
private readonly IQuoteService _quoteService;
public IndexModel(ApplicationDbContext db, IQuoteService quoteService)
{
_db = db;
_quoteService = quoteService;
}
[BindProperty]
public Message Message { get; set; }
public IList<Message> Messages { get; private set; }
[TempData]
public string MessageAnalysisResult { get; set; }
public string Quote { get; private set; }
public async Task OnGetAsync()
{
Messages = await _db.GetMessagesAsync();
Quote = await _quoteService.GenerateQuote();
}
Pages/Index.cs
:
<input id="quote" type="hidden" value="@Model.Quote">
运行 SUT 应用时,会生成以下标记:
<input id="quote" type="hidden" value="Come on, Sarah. We've an appointment in
London, and we'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's interfering with time,
Mr. Scarman, and time is my business.">
模拟身份验证
AuthTests
类中的测试检查安全终结点是否:
- 将未经身份验证的用户重定向到应用的登录页面。
- 为经过身份验证的用户返回内容。
在 SUT 中,/SecurePage
页面使用 AuthorizePage 约定,将 AuthorizeFilter 应用到页面。 有关详细信息,请参阅 Razor 页面授权约定。
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);
}
通过禁止客户端追随重定向,可以执行以下检查:
- 可以将 SUT 返回的状态代码与预期的 HttpStatusCode.Redirect 结果进行比较,而不应与重定向到登录页面后的最终状态代码(即 HttpStatusCode.OK)进行比较。
- 检查响应标头中的
Location
标头值,以确认它以http://localhost/Identity/Account/Login
开头,而不是最终登录页面响应(其中Location
标头不存在)。
测试应用可以在 AuthenticationHandler<TOptions> 中模拟 ConfigureTestServices,以便测试身份验证和授权的各个方面。 最小方案返回 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
环境变量(例如,Staging
、Production
或其他自定义值,例如Testing
)。 - 在测试应用中替代
CreateHostBuilder
,以读取以ASPNETCORE
为前缀的环境变量。
protected override IHostBuilder CreateHostBuilder() =>
base.CreateHostBuilder()
.ConfigureHostConfiguration(
config => config.AddEnvironmentVariables("ASPNETCORE"));
如果 SUT 使用 Web 主机 (IWebHostBuilder
),则替代 CreateWebHostBuilder
:
protected override IWebHostBuilder CreateWebHostBuilder() =>
base.CreateWebHostBuilder().UseEnvironment("Testing");
测试基础结构如何推断应用内容根路径
WebApplicationFactory
构造函数通过在包含集成测试的程序集中搜索键等于 TEntryPoint
程序集 System.Reflection.Assembly.FullName
的 WebApplicationFactoryContentRootAttribute,来推断应用内容根路径。 如果找不到具有正确键的属性,则 WebApplicationFactory
会回退到搜索解决方案文件 (.sln) 并将 TEntryPoint
程序集名称追加到解决方案目录。 应用根目录(内容根路径)用于发现视图和内容文件。
禁用卷影复制
卷影复制会导致在与输出目录不同的目录中执行测试。 如果测试需要加载相对于 Assembly.Location
的文件,而你遇到问题,那么你可能需要禁用卷影复制。
若要在使用 xUnit 时禁用卷影复制,请通过xunit.runner.json
在测试项目目录中创建 文件:
{
"shadowCopy": false
}
物品的处理
执行 IClassFixture
实现的测试之后,当 xUnit 处置 WebApplicationFactory
时,TestServer 和 HttpClient 会被处置。 如果开发者实例化的对象需要处置,请在 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.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 |
包含针对以下方面的测试方法:
|
BasicTests |
包含用于路由和内容类型的测试方法。 |
IntegrationTests |
包含使用自定义 WebApplicationFactory 类的索引页面的集成测试。 |
Helpers/Utilities |
|
测试框架为 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 的数据库上下文在其 Startup.ConfigureServices
方法中注册。 测试应用的 builder.ConfigureServices
回调在执行应用的 Startup.ConfigureServices
代码之后执行。 若要将不同的数据库用于测试,必须在 builder.ConfigureServices
中替换应用的数据库上下文。 有关详细信息,请参阅自定义 WebApplicationFactory 部分。
对于仍使用 Web 主机的 SUT,测试应用的 builder.ConfigureServices
回调先于 SUT 的 代码。 之后执行测试应用的 builder.ConfigureTestServices
回调。
其他资源
本文假设您具备对单元测试的基本了解。 如果不熟悉测试概念,请参阅 .NET Core 和 .NET Standard 中的单元测试文章及其链接内容。
示例应用是一个 Razor Pages 应用,并假定读者具备对 Razor Pages 的基本理解。 如果您对 Razor Pages 不熟悉,请参阅以下文章:
对于测试 SPA,建议使用可自动执行浏览器的工具,例如 Playwright for .NET。
集成测试简介
与单元测试相比,集成测试可在更广泛的级别上评估应用的组件。 单元测试用于测试独立软件组件,如单独的类方法。 集成测试确认两个或更多应用组件一起工作以生成预期结果,可能包括完整处理请求所需的每个组件。
这些更广泛的测试用于测试应用的基础结构和整个框架,通常包括以下组件:
- 数据库
- 文件系统
- 网络设备
- 请求-响应管道
单元测试使用称为 fakes 或 mock 对象的制造组件,而不是基础结构组件。
与单元测试相比,集成测试:
- 使用应用在生产环境中使用的实际组件。
- 需要进行更多代码和数据处理。
- 需要更长时间来运行。
因此,将集成测试的使用限制为最重要的基础结构方案。 如果可以使用单元测试或集成测试来测试行为,请选择单元测试。
在集成测试的讨论中,测试的项目经常称为“测试中的系统”,简称“SUT”。 本文中使用“SUT”来指代要测试的 ASP.NET Core 应用。
请勿为通过数据库和文件系统进行的数据和文件访问的每个排列编写集成测试。 无论应用中有多少位置与数据库和文件系统交互,一组集中式读取、写入、更新和删除集成测试通常能够充分测试数据库和文件系统组件。 将单元测试用于与这些组件交互的方法逻辑的例程测试。 在单元测试中,使用基础设施模拟或仿冒对象可以加快测试执行速度。
ASP.NET Core 集成测试
ASP.NET Core 中的集成测试需要以下内容:
- 测试项目用于包含和执行测试。 测试项目具有对 SUT 的引用。
- 测试项目为 SUT 创建测试 Web 主机,并使用测试服务器客户端处理 SUT 的请求和响应。
- 测试运行程序用于执行测试并报告测试结果。
集成测试遵循一系列事件的顺序,包括常规的 "Arrange"(安排)、"Act"(执行)和 "Assert"(断言)测试步骤:
- SUT 的 Web 主机已配置。
- 创建测试服务器客户端以向应用提交请求。
- 执行排列测试步骤:测试应用会准备请求。
- 执行“操作”测试步骤:客户端提交请求并接收响应。
- 执行“断言”测试步骤:根据预期响应验证实际响应的结果,以判定为通过或失败。
- 该过程会一直继续,直到执行了所有测试。
- 报告测试结果。
通常,测试 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 控制器的组件集成)。
测试应用先决条件
测试项目必须:
- 引用
Microsoft.AspNetCore.Mvc.Testing
包。 - 在项目文件中指定 Web SDK (
<Project Sdk="Microsoft.NET.Sdk.Web">
)。
可以在示例应用中查看这些先决条件。 检查 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
。
测试类实现一个类固定例程接口 (),以指示类包含测试,并在类中的所有测试间提供共享对象实例。
以下测试类 BasicTests
使用 WebApplicationFactory
启动 SUT,并向测试方法 Get_EndpointsReturnSuccessAndCorrectContentType
提供 HttpClient。 该方法验证响应状态代码是否成功(200-299),并且多个应用页面的Content-Type
标头是否为text/html; charset=utf-8
。
CreateClient() 创建一个 HttpClient
实例,该实例会自动跟随重定向并处理 Cookie。
public class BasicTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public BasicTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Theory]
[InlineData("/")]
[InlineData("/Index")]
[InlineData("/About")]
[InlineData("/Privacy")]
[InlineData("/Contact")]
public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode(); // Status Code 200-299
Assert.Equal("text/html; charset=utf-8",
response.Content.Headers.ContentType.ToString());
}
}
默认情况下,当启用一般数据保护条例同意策略时,不会在请求中保留非必要 Cookie。 若要保留非必要 cookie(如 TempData 提供程序使用的 cookie),请在测试中将它们标记为必要。 有关将 cookie 标记为必要的说明,请参阅必要的 Cookie。
AngleSharp 与 Application Parts
的防伪检查对比
本文使用 AngleSharp 分析器通过加载页面和分析 HTML 来处理防伪造检查。 若要在较低级别测试控制器和 Razor Pages 视图的终结点,而不关心它们在浏览器中的呈现方式,请考虑使用 Application Parts
。 应用程序部件方法将控制器或 Razor 页注入到应用中,可用于发出 JSON 请求以获取所需的值。 有关详细信息,请参阅博客使用应用程序部件进行防伪造保护的 ASP.NET Core 资源集成测试和相关的 GitHub 存储库,由Martin Costello撰写。
自定义 WebApplicationFactory
通过从 WebApplicationFactory<TEntryPoint> 来创建一个或多个自定义工厂,可以独立于测试类创建 Web 主机配置:
从
WebApplicationFactory
继承并重写 ConfigureWebHost。 IWebHostBuilder 允许使用IWebHostBuilder.ConfigureServices
配置服务集合public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { var dbContextDescriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>)); services.Remove(dbContextDescriptor); var dbConnectionDescriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbConnection)); services.Remove(dbConnectionDescriptor); // Create open SqliteConnection so EF won't automatically close it. services.AddSingleton<DbConnection>(container => { var connection = new SqliteConnection("DataSource=:memory:"); connection.Open(); return connection; }); services.AddDbContext<ApplicationDbContext>((container, options) => { var connection = container.GetRequiredService<DbConnection>(); options.UseSqlite(connection); }); }); builder.UseEnvironment("Development"); } }
示例应用中的数据库种子设定由
InitializeDbForTests
方法执行。 集成测试示例:测试应用组织部分中介绍了该方法。SUT 的数据库上下文在
Program.cs
中注册。 测试应用的builder.ConfigureServices
回调在执行应用的Program.cs
代码之后执行。 若要将与应用数据库不同的数据库用于测试,必须在builder.ConfigureServices
中替换应用的数据库上下文。示例应用会查找数据库上下文的服务描述符,并使用该描述符删除服务注册。 然后,工厂会添加一个新
ApplicationDbContext
,它使用内存中数据库进行测试。要连接到其他数据库,请更改
DbConnection
。 使用 SQL Server 测试数据库的方法:
- 在项目文件中引用
Microsoft.EntityFrameworkCore.SqlServer
NuGet 包。 - 调用
UseInMemoryDatabase
。
在测试类中使用自定义
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
标头的测试中,第一个响应是重定向。典型测试使用
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 请求,测试应用必须:
- 对页面发出请求。
- 分析来自响应的防伪 cookie 和请求验证令牌。
- 发出放置了防伪 cookie 和请求验证令牌的 POST 请求。
SendAsync
中的 Helpers/HttpClientExtensions.cs
帮助程序扩展方法 (GetDocumentAsync
) 和 Helpers/HtmlHelpers.cs
帮助程序方法 () 使用 AngleSharp 分析程序,通过以下方法处理防伪检查:
GetDocumentAsync
:接收 HttpResponseMessage 并返回IHtmlDocument
。GetDocumentAsync
使用一个基于原始HttpResponseMessage
准备虚拟响应的工厂。 有关详细信息,请参阅 AngleSharp 文档。-
HttpClient
的SendAsync
扩展方法组成 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);
}
客户端选项
有关创建 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
});
}
注意:要在使用 HTTPS 重定向中间件时避免日志中出现 HTTPS 重定向警告,请设置 BaseAddress = new Uri("https://localhost")
注入模拟服务
可以通过在主机生成器上调用 ConfigureTestServices,在测试中替代服务。 若要将重写的服务范围限定为测试本身,使用 WithWebHostBuilder 方法来检索主机生成器。 这可在以下测试中看到:
- Get_QuoteService_ProvidesQuoteInPage
- Get_GithubProfilePageCanGetAGithubUser
- Get_SecurePageIsReturnedForAnAuthenticatedUser
示例 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've an appointment in
London, and we'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'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);
}
通过禁止客户端追随重定向,可以执行以下检查:
- 可以将 SUT 返回的状态代码与预期的 HttpStatusCode.Redirect 结果进行比较,而不是与重定向到登录页面后的最终状态代码 HttpStatusCode.OK 进行比较。
- 检查响应标头中的
Location
标头值,以确认它以http://localhost/Identity/Account/Login
开头,而不是最终登录页面响应(其中Location
标头不存在)。
测试应用可以在 AuthenticationHandler<TOptions> 中模拟 ConfigureTestServices,以便测试身份验证和授权的各个方面。 最小方案返回 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.FullName
的 WebApplicationFactoryContentRootAttribute,来推断应用内容根路径。 在没有找到具有正确键的属性时,WebApplicationFactory
将回退到搜索解决方案文件 (.sln),并将 TEntryPoint
程序集名称追加到解决方案目录。 应用根目录(内容根路径)用于发现视图和内容文件。
禁用卷影复制
卷影复制会导致在与输出目录不同的目录中执行测试。 如果测试需要加载相对于 Assembly.Location
的文件,而你遇到问题,那么你可能需要禁用卷影复制。
若要在使用 xUnit 时禁用卷影复制,请通过xunit.runner.json
在测试项目目录中创建 文件:
{
"shadowCopy": false
}
对象的处置
执行 IClassFixture
实现的测试之后,当 xUnit 处置 WebApplicationFactory
时,TestServer 和 HttpClient 被处置。 如果开发者实例化的对象需要处置,请在 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.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 |
包含针对以下方面的测试方法:
|
BasicTests |
包含用于路由和内容类型的测试方法。 |
IntegrationTests |
包含使用自定义 WebApplicationFactory 类的索引页面的集成测试。 |
Helpers/Utilities |
|
测试框架为 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
代码之后执行。 若要将不同的数据库用于测试,必须在 builder.ConfigureServices
中替换应用的数据库上下文。 有关详细信息,请参阅自定义 WebApplicationFactory 部分。
其他资源
本文假定读者对单元测试有基本了解。 如果不熟悉测试概念,请参阅 .NET Core 和 .NET Standard 中的单元测试文章及其链接内容。
示例应用是一个 Razor Pages 应用程序,并假设用户对 Razor Pages 有基本的了解。 如果您对Razor Pages不熟悉,请参阅以下文章:
对于测试 SPA,建议使用可自动执行浏览器的工具,例如 Playwright for .NET。
集成测试简介
与单元测试相比,集成测试可在更广泛的级别上评估应用的组件。 单元测试用于测试独立软件组件,如单独的类方法。 集成测试确认两个或更多应用组件一起工作以生成预期结果,可能包括完整处理请求所需的每个组件。
这些更广泛的测试用于测试应用的基础结构和整个框架,通常包括以下组件:
- 数据库
- 文件系统
- 网络设备
- 请求-响应管道
单元测试使用称为虚构组件的“仿制件”或“模拟对象”,来替代基础设施组件。
与单元测试相比,集成测试:
- 使用应用在生产环境中使用的实际组件。
- 需要进行更多代码和数据处理。
- 需要更长时间来运行。
因此,将集成测试的使用限制为最重要的基础结构方案。 如果可以使用单元测试或集成测试来测试行为,请选择单元测试。
在集成测试的讨论中,测试的项目经常称为“测试中的系统”,简称“SUT”。 本文中使用“SUT”来指代要测试的 ASP.NET Core 应用。
请勿为通过数据库和文件系统进行的数据和文件访问的每个排列编写集成测试。 无论应用中有多少位置与数据库和文件系统交互,一组集中式读取、写入、更新和删除集成测试通常能够充分测试数据库和文件系统组件。 将单元测试用于与这些组件交互的方法逻辑的例程测试。 在单元测试中,使用基础架构的假对象(fake)或模拟对象(mock)可以加快测试执行速度。
ASP.NET Core 集成测试
ASP.NET Core 中的集成测试需要以下内容:
- 测试项目用于包含和执行测试。 测试项目具有对 SUT 的引用。
- 测试项目为 SUT 创建测试 Web 主机,并使用测试服务器客户端处理 SUT 的请求和响应。
- 测试运行器用于执行测试并报告测试结果。
集成测试遵循一系列事件,包括常规的安排、操作和断言测试步骤:
- SUT 的 Web 主机已配置。
- 创建测试服务器客户端以向应用提交请求。
- 执行排列测试步骤:测试应用会准备请求。
- 执行“操作”测试步骤:客户端提交请求并接收响应。
- 执行断言测试步骤:实际响应将被验证为通过或失败,基于预期响应。
- 该过程会一直继续,直到执行了所有测试。
- 报告测试结果。
通常,测试 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 控制器的组件集成)。
测试应用先决条件
测试项目必须:
- 引用
Microsoft.AspNetCore.Mvc.Testing
包。 - 在项目文件中指定 Web SDK (
<Project Sdk="Microsoft.NET.Sdk.Web">
)。
可以在示例应用中查看这些先决条件。 检查 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
。
测试类实现一个类固定例程接口 (),以指示类包含测试,并在类中的所有测试间提供共享对象实例。
以下测试类 BasicTests
使用 WebApplicationFactory
引导 SUT,并为测试方法 Get_EndpointsReturnSuccessAndCorrectContentType
传递 HttpClient。 该方法验证响应状态代码是否在成功范围 (200-299),以及多个应用页面的 Content-Type
标头是否为 text/html; charset=utf-8
。
CreateClient() 创建一个 HttpClient
实例,它会自动跟随重定向并处理 Cookie。
public class BasicTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public BasicTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Theory]
[InlineData("/")]
[InlineData("/Index")]
[InlineData("/About")]
[InlineData("/Privacy")]
[InlineData("/Contact")]
public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode(); // Status Code 200-299
Assert.Equal("text/html; charset=utf-8",
response.Content.Headers.ContentType.ToString());
}
}
默认情况下,当启用一般数据保护条例同意策略时,不会在请求中保留非必要 Cookie。 若要保留非必要 cookie(如 TempData 提供程序使用的 cookie),请在测试中将它们标记为必要。 有关将 cookie 标记为必要的说明,请参阅必要的 Cookie。
AngleSharp 与 Application Parts
在防伪造检查中的对比分析
本文使用 AngleSharp 分析器通过加载页面和分析 HTML 来处理防伪造检查。 若要在较低级别测试控制器和 Razor Pages 视图的终结点,而不关心它们在浏览器中的呈现方式,请考虑使用 Application Parts
。 应用程序部件方法将控制器或 Razor 页注入到应用中,可用于发出 JSON 请求以获取所需的值。 有关详细信息,请参阅由Martin Costello撰写的博客文章使用应用程序部件对 ASP.NET Core 资源进行反伪造保护的集成测试以及的相关 GitHub 仓库。
自定义 WebApplicationFactory
可以通过从 WebApplicationFactory<TEntryPoint> 继承来创建一个或多个自定义工厂,以便独立于测试类进行 Web 主机配置的创建。
从
WebApplicationFactory
继承并重写 ConfigureWebHost。 IWebHostBuilder 允许使用IWebHostBuilder.ConfigureServices
配置服务集合public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { var dbContextDescriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>)); services.Remove(dbContextDescriptor); var dbConnectionDescriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbConnection)); services.Remove(dbConnectionDescriptor); // Create open SqliteConnection so EF won't automatically close it. services.AddSingleton<DbConnection>(container => { var connection = new SqliteConnection("DataSource=:memory:"); connection.Open(); return connection; }); services.AddDbContext<ApplicationDbContext>((container, options) => { var connection = container.GetRequiredService<DbConnection>(); options.UseSqlite(connection); }); }); builder.UseEnvironment("Development"); } }
示例应用中的数据库种子设定由
InitializeDbForTests
方法执行。 集成测试示例:测试应用组织部分中介绍了该方法。SUT 的数据库上下文在
Program.cs
中注册。 测试应用的builder.ConfigureServices
回调在执行应用的Program.cs
代码之后执行。 若要将与应用数据库不同的数据库用于测试,必须在builder.ConfigureServices
中替换应用的数据库上下文。示例应用会查找数据库上下文的服务描述符,并使用该描述符删除服务注册。 然后,工厂会添加一个新
ApplicationDbContext
,它使用内存中数据库进行测试。要连接到其他数据库,请更改
DbConnection
。 使用 SQL Server 来测试数据库:- 在项目文件中引用
Microsoft.EntityFrameworkCore.SqlServer
NuGet 包。 - 调用
UseInMemoryDatabase
。
- 在项目文件中引用
在测试类中使用自定义
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
标头的测试中,第一个响应是重定向。典型测试使用
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 请求,测试应用必须:
- 对页面发出请求。
- 分析来自响应的防伪 cookie 和请求验证令牌。
- 发出放置了防伪 cookie 和请求验证令牌的 POST 请求。
SendAsync
中的 Helpers/HttpClientExtensions.cs
帮助程序扩展方法 (GetDocumentAsync
) 和 Helpers/HtmlHelpers.cs
帮助程序方法 () 使用 AngleSharp 分析程序,通过以下方法处理防伪检查:
GetDocumentAsync
:接收 HttpResponseMessage 并返回IHtmlDocument
。GetDocumentAsync
使用一个基于原始HttpResponseMessage
准备虚拟响应的工厂。 有关详细信息,请参阅 AngleSharp 文档。-
HttpClient
的SendAsync
扩展方法组成 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);
}
客户端选项
有关创建 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
});
}
}
注意:要在使用 HTTPS 重定向中间件时避免日志中出现 HTTPS 重定向警告,请设置 BaseAddress = new Uri("https://localhost")
注入模拟服务
可以通过在主机生成器上调用 ConfigureTestServices,在测试中替代服务。 若要将重写的服务范围限定为测试本身,使用 WithWebHostBuilder 方法来检索主机生成器。 这可在以下测试中看到:
- Get_QuoteService_ProvidesQuoteInPage
- Get_GithubProfilePageCanGetAGithubUser
- Get_SecurePageIsReturnedForAnAuthenticatedUser
示例 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've an appointment in
London, and we'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's interfering with time,
Mr. Scarman, and time is my business.">
模拟身份验证
AuthTests
类中的测试检查安全终结点是否:
- 将未经身份验证的用户重定向到应用的登录页面。
- 为经过身份验证的用户返回内容。
在 SUT 中,/SecurePage
页面使用 AuthorizePage 约定,将 AuthorizeFilter 应用到页面。 有关详细信息,请参阅 Razor 页面授权约定。
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
标头不存在)。
测试应用可以在 AuthenticationHandler<TOptions> 中模拟 ConfigureTestServices,以便测试身份验证和授权的各个方面。 最小方案返回 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
且 AddAuthentication
注册于 ConfigureTestServices
的情况下,会调用 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.FullName
的 WebApplicationFactoryContentRootAttribute,来推断应用内容根路径。 如果找不到具有正确键的属性,则 WebApplicationFactory
会回退到搜索解决方案文件 (.sln),然后将 TEntryPoint
程序集名称追加到解决方案目录。 应用根目录(内容根路径)用于发现视图和内容文件。
禁用卷影复制
卷影复制会导致在与输出目录不同的目录中执行测试。 如果测试需要加载相对于 Assembly.Location
的文件,而你遇到问题,那么你可能需要禁用卷影复制。
若要在使用 xUnit 时禁用卷影复制,请通过xunit.runner.json
在测试项目目录中创建 文件:
{
"shadowCopy": false
}
物品的处置
执行 IClassFixture
实现的测试之后,当 xUnit 处置 WebApplicationFactory
时,TestServer 和 HttpClient 会被处置。 如果开发者实例化的对象需要处置,请在 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.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 |
包含针对以下方面的测试方法:
|
BasicTests |
包含用于路由和内容类型的测试方法。 |
IntegrationTests |
包含使用自定义 WebApplicationFactory 类的索引页面的集成测试。 |
Helpers/Utilities |
|
测试框架为 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
代码执行完毕后被执行的。 若要将不同的数据库用于测试,必须在 builder.ConfigureServices
中替换应用的数据库上下文。 有关详细信息,请参阅自定义 WebApplicationFactory 部分。