Kiểm thử tích hợp (Integration Tests) trong ASP.NET Core
Tổng quan
Integration tests (kiểm thử tích hợp) đảm bảo các thành phần của ứng dụng hoạt động đúng ở mức độ bao gồm cả cơ sở hạ tầng hỗ trợ, như cơ sở dữ liệu, hệ thống tệp và mạng. ASP.NET Core hỗ trợ integration tests bằng cách sử dụng một unit test framework với một test web host (máy chủ web kiểm thử) và một in-memory test server (máy chủ kiểm thử trong bộ nhớ).
Giới thiệu về Integration Tests
Integration tests đánh giá các thành phần của ứng dụng ở mức rộng hơn so với unit tests (kiểm thử đơn vị):
- Unit tests kiểm tra các thành phần phần mềm riêng lẻ (ví dụ: các phương thức trong từng lớp)
- Integration tests xác nhận rằng hai hoặc nhiều thành phần ứng dụng phối hợp với nhau để tạo ra kết quả mong đợi
Đặc điểm chính
Integration tests:
- Sử dụng các thành phần thực mà ứng dụng dùng trong môi trường production
- Yêu cầu nhiều code và xử lý dữ liệu hơn
- Mất nhiều thời gian chạy hơn
- Kiểm tra cơ sở hạ tầng và framework bao gồm:
- Cơ sở dữ liệu
- Hệ thống tệp
- Thiết bị mạng
- Pipeline xử lý request-response
Các thực hành tốt nhất
- Không viết integration tests cho mọi tổ hợp dữ liệu và truy cập tệp
- Sử dụng tập hợp kiểm thử đọc, ghi, cập nhật và xóa có trọng tâm cho các thành phần cơ sở dữ liệu và hệ thống tệp
- Sử dụng unit tests cho các kiểm thử thường xuyên về logic phương thức tương tác với cơ sở hạ tầng
- Giới hạn integration tests vào các kịch bản cơ sở hạ tầng quan trọng nhất
- Ưu tiên unit tests hơn integration tests khi hành vi có thể được kiểm thử theo cả hai cách
Yêu cầu Integration Test trong ASP.NET Core
Integration tests trong ASP.NET Core yêu cầu:
- Một dự án kiểm thử chứa và thực thi các test (có tham chiếu đến System Under Test - SUT)
- Một test web host cho SUT và một test server client để xử lý requests/responses
- Một test runner để thực thi tests và báo cáo kết quả
Thứ tự kiểm thử
- Web host của SUT được cấu hình
- Tạo một test server client để gửi requests
- Arrange (Chuẩn bị): Ứng dụng kiểm thử chuẩn bị một request
- Act (Thực hiện): Client gửi request và nhận response
- Assert (Kiểm tra): Response được xác nhận so với kết quả mong đợi
- Quá trình tiếp tục cho đến khi tất cả các tests đã thực thi
- Kết quả test được báo cáo
Điều kiện tiên quyết cho dự án kiểm thử
Dự án kiểm thử phải:
- Tham chiếu gói
Microsoft.AspNetCore.Mvc.Testing - Chỉ định Web SDK trong file project:
<Project Sdk="Microsoft.NET.Sdk.Web">
Các phụ thuộc bổ sung
Đối với ứng dụng mẫu sử dụng xUnit và AngleSharp:
<PackageReference Include="AngleSharp" Version="X.X.X" /> <PackageReference Include="xunit" Version="X.X.X" /> <PackageReference Include="xunit.runner.visualstudio" Version="X.X.X" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="X.X.X" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="X.X.X" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="X.X.X" />
Kiểm thử cơ bản với WebApplicationFactory mặc định
Ví dụ đơn giản (xUnit)
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());
}
}Phương thức CreateClient() tạo một instance HttpClient tự động theo dõi redirects và xử lý cookies.
Tùy chỉnh WebApplicationFactory
Triển khai Custom Factory
public class CustomWebApplicationFactory<TProgram>
: WebApplicationFactory<TProgram> where TProgram : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var dbContextDescriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(IDbContextOptionsConfiguration<ApplicationDbContext>));
services.Remove(dbContextDescriptor);
var dbConnectionDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbConnection));
services.Remove(dbConnectionDescriptor);
// Tạo SqliteConnection mở để EF không tự động đóng nó
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");
}
}Sử dụng Custom Factory trong Tests
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
});
}
[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);
}
}Tùy chỉnh Client với WithWebHostBuilder
[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);
}Tùy chọn Client
Tạo WebApplicationFactoryClientOptions để tùy chỉnh hành vi client:
var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
BaseAddress = new Uri("https://localhost"), // Dùng cho HTTPS redirection
HandleCookies = true,
MaxAutomaticRedirections = 7
});Lưu ý: Để tránh cảnh báo HTTPS redirection, đặt BaseAddress = new Uri("https://localhost")
Chèn Mock Services (dịch vụ giả lập)
Triển khai Service
public interface IQuoteService
{
Task<string> GenerateQuote();
}
public class QuoteService : IQuoteService
{
public Task<string> GenerateQuote()
{
return Task.FromResult(
"Come on, Sarah. We've an appointment in London, " +
"and we're already 30,000 years late.");
}
}
// Trong Program.cs:
services.AddScoped<IQuoteService, QuoteService>();Test Mock Service
public class TestQuoteService : IQuoteService
{
public Task<string> GenerateQuote()
{
return Task.FromResult(
"Something's interfering with time, Mr. Scarman, " +
"and time is my business.");
}
}
[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);
}Mock Authentication (Xác thực giả lập)
Authentication Handler
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");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}Kiểm thử truy cập chưa xác thực
[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);
}Kiểm thử truy cập đã xác thực
[Fact]
public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser()
{
// Arrange
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "TestScheme";
options.DefaultChallengeScheme = "TestScheme";
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"TestScheme", options => { });
});
})
.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue(scheme: "TestScheme");
//Act
var response = await client.GetAsync("/SecurePage");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}Xử lý Antiforgery (chống giả mạo yêu cầu)
Ứng dụng mẫu sử dụng AngleSharp để phân tích HTML và xử lý antiforgery token. Các phương thức helper bao gồm:
GetDocumentAsync: NhậnHttpResponseMessagevà trả vềIHtmlDocumentSendAsync: Các phương thức mở rộng tạoHttpRequestMessagevà xử lý gửi form kèm antiforgery tokens
var response = await _client.SendAsync(
(IHtmlFormElement)content.QuerySelector("form[id='messages']"),
(IHtmlButtonElement)content.QuerySelector("button[id='deleteAllBtn']"));Ghi chú bổ sung
- Tách biệt unit tests và integration tests vào các dự án khác nhau
- Sử dụng SQLite provider (khuyến nghị hơn EF-Core in-memory) cho kiểm thử in-memory
- Mặc định, môi trường SUT được đặt là
Development - Tắt shadow copying nếu tests dựa vào việc tải tệp theo đường dẫn tương đối từ
Assembly.Location WebApplicationFactorysuy luận đường dẫn app content root thông quaWebApplicationFactoryContentRootAttribute