我软件工程中的集成测试检查程序的不同部分是否很好地协同工作。它确保当一段代码与另一段代码交谈时,它们相互理解并正确共享信息。通过这样做,它有助于及早发现和解决问题,使软件更好、更可靠。集成测试就像确保机器中的所有齿轮都能顺利地组装在一起,因此整个过程也运行平稳。这是确保我们每天使用的软件按预期方式工作的重要一步。
集成测试检查软件系统的不同部分是否很好地协同工作,验证模块之间的交互。另一方面,单元测试单独测试单个组件。集成测试验证整体系统行为,而单元测试则侧重于每个组件中的特定功能,确保它们正确且独立地工作。
让我们为 .NET 8 API 创建集成测试。
假设我们有一个示例 Web API,如下所示。
InvoiceItem 控制器
[ApiController]
[Route("/api/v1")]
public class InvoiceItemController(IInvoiceItemService invoiceItemService) : ControllerBase
{
private readonly IInvoiceItemService _invoiceItemService = invoiceItemService;
[HttpGet("InvoiceItems")]
public async Task<ActionResult<IEnumerable<InvoiceDto>>> QueryAsync([FromQuery] InvoiceQueryRequestModel request)
{
return Ok(await _invoiceItemService.QueryAsync(request));
}
[HttpGet("InvoiceItems/{id}")]
public async Task<ActionResult<InvoiceRequestModel>> GetAsync(long id)
{
return Ok(await _invoiceItemService.GetByIdAsync(id));
}
[HttpPost("InvoiceItems")]
public async Task<ActionResult<InvoiceDto>> CreateAsync(InvoiceRequestModel request)
{
var result = await _invoiceItemService.CreateAsync(request);
return StatusCode((int)HttpStatusCode.Created, result);
}
[HttpDelete("InvoiceItems/{id}")]
public async Task<IActionResult> DeleteAsync(long id)
{
await _invoiceItemService.DeleteAsync(id);
return NoContent();
}
[HttpPatch("InvoiceItems/{id}")]
public async Task<ActionResult<InvoiceRequestModel>> UpdateAsync(long id, InvoiceRequestModel request)
{
return Ok(await _invoiceItemService.UpdateAsync(id, request));
}
}
我将详细介绍 API 的其他组件。让我们考虑一下此 API 的以下实体设计。
public class Invoice
{
[Key]
public long Id { get; set; }
public long CategoryId { get; set; }
public virtual InvoiceCategory? Category { get; set; }
public long? SubCategoryId { get; set; }
public virtual InvoiceSubCategory? SubCategory { get; set; }
public double TonsOfCO2 { get; set; }
}
public class InvoiceCategory
{
[Key]
public long Id { get; set; }
public string Name { get; set; }
}
public class InvoiceSubCategory
{
[Key]
public long Id { get; set; }
public string Name { get; set; }
}
public class InvoiceDbContext(DbContextOptions<InvoiceDbContext> options) : DbContext(options)
{
public DbSet<Invoice> Invoices { get; set; }
public DbSet<InvoiceCategory> InvoiceCategories { get; set; }
public DbSet<InvoiceSubCategory> InvoiceSubCategories { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new InvoiceCategoryConfiguration());
modelBuilder.ApplyConfiguration(new InvoiceSubCategoryConfiguration());
modelBuilder.ApplyConfiguration(new InvoiceRowConfiguration());
}
}
public class InvoiceCategoryConfiguration : IEntityTypeConfiguration<InvoiceCategory>
{
public void Configure(EntityTypeBuilder<InvoiceCategory> builder) => SeedDefaultCategories(builder);
private static void SeedDefaultCategories(EntityTypeBuilder<InvoiceCategory> builder) =>
builder.HasData(
new InvoiceCategory
{
Id = 1,
Name = "Category 1"
},
new InvoiceCategory
{
Id = 2,
Name = "Category 2"
},
new InvoiceCategory
{
Id = 3,
Name = "Category 3"
}
);
}
public class InvoiceRowConfiguration : IEntityTypeConfiguration<Invoice>
{
public void Configure(EntityTypeBuilder<Invoice> builder)
{
ConfigureCategoryRelationship(builder);
ConfigureSubCategoryRelationship(builder);
SeedDefaultData(builder);
}
private static void ConfigureCategoryRelationship(EntityTypeBuilder<Invoice> builder) =>
builder
.HasOne(e => e.Category)
.WithMany()
.HasForeignKey(e => e.CategoryId)
.IsRequired();
private static void ConfigureSubCategoryRelationship(EntityTypeBuilder<Invoice> builder) =>
builder
.HasOne(e => e.SubCategory)
.WithMany()
.HasForeignKey(e => e.SubCategoryId);
private static void SeedDefaultData(EntityTypeBuilder<Invoice> builder)
{
builder.HasData(
new Invoice
{
Id = 1,
CategoryId = 1,
SubCategoryId = 1,
TonsOfCO2 = 1
},
new Invoice
{
Id = 2,
CategoryId = 2,
SubCategoryId = 2,
TonsOfCO2 = 2
}
);
}
}
public class InvoiceSubCategoryConfiguration : IEntityTypeConfiguration<InvoiceSubCategory>
{
public void Configure(EntityTypeBuilder<InvoiceSubCategory> builder) => SeedDefaultSubCategories(builder);
private void SeedDefaultSubCategories(EntityTypeBuilder<InvoiceSubCategory> builder) =>
builder.HasData(
new InvoiceSubCategory
{
Id = 1,
Name = "Sub Category 1"
},
new InvoiceSubCategory
{
Id = 2,
Name = "Sub Category 2"
},
new InvoiceSubCategory
{
Id = 3,
Name = "Sub Category 3"
}
);
}
发票项目服务
public interface IInvoiceItemService
{
Task<IEnumerable<InvoiceDto>> QueryAsync(InvoiceQueryRequestModel invoiceQueryRequestModel);
Task<InvoiceDto> GetByIdAsync(long id);
Task<InvoiceDto> CreateAsync(InvoiceRequestModel invoice);
Task DeleteAsync(long id);
Task<InvoiceDto> UpdateAsync(long id, InvoiceRequestModel invoice);
}
public class InvoiceItemService(IInvoiceItemRepository invoiceItemRepository) : IInvoiceItemService
{
private readonly IInvoiceItemRepository _invoiceItemRepository = invoiceItemRepository;
public async Task<InvoiceDto> GetByIdAsync(long id) =>
await _invoiceItemRepository.GetByIdAsync(id);
public async Task<InvoiceDto> CreateAsync(InvoiceRequestModel emissionBreakdownRow) =>
await _invoiceItemRepository.CreateAsync(emissionBreakdownRow);
public async Task DeleteAsync(long id) =>
await _invoiceItemRepository.DeleteAsync(id);
public async Task<InvoiceDto> UpdateAsync(long id, InvoiceRequestModel emissionBreakdownRow) =>
await _invoiceItemRepository.UpdateAsync(id, emissionBreakdownRow);
public async Task<IEnumerable<InvoiceDto>> QueryAsync(InvoiceQueryRequestModel emissionBreakdownQuery) =>
await _invoiceItemRepository.QueryAsync(emissionBreakdownQuery);
}
发票项目存储库
public interface IInvoiceItemRepository
{
Task<IEnumerable<InvoiceDto>> QueryAsync(InvoiceQueryRequestModel invoiceQuery);
Task<InvoiceDto> GetByIdAsync(long id);
Task<InvoiceDto> CreateAsync(InvoiceRequestModel invoice);
Task<InvoiceDto> UpdateAsync(long id, InvoiceRequestModel invoice);
Task DeleteAsync(long id);
}
public class InvoiceItemRepository(InvoiceDbContext context, IMapper mapper) : IInvoiceItemRepository
{
private readonly InvoiceDbContext _context = context;
private readonly IMapper _mapper = mapper;
public async Task<IEnumerable<InvoiceDto>> QueryAsync(InvoiceQueryRequestModel invoiceQuery)
{
var query = _context.Invoices
.Include(x => x.Category)
.Include(x => x.SubCategory)
.AsQueryable();
// Apply filters
if (invoiceQuery.CategoryId != null)
query = query.Where(row => row.CategoryId == invoiceQuery.CategoryId);
if (invoiceQuery.SubCategoryId != null)
query = query.Where(row => row.SubCategoryId == invoiceQuery.SubCategoryId);
// Apply sorting
if (!string.IsNullOrEmpty(invoiceQuery.SortField))
{
query = invoiceQuery.SortField switch
{
"CategoryId" => query.OrderBy(row => row.CategoryId),
"SubCategoryId" => query.OrderBy(row => row.SubCategoryId),
"TonsOfCO2" => query.OrderBy(row => row.TonsOfCO2),
"Id" => query.OrderBy(row => row.Id),
_ => query.OrderBy(row => row.Id),
};
}
else
{
query = query.OrderBy(row => row.Id);
}
// Apply paging
query = query.Skip((invoiceQuery.PageToken - 1) * invoiceQuery.PageSize).Take(invoiceQuery.PageSize);
var result = await query.ToListAsync();
return _mapper.Map<List<InvoiceDto>>(result);
}
public async Task<InvoiceDto> CreateAsync(InvoiceRequestModel invoiceQuery)
{
var existingEntity = await _context.Invoices
.FirstOrDefaultAsync(e => e.CategoryId == invoiceQuery.CategoryId &&
e.SubCategoryId == invoiceQuery.SubCategoryId);
if (existingEntity != null)
{
throw new ServerException(Messages.DuplicateValues);
}
try
{
var entity = _mapper.Map<Invoice>(invoiceQuery);
await _context.Invoices.AddAsync(entity);
await _context.SaveChangesAsync();
return _mapper.Map<InvoiceDto>(entity);
}
// To throw a meaning message from the API
catch
{
throw new ServerException(Messages.CreationFailed);
}
}
public async Task<InvoiceDto> GetByIdAsync(long id)
{
var invoice = await (_context.Invoices
.Include(x => x.Category)
.Include(x => x.SubCategory)).FirstOrDefaultAsync(x => x.Id == id)
?? throw new NotFoundException(Messages.NotFound);
return _mapper.Map<InvoiceDto>(invoice);
}
public async Task DeleteAsync(long id)
{
var entity = await _context.Invoices.FindAsync(id)
?? throw new NotFoundException(Messages.NotFound);
_context.Invoices.Remove(entity);
await _context.SaveChangesAsync();
}
public async Task<InvoiceDto> UpdateAsync(long id, InvoiceRequestModel invoice)
{
var entity = await _context.Invoices.FindAsync(id)
?? throw new NotFoundException(Messages.NotFound);
entity.TonsOfCO2 = invoice.TonsOfCO2;
entity.CategoryId = invoice.CategoryId;
entity.SubCategoryId = invoice.SubCategoryId;
await _context.SaveChangesAsync();
return _mapper.Map<InvoiceDto>(entity);
}
}
让我们在 VS 中创建新项目时通过选择“xUnit Test Project”来创建单元测试项目。
安装以下软件包。
在 ASP.NET Core 中实现集成测试时,可能需要覆盖配置设置以进行测试。
在集成测试期间,您通常需要与“program.cs”中的设置和配置不同的设置和配置。常见方案包括连接到不同的数据库(例如,内存中的 SQLite)和模拟外部依赖项。可以通过更改 Options 类型的属性来使用 Options 模式覆盖配置值。确保测试设置在运行应用程序之前和之后提供必要的配置值。
对于 SQLite 内存中测试,请安装 EF Core InMemory NuGet 包。之后,我们将覆盖相关配置并利用现有的数据库上下文、实体和迁移。
让我们通过继承 WebApplicationFactory 类来创建一个工厂类,如下所示。
public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
Program.IsFromTest = true;
builder.ConfigureTestServices(services =>
{
var dbContext = services.SingleOrDefault(x => x.ServiceType == typeof(DbContextOptions<InvoiceDbContext>));
services.Remove(dbContext);
var dbConnection = services.SingleOrDefault(x => x.ServiceType == typeof(DbConnection));
services.Remove(dbConnection);
services.AddSingleton<DbConnection>(container =>
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
return connection;
});
services.AddDbContext<InvoiceDbContext>((container, options) =>
{
var connection = container.GetRequiredService<DbConnection>();
options.UseSqlite(connection);
});
});
builder.UseEnvironment("Development");
}
}
此代码片段自定义集成测试的 Web 主机配置。它删除现有的数据库相关服务,添加 SQLite 内存中连接,并将 InvoiceDbContext 配置为使用此连接。
public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class
CustomWebApplicationFactory 继承自“WebApplicationFactory<TProgram>”。泛型类型参数“TProgram”必须是类的引用类型。
protected override void ConfigureWebHost(IWebHostBuilder builder){
}
此方法重写基类的 ConfigureWebHost 方法。它允许在应用程序启动之前自定义配置 Web 主机构建器。
base.ConfigureWebHost(builder);
调用基类的 ConfigureWebHost 方法来执行任何默认配置。 这可确保保留基本行为。
builder.ConfigureTestServices(services => { … });
专门为测试配置服务。在 lambda 表达式中,您可以修改服务集合 (services)。
var dbContext = services.SingleOrDefault(x => x.ServiceType == typeof(DbContextOptions<InvoiceDbContext>));
检索 DbContextOptions<InvoiceDbContext 的注册服务(通常与 Entity Framework Core 相关)。
services.Remove(dbContext);
从集合中删除现有的 DbContextOptions<InvoiceDbContext> 服务。对 DbConnection 执行类似的步骤。
services.AddSingleton\<DbConnection>(container => { … });
添加 DbConnection 类型的单一实例服务。创建数据源设置为“:memory:”的内存中 SQLite 连接。
services.AddDbContext<InvoiceDbContext>((container, options) => { … });
添加 InvoiceDbContext 类型的作用域服务。将 InvoiceDbContext 配置为使用之前创建的 SQLite 连接。
builder.UseEnvironment("Development");
将 Web 主机的环境设置为“开发”。这会影响各种行为,例如日志记录和异常处理。
让我们为“Invoice Controller”创建测试类。初始化自定义 Web 应用程序工厂,并创建 HttpClient 实例。
public class InvoiceControllerTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
private readonly CustomWebApplicationFactory<Program> _factory;
private HttpClient _httpClient;
public InvoiceControllerTests(CustomWebApplicationFactory<Program> factory)
{
_factory = factory;
_httpClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
private void CreateDatabase()
{
using var scope = _factory.Services.CreateScope();
var scopedService = scope.ServiceProvider;
var db = scopedService.GetRequiredService<InvoiceDbContext>();
db.Database.EnsureCreated();
}
}
此类表示“InvoiceController”的一组测试用例。它实现了“IClassFixture<CustomWebApplicationFactory<Program>>”接口,该接口允许它使用自定义 Web 应用程序工厂进行测试设置。 用于保存自定义 Web 应用程序工厂实例的私有字段“_factory”。用于存储“HttpClient”实例的私有字段“_httpClient”。
私有方法“CreateDatabase”负责创建具有必要架构的数据库。它在自定义 Web 应用程序工厂创建的范围内运行。InvoiceDbContext 是从服务提供商处获取的。'EnsureCreated()' 方法确保数据库架构存在。
测试“GetAsync”终结点的第一个测试方法。
[Fact]
public async Task GetAsync_InvoiceExist_ReturnsSuccessWithInvoiceItems()
{
// Arrange
CreateDatabase();
// Act
var response = await _httpClient.GetAsync("/api/v1/InvoiceItems/1");
var result = await response.Content.ReadFromJsonAsync<InvoiceDto>();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
result?.Category?.Name.Should().Be("Category 1");
}
让我们也为其他端点添加测试方法。以下是“发票控制器”的已完成测试类。
public class InvoiceControllerTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
private readonly CustomWebApplicationFactory<Program> _factory;
private HttpClient _httpClient;
public InvoiceControllerTests(CustomWebApplicationFactory<Program> factory)
{
_factory = factory;
_httpClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
private void CreateDatabase()
{
using var scope = _factory.Services.CreateScope();
var scopedService = scope.ServiceProvider;
var db = scopedService.GetRequiredService<InvoiceDbContext>();
db.Database.EnsureCreated();
}
[Fact]
public async Task GetAsync_InvoiceExist_ReturnsSuccessWithInvoiceItems()
{
// Arrange
CreateDatabase();
// Act
var response = await _httpClient.GetAsync("/api/v1/InvoiceItems/1");
var result = await response.Content.ReadFromJsonAsync<InvoiceDto>();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
result?.Category?.Name.Should().Be("Category 1");
}
[Fact]
public async Task QueryAsync_InvoiceExist_ReturnsSuccessWithInvoiceItems()
{
// Arrange
CreateDatabase();
// Act
var response = await _httpClient.GetAsync("/api/v1/InvoiceItems?PageToken=1&PageSize=5");
var result = await response.Content.ReadFromJsonAsync<List<InvoiceDto>>();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
result.Should().HaveCount(2);
}
[Fact]
public async Task QueryAsync_InvalidInput_ReturnsBadRequest()
{
// Arrange
CreateDatabase();
// Act
var response = await _httpClient.GetAsync("/api/v1/InvoiceItems?PageToken=0&PageSize=5");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task CreateAsync_ValidInput_ReturnsCreatedWithInvoiceItems()
{
// Arrange
CreateDatabase();
var requestContent = new InvoiceRequestModel() { CategoryId = 3, SubCategoryId = 3, TonsOfCO2 = 3 };
var content = JsonConvert.SerializeObject(requestContent);
HttpContent httpContent = new StringContent(content, Encoding.UTF8, "application/json");
httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
// Act
var response = await _httpClient.PostAsync("/api/v1/InvoiceItems", httpContent);
var result = await response.Content.ReadFromJsonAsync<InvoiceDto>();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
result.Id.Should().Be(3);
}
[Fact]
public async Task CreateAsync_ThrowException_ReturnsInternalServerError()
{
// Arrange
CreateDatabase();
var requestContent = new InvoiceRequestModel() { CategoryId = 1, SubCategoryId = 1, TonsOfCO2 = 1 };
var content = JsonConvert.SerializeObject(requestContent);
HttpContent httpContent = new StringContent(content, Encoding.UTF8, "application/json");
httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
// Act
var response = await _httpClient.PostAsync("/api/v1/InvoiceItems", httpContent);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
}
[Fact]
public async Task DeleteAsync_ReturnsNoContent()
{
// Arrange
CreateDatabase();
// Act
var response = await _httpClient.DeleteAsync("/api/v1/InvoiceItems/2");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
public async Task UpdateAsync_ValidInput_ReturnsSuccessWithInvoiceItems()
{
// Arrange
CreateDatabase();
var requestContent = new InvoiceRequestModel() { CategoryId = 1, SubCategoryId = 1, TonsOfCO2 = 25 };
var content = JsonConvert.SerializeObject(requestContent);
HttpContent httpContent = new StringContent(content, Encoding.UTF8, "application/json");
httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
// Act
var response = await _httpClient.PatchAsync("/api/v1/InvoiceItems/1", httpContent);
var result = await response.Content.ReadFromJsonAsync<InvoiceDto>();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
result.TonsOfCO2.Should().Be(25);
}
}
是时候运行测试用例并检查结果了。