作者: Luke Latham、 Javier Calvarro 使用、 Steve Smith和Jos van der 等到
集成测试可确保应用程序的组件在包含应用程序支持的基础结构的级别(例如数据库、文件系统和网络)正常运行。 ASP.NET Core 支持结合使用单元测试框架和测试 web 主机和内存中测试服务器的集成测试。
本主题假定基本了解单元测试。 如果不熟悉测试概念,请参阅.Net Core 中的单元测试和 .NET Standard主题及其链接的内容。
该示例应用是 Razor Pages 应用程序,并假定基本了解 Razor Pages。 如果不熟悉 Razor Pages,请参阅以下主题:
备注
对于测试 Spa,我们建议使用Selenium这样的工具,它可以自动执行浏览器。
与单元测试相比,集成测试在更广泛的级别上评估应用的组件。 单元测试用于测试独立的软件组件,如单独的类方法。 集成测试确认两个或更多应用程序组件一起工作以生成预期的结果,其中可能包括完全处理请求所需的每个组件。
这些广泛的测试用于测试应用程序的基础结构和整个框架,通常包括以下组件:
单元测试使用称为fakes或mock 对象的制造组件来代替基础结构组件。
与单元测试相比,集成测试:
因此,将集成测试的使用限制为最重要的基础结构方案。 如果可以使用单元测试或集成测试来测试行为,请选择单元测试。
提示
请勿为数据库和文件系统的每个可能的数据排列和文件访问编写集成测试。 无论应用程序中有多少位置与数据库和文件系统交互,一组集中式的读取、写入、更新和删除集成测试通常都能充分测试数据库和文件系统组件。 使用单元测试对与这些组件进行交互的方法逻辑进行例程测试。 在单元测试中,使用基础结构 fakes/模拟会导致更快地执行测试。
备注
在讨论集成测试时,测试的项目经常称为 "测试中的系统" 或简称 "SUT"。
本主题中使用 "SUT" 来引用已测试的 ASP.NET Core 应用。
ASP.NET Core 中的集成测试需要以下各项:
集成测试按一个顺序特定的事件序列发生, 其中包括常见的 Arrange、Act 和 Assert 测试步骤:
通常,测试 web 主机的配置与应用程序用于测试运行的普通 web 主机的配置不同。 例如,可以将不同的数据库或不同的应用设置用于测试。
基础结构组件(例如测试 web 主机和内存中测试服务器(TestServer))由AspNetCore包提供或管理。 使用此包简化了测试的创建和执行。
Microsoft.AspNetCore.Mvc.Testing
包处理以下任务:
TestServer
的 SUT 的引导。单元测试文档介绍了如何设置测试项目和测试运行程序,以及有关如何为测试和测试类命名测试和建议的详细说明。
备注
为应用程序创建测试项目时,请将集成测试中的单元测试分成不同的项目。 这有助于确保不会意外地将基础结构测试组件包含在单元测试中。 单元测试和集成测试的隔离还允许控制运行的测试集。
Razor Pages 应用和 MVC 应用的测试的配置几乎没有任何区别。 唯一的区别在于测试的命名方式。 在 Razor Pages 应用程序中,页终结点的测试通常以页面模型类命名(例如,IndexPageTests
为索引页测试组件集成)。 在 MVC 应用中,通常按控制器类对测试进行组织,并按它们所测试的控制器(例如 HomeControllerTests
来测试主控制器的组件集成)进行命名。
测试项目必须:
<Project Sdk="Microsoft.NET.Sdk.Web">
)。可以在示例应用中查看这些先决条件。 检查 "测试"/"RazorPagesProject"/"RazorPagesProject " 文件。 示例应用使用xUnit测试框架和AngleSharp分析器库,因此示例应用还引用:
还可在测试中使用 Entity Framework Core。 应用引用:
如果未设置 SUT 的环境,环境将默认为 "开发"。
WebApplicationFactory<TEntryPoint >用于创建集成测试的TestServer 。 TEntryPoint
是 SUT (通常是 Startup
类)的入口点类。
测试类实现类装置接口(IClassFixture)以指示类包含测试,并跨类中的测试提供共享对象实例。
下面的测试类 BasicTests
使用 WebApplicationFactory
来启动 SUT,并为测试方法 Get_EndpointsReturnSuccessAndCorrectContentType
提供HttpClient 。 方法将检查响应状态代码是否成功(范围200-299 中的状态代码)和多个应用页面的 Content-Type
标头是否 text/html; charset=utf-8
。
CreateClient创建自动跟随重定向并处理 cookie 的 HttpClient
的实例。
public class BasicTests : IClassFixture<WebApplicationFactory<RazorPagesProject.Startup>> { private readonly WebApplicationFactory<RazorPagesProject.Startup> _factory; public BasicTests(WebApplicationFactory<RazorPagesProject.Startup> factory) { _factory = factory; } [Theory] [InlineData("/")] [InlineData("/Index")] [InlineData("/About")] [InlineData("/Privacy")] [InlineData("/Contact")] public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url) { // Arrange var client = _factory.CreateClient(); // Act var response = await client.GetAsync(url); // Assert response.EnsureSuccessStatusCode(); // Status Code 200-299 Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString()); } }
默认情况下,当启用GDPR 同意策略时,不会跨请求保留非关键 cookie。 若要保留不重要的 cookie (如 TempData 提供程序使用的 cookie),请将它们标记为测试中的重要 cookie。 有关将 cookie 标记为必要的说明,请参阅基本 cookie。
通过继承 WebApplicationFactory
来创建一个或多个自定义工厂,可以独立于测试类创建 Web 主机配置:
从 WebApplicationFactory
继承并重写ConfigureWebHost。 IWebHostBuilder允许通过ConfigureServices配置服务集合:
public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup: class { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { // Remove the app's ApplicationDbContext registration. var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>)); if (descriptor != null) { services.Remove(descriptor); } // Add ApplicationDbContext using an in-memory database for testing. services.AddDbContext<ApplicationDbContext>(options => { options.UseInMemoryDatabase("InMemoryDbForTesting"); }); // Build the service provider. var sp = services.BuildServiceProvider(); // Create a scope to obtain a reference to the database // context (ApplicationDbContext). using (var scope = sp.CreateScope()) { var scopedServices = scope.ServiceProvider; var db = scopedServices.GetRequiredService<ApplicationDbContext>(); var logger = scopedServices .GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>(); // Ensure the database is created. db.Database.EnsureCreated(); try { // Seed the database with test data. Utilities.InitializeDbForTests(db); } catch (Exception ex) { logger.LogError(ex, "An error occurred seeding the " + "database with test messages. Error: {Message}", ex.Message); } } }); } }
示例应用中的数据库种子设定由 InitializeDbForTests
方法执行。 集成测试示例:测试应用组织部分介绍了方法。
在其 Startup.ConfigureServices
方法中注册了 SUT 的数据库上下文。 执行应用的 Startup.ConfigureServices
代码后,将执行测试应用的 builder.ConfigureServices
回调。 对于版本为 ASP.NET Core 3.0 的泛型主机,执行顺序是一项重大更改。 若要将不同于应用程序的数据库用于测试,则必须将应用程序的数据库上下文替换 builder.ConfigureServices
中。
示例应用将查找数据库上下文的服务描述符,并使用描述符来删除服务注册。 接下来,工厂添加一个新的 ApplicationDbContext
,使用内存中数据库进行测试。
若要连接到与内存中数据库不同的数据库,请更改 UseInMemoryDatabase
调用以将上下文连接到其他数据库。 使用 SQL Server 测试数据库:
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
和 helper 方法来处理请求和响应:
[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 请求,测试应用必须:
示例应用中的 SendAsync
helper 扩展方法(Helper /HttpClientExtensions)和 GetDocumentAsync
helper 方法(helper /HtmlHelpers)使用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 灵活性包(HAP)。 另一种方法是编写代码来直接处理防伪系统的请求验证令牌和防伪 cookie。
如果在测试方法中需要其他配置, WithWebHostBuilder会创建一个新的 WebApplicationFactory
,其中包含由配置进一步自定义的IWebHostBuilder 。
示例应用的 Post_DeleteMessageHandler_ReturnsRedirectToRoot
测试方法演示如何使用 WithWebHostBuilder
。 此测试通过在 SUT 中触发窗体提交来在数据库中执行记录删除。
由于 IndexPageTests
类中的另一个测试会执行一个操作,该操作将删除数据库中的所有记录,并且该操作可能在 Post_DeleteMessageHandler_ReturnsRedirectToRoot
方法之前运行,因此,该数据库在此测试方法中种子,以确保要删除的 SUT 存在记录。 选择 SUT 中 messages
窗体的第一个 "删除" 按钮时,会将请求中的内容模拟到 SUT:
[Fact] public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot() { // Arrange var client = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var serviceProvider = services.BuildServiceProvider(); using (var scope = serviceProvider.CreateScope()) { var scopedServices = scope.ServiceProvider; var db = scopedServices .GetRequiredService<ApplicationDbContext>(); var logger = scopedServices .GetRequiredService<ILogger<IndexPageTests>>(); try { Utilities.ReinitializeDbForTests(db); } catch (Exception ex) { logger.LogError(ex, "An error occurred seeding " + "the database with test messages. Error: {Message}", ex.Message); } } }); }) .CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); var defaultPage = await client.GetAsync("/"); var content = await HtmlHelpers.GetDocumentAsync(defaultPage); //Act var response = await client.SendAsync( (IHtmlFormElement)content.QuerySelector("form[id='messages']"), (IHtmlButtonElement)content.QuerySelector("form[id='messages']") .QuerySelector("div[class='panel-body']") .QuerySelector("button")); // Assert Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.Equal("/", response.Headers.Location.OriginalString); }
下表显示了在创建 HttpClient
实例时可用的默认WebApplicationFactoryClientOptions 。
选项 | 描述 | 默认值 |
---|---|---|
AllowAutoRedirect | 获取或设置 HttpClient 实例是否应自动跟随重定向响应。 |
true |
BaseAddress | 获取或设置 HttpClient 实例的基址。 |
http://localhost |
HandleCookies | 获取或设置 HttpClient 实例是否应处理 cookie。 |
true |
MaxAutomaticRedirections | 获取或设置 HttpClient 实例应遵循的重定向响应的最大数目。 |
7 |
创建WebApplicationFactoryClientOptions
类并将其传递给 CreateClient 方法 (在代码示例中显示默认值):
// Default client option values are shown var clientOptions = new WebApplicationFactoryClientOptions(); clientOptions.AllowAutoRedirect = true; clientOptions.BaseAddress = new Uri("http://localhost"); clientOptions.HandleCookies = true; clientOptions.MaxAutomaticRedirections = 7; _client = _factory.CreateClient(clientOptions);
通过在主机生成器上调用ConfigureTestServices ,可以在测试中覆盖服务。 若要注入模拟服务,SUT 必须有一个具有 Startup.ConfigureServices
方法的 Startup
类。
示例 SUT 包含一个返回 quote 的作用域服务。 请求索引页时,引号嵌入到索引页上的隐藏字段中。
服务/IQuoteService:
public interface IQuoteService { Task<string> GenerateQuote(); }
服务/QuoteService:
// 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 Pages 授权约定。
services.AddRazorPages() .AddRazorPagesOptions(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); }
通过禁止客户端按照重定向操作,可以执行以下检查:
Location
标头值,以确认该标头值以 http://localhost/Identity/Account/Login
(而不是最终的登录页响应)开头,而 Location
标头不存在。测试应用可以模拟ConfigureTestServices中的 AuthenticationHandler<TOptions>,以便测试身份验证和授权的各个方面。 最小方案返回AuthenticateResult:
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
来对用户进行身份验证:
[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
的详细信息,请参阅Client options部分。
默认情况下,SUT 的主机和应用环境配置为使用开发环境。 重写 SUT 的环境:
ASPNETCORE_ENVIRONMENT
环境变量(例如,Staging
、Production
或其他自定义值,例如 Testing
)。CreateHostBuilder
,以读取以 ASPNETCORE
为前缀的环境变量。protected override IHostBuilder CreateHostBuilder() => base.CreateHostBuilder() .ConfigureHostConfiguration( config => config.AddEnvironmentVariables("ASPNETCORE"));
WebApplicationFactory
构造函数通过使用等于 TEntryPoint
程序集 System.Reflection.Assembly.FullName
的键搜索包含集成测试的程序集上的WebApplicationFactoryContentRootAttribute来推断应用内容根路径。 如果找不到具有正确键的属性,WebApplicationFactory
将回退到搜索解决方案文件( .sln),并在解决方案目录中追加 TEntryPoint
程序集名称。 应用根目录(内容根路径)用于发现视图和内容文件。
卷影复制会导致在输出目录以外的目录中执行测试。 要使测试正常工作,必须禁用卷影复制。 该示例应用使用 xUnit 并通过包含具有正确配置设置的xUnit文件来禁用 xUnit 的卷影复制。 有关详细信息,请参阅配置 xUnit 与 JSON。
将xunit文件添加到测试项目的根,其中包含以下内容:
{ "shadowCopy": false }
执行 IClassFixture
实现的测试后,当 xUnit 释放WebApplicationFactory时, TestServer和HttpClient会被释放。 如果开发人员实例化的对象需要处置,请在 IClassFixture
实现中释放这些对象。 有关详细信息,请参阅实现 Dispose 方法。
该示例应用由两个应用组成:
应用程序 | 项目目录 | 描述 |
---|---|---|
消息应用(SUT) | src/RazorPagesProject | 允许用户添加、删除一个、删除和分析消息。 |
测试应用 | tests/RazorPagesProject.Tests | 用于集成测试 SUT。 |
可以使用 IDE (如Visual Studio)的内置测试功能来运行测试。 如果使用Visual Studio Code或命令行,请在RazorPagesProject 目录中的命令提示符处执行以下命令:
dotnet test
SUT 是 Razor Pages 的消息系统,具有以下特征:
Message
类(Data/message)描述,具有两个属性: Id
(密钥)和 Text
(message)。 Text
属性是必需的,并且限制为200个字符。AppDbContext
(data/AppDbContext)。/SecurePage
。†EF 主题使用 InMemory 进行测试说明了如何将内存中数据库用于使用 MSTest 进行测试。 本主题使用xUnit测试框架。 不同测试框架中的测试概念和测试实现相似,但并不完全相同。
尽管应用程序不使用存储库模式,并且不是工作单元(UoW)模式的有效示例,但 Razor Pages 支持这些模式的开发模式。 有关详细信息,请参阅设计基础结构持久性层和测试控制器逻辑(示例实现存储库模式)。
测试应用是 "测试"/"RazorPagesProject " 目录中的控制台应用。
测试应用程序目录 | 描述 |
---|---|
AuthTests | 包含的测试方法:
|
BasicTests | 包含路由和内容类型的测试方法。 |
IntegrationTests | 包含使用自定义 WebApplicationFactory 类的索引页的集成测试。 |
帮助程序/实用工具 |
|
测试框架为xUnit。 集成测试是使用 TestHost 的AspNetCore进行的,其中包括TestServer。 由于AspNetCore包用于配置测试主机和测试服务器,因此 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." } }; }
在其 Startup.ConfigureServices
方法中注册了 SUT 的数据库上下文。 执行应用的 Startup.ConfigureServices
代码后,将执行测试应用的 builder.ConfigureServices
回调。 若要为测试使用其他数据库,必须将应用程序的数据库上下文替换 builder.ConfigureServices
中。 有关详细信息,请参阅自定义 WebApplicationFactory部分。
集成测试可确保应用程序的组件在包含应用程序支持的基础结构的级别(例如数据库、文件系统和网络)正常运行。 ASP.NET Core 支持结合使用单元测试框架和测试 web 主机和内存中测试服务器的集成测试。
本主题假定基本了解单元测试。 如果不熟悉测试概念,请参阅.Net Core 中的单元测试和 .NET Standard主题及其链接的内容。
该示例应用是 Razor Pages 应用程序,并假定基本了解 Razor Pages。 如果不熟悉 Razor Pages,请参阅以下主题:
备注
对于测试 Spa,我们建议使用Selenium这样的工具,它可以自动执行浏览器。
与单元测试相比,集成测试在更广泛的级别上评估应用的组件。 单元测试用于测试独立的软件组件,如单独的类方法。 集成测试确认两个或更多应用程序组件一起工作以生成预期的结果,其中可能包括完全处理请求所需的每个组件。
这些广泛的测试用于测试应用程序的基础结构和整个框架,通常包括以下组件:
单元测试使用称为fakes或mock 对象的制造组件来代替基础结构组件。
与单元测试相比,集成测试:
因此,将集成测试的使用限制为最重要的基础结构方案。 如果可以使用单元测试或集成测试来测试行为,请选择单元测试。
提示
请勿为数据库和文件系统的每个可能的数据排列和文件访问编写集成测试。 无论应用程序中有多少位置与数据库和文件系统交互,一组集中式的读取、写入、更新和删除集成测试通常都能充分测试数据库和文件系统组件。 使用单元测试对与这些组件进行交互的方法逻辑进行例程测试。 在单元测试中,使用基础结构 fakes/模拟会导致更快地执行测试。
备注
在讨论集成测试时,测试的项目经常称为 "测试中的系统" 或简称 "SUT"。
本主题中使用 "SUT" 来引用已测试的 ASP.NET Core 应用。
ASP.NET Core 中的集成测试需要以下各项:
集成测试按一个顺序特定的事件序列发生, 其中包括常见的 Arrange、Act 和 Assert 测试步骤:
通常,测试 web 主机的配置与应用程序用于测试运行的普通 web 主机的配置不同。 例如,可以将不同的数据库或不同的应用设置用于测试。
基础结构组件(例如测试 web 主机和内存中测试服务器(TestServer))由AspNetCore包提供或管理。 使用此包简化了测试的创建和执行。
Microsoft.AspNetCore.Mvc.Testing
包处理以下任务:
TestServer
的 SUT 的引导。单元测试文档介绍了如何设置测试项目和测试运行程序,以及有关如何为测试和测试类命名测试和建议的详细说明。
备注
为应用程序创建测试项目时,请将集成测试中的单元测试分成不同的项目。 这有助于确保不会意外地将基础结构测试组件包含在单元测试中。 单元测试和集成测试的隔离还允许控制运行的测试集。
Razor Pages 应用和 MVC 应用的测试的配置几乎没有任何区别。 唯一的区别在于测试的命名方式。 在 Razor Pages 应用程序中,页终结点的测试通常以页面模型类命名(例如,IndexPageTests
为索引页测试组件集成)。 在 MVC 应用中,通常按控制器类对测试进行组织,并按它们所测试的控制器(例如 HomeControllerTests
来测试主控制器的组件集成)进行命名。
测试项目必须:
<Project Sdk="Microsoft.NET.Sdk.Web">
)。 引用AspNetCore 元包时,需要 Web SDK。可以在示例应用中查看这些先决条件。 检查 "测试"/"RazorPagesProject"/"RazorPagesProject " 文件。 示例应用使用xUnit测试框架和AngleSharp分析器库,因此示例应用还引用:
如果未设置 SUT 的环境,环境将默认为 "开发"。
WebApplicationFactory<TEntryPoint >用于创建集成测试的TestServer 。 TEntryPoint
是 SUT (通常是 Startup
类)的入口点类。
测试类实现类装置接口(IClassFixture)以指示类包含测试,并跨类中的测试提供共享对象实例。
下面的测试类 BasicTests
使用 WebApplicationFactory
来启动 SUT,并为测试方法 Get_EndpointsReturnSuccessAndCorrectContentType
提供HttpClient 。 方法将检查响应状态代码是否成功(范围200-299 中的状态代码)和多个应用页面的 Content-Type
标头是否 text/html; charset=utf-8
。
CreateClient创建自动跟随重定向并处理 cookie 的 HttpClient
的实例。
public class BasicTests : IClassFixture<WebApplicationFactory<RazorPagesProject.Startup>> { private readonly WebApplicationFactory<RazorPagesProject.Startup> _factory; public BasicTests(WebApplicationFactory<RazorPagesProject.Startup> factory) { _factory = factory; } [Theory] [InlineData("/")] [InlineData("/Index")] [InlineData("/About")] [InlineData("/Privacy")] [InlineData("/Contact")] public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url) { // Arrange var client = _factory.CreateClient(); // Act var response = await client.GetAsync(url); // Assert response.EnsureSuccessStatusCode(); // Status Code 200-299 Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString()); } }
默认情况下,当启用GDPR 同意策略时,不会跨请求保留非关键 cookie。 若要保留不重要的 cookie (如 TempData 提供程序使用的 cookie),请将它们标记为测试中的重要 cookie。 有关将 cookie 标记为必要的说明,请参阅基本 cookie。
通过继承 WebApplicationFactory
来创建一个或多个自定义工厂,可以独立于测试类创建 Web 主机配置:
从 WebApplicationFactory
继承并重写ConfigureWebHost。 IWebHostBuilder允许通过ConfigureServices配置服务集合:
public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup: class { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { // Create a new service provider. var serviceProvider = new ServiceCollection() .AddEntityFrameworkInMemoryDatabase() .BuildServiceProvider(); // Add a database context (ApplicationDbContext) using an in-memory // database for testing. services.AddDbContext<ApplicationDbContext>(options => { options.UseInMemoryDatabase("InMemoryDbForTesting"); options.UseInternalServiceProvider(serviceProvider); }); // Build the service provider. var sp = services.BuildServiceProvider(); // Create a scope to obtain a reference to the database // context (ApplicationDbContext). using (var scope = sp.CreateScope()) { var scopedServices = scope.ServiceProvider; var db = scopedServices.GetRequiredService<ApplicationDbContext>(); var logger = scopedServices .GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>(); // Ensure the database is created. db.Database.EnsureCreated(); try { // Seed the database with test data. Utilities.InitializeDbForTests(db); } catch (Exception ex) { logger.LogError(ex, "An error occurred seeding the database. Error: {Message}", ex.Message); } } }); } }
示例应用中的数据库种子设定由 InitializeDbForTests
方法执行。 集成测试示例:测试应用组织部分介绍了方法。
使用测试类中的自定义 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
和 helper 方法来处理请求和响应:
[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 请求,测试应用必须:
示例应用中的 SendAsync
helper 扩展方法(Helper /HttpClientExtensions)和 GetDocumentAsync
helper 方法(helper /HtmlHelpers)使用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 灵活性包(HAP)。 另一种方法是编写代码来直接处理防伪系统的请求验证令牌和防伪 cookie。
如果在测试方法中需要其他配置, WithWebHostBuilder会创建一个新的 WebApplicationFactory
,其中包含由配置进一步自定义的IWebHostBuilder 。
示例应用的 Post_DeleteMessageHandler_ReturnsRedirectToRoot
测试方法演示如何使用 WithWebHostBuilder
。 此测试通过在 SUT 中触发窗体提交来在数据库中执行记录删除。
由于 IndexPageTests
类中的另一个测试会执行一个操作,该操作将删除数据库中的所有记录,并且该操作可能在 Post_DeleteMessageHandler_ReturnsRedirectToRoot
方法之前运行,因此,该数据库在此测试方法中种子,以确保要删除的 SUT 存在记录。 选择 SUT 中 messages
窗体的第一个 "删除" 按钮时,会将请求中的内容模拟到 SUT:
[Fact] public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot() { // Arrange var client = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var serviceProvider = services.BuildServiceProvider(); using (var scope = serviceProvider.CreateScope()) { var scopedServices = scope.ServiceProvider; var db = scopedServices .GetRequiredService<ApplicationDbContext>(); var logger = scopedServices .GetRequiredService<ILogger<IndexPageTests>>(); try { Utilities.ReinitializeDbForTests(db); } catch (Exception ex) { logger.LogError(ex, "An error occurred seeding " + "the database with test messages. Error: {Message}" + ex.Message); } } }); }) .CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); var defaultPage = await client.GetAsync("/"); var content = await HtmlHelpers.GetDocumentAsync(defaultPage); //Act var response = await client.SendAsync( (IHtmlFormElement)content.QuerySelector("form[id='messages']"), (IHtmlButtonElement)content.QuerySelector("form[id='messages']") .QuerySelector("div[class='panel-body']") .QuerySelector("button")); // Assert Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.Equal("/", response.Headers.Location.OriginalString); }
下表显示了在创建 HttpClient
实例时可用的默认WebApplicationFactoryClientOptions 。
选项 | 描述 | 默认值 |
---|---|---|
AllowAutoRedirect | 获取或设置 HttpClient 实例是否应自动跟随重定向响应。 |
true |
BaseAddress | 获取或设置 HttpClient 实例的基址。 |
http://localhost |
HandleCookies | 获取或设置 HttpClient 实例是否应处理 cookie。 |
true |
MaxAutomaticRedirections | 获取或设置 HttpClient 实例应遵循的重定向响应的最大数目。 |
7 |
创建WebApplicationFactoryClientOptions
类并将其传递给 CreateClient 方法 (在代码示例中显示默认值):
// Default client option values are shown var clientOptions = new WebApplicationFactoryClientOptions(); clientOptions.AllowAutoRedirect = true; clientOptions.BaseAddress = new Uri("http://localhost"); clientOptions.HandleCookies = true; clientOptions.MaxAutomaticRedirections = 7; _client = _factory.CreateClient(clientOptions);
通过在主机生成器上调用ConfigureTestServices ,可以在测试中覆盖服务。 若要注入模拟服务,SUT 必须有一个具有 Startup.ConfigureServices
方法的 Startup
类。
示例 SUT 包含一个返回 quote 的作用域服务。 请求索引页时,引号嵌入到索引页上的隐藏字段中。
服务/IQuoteService:
public interface IQuoteService { Task<string> GenerateQuote(); }
服务/QuoteService:
// 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 Pages 授权约定。
services.AddMvc() .SetCompatibilityVersion(CompatibilityVersion.Version_2_2) .AddRazorPagesOptions(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); }
通过禁止客户端按照重定向操作,可以执行以下检查:
Location
标头值,以确认该标头值以 http://localhost/Identity/Account/Login
(而不是最终的登录页响应)开头,而 Location
标头不存在。测试应用可以模拟ConfigureTestServices中的 AuthenticationHandler<TOptions>,以便测试身份验证和授权的各个方面。 最小方案返回AuthenticateResult:
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
来对用户进行身份验证:
[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
的详细信息,请参阅Client options部分。
默认情况下,SUT 的主机和应用环境配置为使用开发环境。 重写 SUT 的环境:
ASPNETCORE_ENVIRONMENT
环境变量(例如,Staging
、Production
或其他自定义值,例如 Testing
)。CreateHostBuilder
,以读取以 ASPNETCORE
为前缀的环境变量。protected override IHostBuilder CreateHostBuilder() => base.CreateHostBuilder() .ConfigureHostConfiguration( config => config.AddEnvironmentVariables("ASPNETCORE"));
WebApplicationFactory
构造函数通过使用等于 TEntryPoint
程序集 System.Reflection.Assembly.FullName
的键搜索包含集成测试的程序集上的WebApplicationFactoryContentRootAttribute来推断应用内容根路径。 如果找不到具有正确键的属性,WebApplicationFactory
将回退到搜索解决方案文件( .sln),并在解决方案目录中追加 TEntryPoint
程序集名称。 应用根目录(内容根路径)用于发现视图和内容文件。
卷影复制会导致在输出目录以外的目录中执行测试。 要使测试正常工作,必须禁用卷影复制。 该示例应用使用 xUnit 并通过包含具有正确配置设置的xUnit文件来禁用 xUnit 的卷影复制。 有关详细信息,请参阅配置 xUnit 与 JSON。
将xunit文件添加到测试项目的根,其中包含以下内容:
{ "shadowCopy": false }
如果使用的是 Visual Studio,请将文件的 "复制到输出目录" 属性设置为 "始终复制"。 如果不使用 Visual Studio,请将 Content
目标添加到测试应用的项目文件中:
<ItemGroup> <Content Update="xunit.runner.json"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </Content> </ItemGroup>
执行 IClassFixture
实现的测试后,当 xUnit 释放WebApplicationFactory时, TestServer和HttpClient会被释放。 如果开发人员实例化的对象需要处置,请在 IClassFixture
实现中释放这些对象。 有关详细信息,请参阅实现 Dispose 方法。
该示例应用由两个应用组成:
应用程序 | 项目目录 | 描述 |
---|---|---|
消息应用(SUT) | src/RazorPagesProject | 允许用户添加、删除一个、删除和分析消息。 |
测试应用 | tests/RazorPagesProject.Tests | 用于集成测试 SUT。 |
可以使用 IDE (如Visual Studio)的内置测试功能来运行测试。 如果使用Visual Studio Code或命令行,请在RazorPagesProject 目录中的命令提示符处执行以下命令:
dotnet test
SUT 是 Razor Pages 的消息系统,具有以下特征:
Message
类(Data/message)描述,具有两个属性: Id
(密钥)和 Text
(message)。 Text
属性是必需的,并且限制为200个字符。AppDbContext
(data/AppDbContext)。/SecurePage
。†EF 主题使用 InMemory 进行测试说明了如何将内存中数据库用于使用 MSTest 进行测试。 本主题使用xUnit测试框架。 不同测试框架中的测试概念和测试实现相似,但并不完全相同。
尽管应用程序不使用存储库模式,并且不是工作单元(UoW)模式的有效示例,但 Razor Pages 支持这些模式的开发模式。 有关详细信息,请参阅设计基础结构持久性层和测试控制器逻辑(示例实现存储库模式)。
测试应用是 "测试"/"RazorPagesProject " 目录中的控制台应用。
测试应用程序目录 | 描述 |
---|---|
AuthTests | 包含的测试方法:
|
BasicTests | 包含路由和内容类型的测试方法。 |
IntegrationTests | 包含使用自定义 WebApplicationFactory 类的索引页的集成测试。 |
帮助程序/实用工具 |
|
测试框架为xUnit。 集成测试是使用 TestHost 的AspNetCore进行的,其中包括TestServer。 由于AspNetCore包用于配置测试主机和测试服务器,因此 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." } }; }