使用Claude Code分析了开源框架netcorepal-cloud-framework的集成测试部分,并出具了搭建教程。方便日后开发时搭建
TestContainers从零开始分步教程
基于NetCorePal框架的TestContainers实践,构建完整的容器化测试解决方案
目录
- 环境准备和项目初始化
- 创建基础项目结构
- 配置依赖包管理
- 实现基础设施层
- 创建数据库测试固件
- 实现Repository层集成测试
- 添加Web API集成测试
- 实现多容器编排
- 性能优化和最佳实践
- CI/CD集成配置
1. 环境准备和项目初始化
1.1 前置条件
确保您的开发环境已安装:
- .NET 8.0 SDK 或更高版本
- Docker Desktop (Windows/Mac) 或 Docker Engine (Linux)
- Visual Studio 2022 或 Visual Studio Code
- Git 版本控制工具
1.2 验证Docker环境
# 验证Docker是否正常运行
docker --version
docker ps# 拉取常用镜像(可选,提升后续启动速度)
docker pull mysql:8.0
docker pull redis:alpine
docker pull rabbitmq:3-management
1.3 创建解决方案
# 创建解决方案目录
mkdir TestContainersDemo
cd TestContainersDemo# 创建解决方案文件
dotnet new sln -n TestContainersDemo# 创建Git仓库
git init
2. 创建基础项目结构
2.1 创建项目目录结构
# 创建源码项目
dotnet new webapi -n TestContainersDemo.Api -o src/TestContainersDemo.Api
dotnet new classlib -n TestContainersDemo.Core -o src/TestContainersDemo.Core
dotnet new classlib -n TestContainersDemo.Infrastructure -o src/TestContainersDemo.Infrastructure# 创建测试项目
dotnet new xunit -n TestContainersDemo.UnitTests -o test/TestContainersDemo.UnitTests
dotnet new xunit -n TestContainersDemo.IntegrationTests -o test/TestContainersDemo.IntegrationTests# 添加项目到解决方案
dotnet sln add src/TestContainersDemo.Api
dotnet sln add src/TestContainersDemo.Core
dotnet sln add src/TestContainersDemo.Infrastructure
dotnet sln add test/TestContainersDemo.UnitTests
dotnet sln add test/TestContainersDemo.IntegrationTests
2.2 设置项目引用关系
# 设置API项目引用
dotnet add src/TestContainersDemo.Api reference src/TestContainersDemo.Core
dotnet add src/TestContainersDemo.Api reference src/TestContainersDemo.Infrastructure# 设置基础设施项目引用
dotnet add src/TestContainersDemo.Infrastructure reference src/TestContainersDemo.Core# 设置测试项目引用
dotnet add test/TestContainersDemo.UnitTests reference src/TestContainersDemo.Core
dotnet add test/TestContainersDemo.IntegrationTests reference src/TestContainersDemo.Api
dotnet add test/TestContainersDemo.IntegrationTests reference src/TestContainersDemo.Infrastructure
3. 配置依赖包管理
3.1 创建集中化包管理
创建 Directory.Packages.props 文件:
<Project><PropertyGroup><ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally><CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled></PropertyGroup><ItemGroup><!-- .NET Framework --><PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" /><PackageVersion Include="Swashbuckle.AspNetCore" Version="6.5.0" /><!-- Entity Framework Core --><PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.0" /><PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0" /><PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0" /><PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" /><PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.0" /><!-- Caching --><PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.0" /><PackageVersion Include="StackExchange.Redis" Version="2.6.122" /><!-- MediatR CQRS --><PackageVersion Include="MediatR" Version="12.1.1" /><PackageVersion Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" /><!-- 验证 --><PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.0" /><!-- 测试框架 --><PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /><PackageVersion Include="xunit" Version="2.9.0" /><PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" /><PackageVersion Include="coverlet.collector" Version="6.0.0" /><!-- TestContainers --><PackageVersion Include="Testcontainers" Version="3.10.0" /><PackageVersion Include="Testcontainers.MySql" Version="3.10.0" /><PackageVersion Include="Testcontainers.Redis" Version="3.10.0" /><PackageVersion Include="Testcontainers.RabbitMq" Version="3.10.0" /><!-- ASP.NET Core Testing --><PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" /><PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="8.0.0" /><!-- Moq --><PackageVersion Include="Moq" Version="4.20.72" /><!-- AutoFixture --><PackageVersion Include="AutoFixture" Version="4.18.1" /><PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" /></ItemGroup>
</Project>
3.2 更新项目文件
更新 test/TestContainersDemo.IntegrationTests/TestContainersDemo.IntegrationTests.csproj:
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" /><PackageReference Include="xunit" /><PackageReference Include="xunit.runner.visualstudio" /><PackageReference Include="coverlet.collector" /><!-- TestContainers --><PackageReference Include="Testcontainers" /><PackageReference Include="Testcontainers.MySql" /><PackageReference Include="Testcontainers.Redis" /><!-- EF Core --><PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" /><PackageReference Include="Pomelo.EntityFrameworkCore.MySql" /><!-- ASP.NET Core Testing --><PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /><!-- AutoFixture --><PackageReference Include="AutoFixture" /><PackageReference Include="AutoFixture.Xunit2" /></ItemGroup><ItemGroup><Using Include="Xunit" /><Using Include="System.Threading.Tasks" /></ItemGroup></Project>
4. 实现基础设施层
4.1 创建领域实体
在 src/TestContainersDemo.Core/Entities/Product.cs:
namespace TestContainersDemo.Core.Entities;public class Product
{public int Id { get; set; }public string Name { get; set; } = string.Empty;public string Description { get; set; } = string.Empty;public decimal Price { get; set; }public int StockQuantity { get; set; }public DateTime CreatedAt { get; set; }public DateTime UpdatedAt { get; set; }
}
在 src/TestContainersDemo.Core/Entities/Order.cs:
namespace TestContainersDemo.Core.Entities;public class Order
{public int Id { get; set; }public string CustomerName { get; set; } = string.Empty;public string CustomerEmail { get; set; } = string.Empty;public List<OrderItem> Items { get; set; } = new();public decimal TotalAmount { get; set; }public OrderStatus Status { get; set; }public DateTime CreatedAt { get; set; }
}public class OrderItem
{public int Id { get; set; }public int OrderId { get; set; }public int ProductId { get; set; }public Product Product { get; set; } = null!;public int Quantity { get; set; }public decimal Price { get; set; }
}public enum OrderStatus
{Pending = 0,Processing = 1,Completed = 2,Cancelled = 3
}
4.2 创建Repository接口
在 src/TestContainersDemo.Core/Repositories/IProductRepository.cs:
namespace TestContainersDemo.Core.Repositories;public interface IProductRepository
{Task<Product?> GetByIdAsync(int id);Task<List<Product>> GetAllAsync();Task<Product> AddAsync(Product product);Task UpdateAsync(Product product);Task DeleteAsync(int id);Task<bool> ExistsAsync(int id);
}
在 src/TestContainersDemo.Core/Repositories/IOrderRepository.cs:
namespace TestContainersDemo.Core.Repositories;public interface IOrderRepository
{Task<Order?> GetByIdAsync(int id);Task<List<Order>> GetAllAsync();Task<List<Order>> GetByCustomerEmailAsync(string email);Task<Order> AddAsync(Order order);Task UpdateAsync(Order order);
}
4.3 创建DbContext
在 src/TestContainersDemo.Infrastructure/Data/ApplicationDbContext.cs:
using Microsoft.EntityFrameworkCore;
using TestContainersDemo.Core.Entities;namespace TestContainersDemo.Infrastructure.Data;public class ApplicationDbContext : DbContext
{public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options): base(options){}public DbSet<Product> Products => Set<Product>();public DbSet<Order> Orders => Set<Order>();public DbSet<OrderItem> OrderItems => Set<OrderItem>();protected override void OnModelCreating(ModelBuilder modelBuilder){base.OnModelCreating(modelBuilder);modelBuilder.Entity<Product>(entity =>{entity.HasKey(e => e.Id);entity.Property(e => e.Name).IsRequired().HasMaxLength(200);entity.Property(e => e.Description).HasMaxLength(1000);entity.Property(e => e.Price).HasPrecision(18, 2);});modelBuilder.Entity<Order>(entity =>{entity.HasKey(e => e.Id);entity.Property(e => e.CustomerName).IsRequired().HasMaxLength(100);entity.Property(e => e.CustomerEmail).IsRequired().HasMaxLength(200);entity.Property(e => e.TotalAmount).HasPrecision(18, 2);entity.Property(e => e.Status).HasConversion<int>();});modelBuilder.Entity<OrderItem>(entity =>{entity.HasKey(e => e.Id);entity.Property(e => e.Price).HasPrecision(18, 2);entity.HasOne<Order>().WithMany(o => o.Items).HasForeignKey(oi => oi.OrderId);entity.HasOne(oi => oi.Product).WithMany().HasForeignKey(oi => oi.ProductId);});}
}
4.4 实现Repository
在 src/TestContainersDemo.Infrastructure/Repositories/ProductRepository.cs:
using Microsoft.EntityFrameworkCore;
using TestContainersDemo.Core.Entities;
using TestContainersDemo.Core.Repositories;
using TestContainersDemo.Infrastructure.Data;namespace TestContainersDemo.Infrastructure.Repositories;public class ProductRepository : IProductRepository
{private readonly ApplicationDbContext _context;public ProductRepository(ApplicationDbContext context){_context = context;}public async Task<Product?> GetByIdAsync(int id){return await _context.Products.FindAsync(id);}public async Task<List<Product>> GetAllAsync(){return await _context.Products.ToListAsync();}public async Task<Product> AddAsync(Product product){product.CreatedAt = DateTime.UtcNow;product.UpdatedAt = DateTime.UtcNow;_context.Products.Add(product);await _context.SaveChangesAsync();return product;}public async Task UpdateAsync(Product product){product.UpdatedAt = DateTime.UtcNow;_context.Products.Update(product);await _context.SaveChangesAsync();}public async Task DeleteAsync(int id){var product = await _context.Products.FindAsync(id);if (product != null){_context.Products.Remove(product);await _context.SaveChangesAsync();}}public async Task<bool> ExistsAsync(int id){return await _context.Products.AnyAsync(p => p.Id == id);}
}
在 src/TestContainersDemo.Infrastructure/Repositories/OrderRepository.cs:
using Microsoft.EntityFrameworkCore;
using TestContainersDemo.Core.Entities;
using TestContainersDemo.Core.Repositories;
using TestContainersDemo.Infrastructure.Data;namespace TestContainersDemo.Infrastructure.Repositories;public class OrderRepository : IOrderRepository
{private readonly ApplicationDbContext _context;public OrderRepository(ApplicationDbContext context){_context = context;}public async Task<Order?> GetByIdAsync(int id){return await _context.Orders.Include(o => o.Items).ThenInclude(i => i.Product).FirstOrDefaultAsync(o => o.Id == id);}public async Task<List<Order>> GetAllAsync(){return await _context.Orders.Include(o => o.Items).ThenInclude(i => i.Product).ToListAsync();}public async Task<List<Order>> GetByCustomerEmailAsync(string email){return await _context.Orders.Include(o => o.Items).ThenInclude(i => i.Product).Where(o => o.CustomerEmail == email).ToListAsync();}public async Task<Order> AddAsync(Order order){order.CreatedAt = DateTime.UtcNow;_context.Orders.Add(order);await _context.SaveChangesAsync();return order;}public async Task UpdateAsync(Order order){_context.Orders.Update(order);await _context.SaveChangesAsync();}
}
5. 创建数据库测试固件
5.1 创建基础数据库固件
在 test/TestContainersDemo.IntegrationTests/Fixtures/DatabaseFixture.cs:
using Microsoft.EntityFrameworkCore;
using TestContainersDemo.Infrastructure.Data;
using Testcontainers.MySql;namespace TestContainersDemo.IntegrationTests.Fixtures;public class DatabaseFixture : IAsyncLifetime
{private readonly MySqlContainer _mySqlContainer;public DatabaseFixture(){_mySqlContainer = new MySqlBuilder().WithImage("mysql:8.0").WithDatabase("testdb").WithUsername("testuser").WithPassword("testpass").WithEnvironment("TZ", "UTC").WithCleanUp(true).Build();}public string ConnectionString => _mySqlContainer.GetConnectionString();public async Task InitializeAsync(){await _mySqlContainer.StartAsync();// 确保数据库架构已创建await using var context = CreateDbContext();await context.Database.EnsureCreatedAsync();}public async Task DisposeAsync(){await _mySqlContainer.DisposeAsync();}public ApplicationDbContext CreateDbContext(){var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseMySql(ConnectionString, new MySqlServerVersion(new Version(8, 0))).EnableSensitiveDataLogging().EnableDetailedErrors().Options;return new ApplicationDbContext(options);}public async Task<T> ExecuteInTransactionAsync<T>(Func<ApplicationDbContext, Task<T>> action){await using var context = CreateDbContext();await using var transaction = await context.Database.BeginTransactionAsync();try{var result = await action(context);await transaction.CommitAsync();return result;}catch{await transaction.RollbackAsync();throw;}}public async Task ExecuteInTransactionAsync(Func<ApplicationDbContext, Task> action){await using var context = CreateDbContext();await using var transaction = await context.Database.BeginTransactionAsync();try{await action(context);await transaction.CommitAsync();}catch{await transaction.RollbackAsync();throw;}}public async Task CleanDatabaseAsync(){await using var context = CreateDbContext();// 删除所有数据,保持表结构await context.OrderItems.ExecuteDeleteAsync();await context.Orders.ExecuteDeleteAsync();await context.Products.ExecuteDeleteAsync();}
}
5.2 创建Collection定义
在 test/TestContainersDemo.IntegrationTests/Fixtures/DatabaseCollection.cs:
namespace TestContainersDemo.IntegrationTests.Fixtures;[CollectionDefinition(CollectionName)]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{public const string CollectionName = "Database Collection";
}
6. 实现Repository层集成测试
6.1 创建测试基类
在 test/TestContainersDemo.IntegrationTests/Common/BaseIntegrationTest.cs:
using TestContainersDemo.IntegrationTests.Fixtures;namespace TestContainersDemo.IntegrationTests.Common;[Collection(DatabaseCollection.CollectionName)]
public abstract class BaseIntegrationTest : IAsyncLifetime
{protected readonly DatabaseFixture DatabaseFixture;protected BaseIntegrationTest(DatabaseFixture databaseFixture){DatabaseFixture = databaseFixture;}public virtual Task InitializeAsync(){return Task.CompletedTask;}public virtual async Task DisposeAsync(){// 每个测试后清理数据库await DatabaseFixture.CleanDatabaseAsync();}
}
6.2 实现Product Repository测试
在 test/TestContainersDemo.IntegrationTests/Repositories/ProductRepositoryTests.cs:
using AutoFixture;
using TestContainersDemo.Core.Entities;
using TestContainersDemo.Infrastructure.Repositories;
using TestContainersDemo.IntegrationTests.Common;
using TestContainersDemo.IntegrationTests.Fixtures;namespace TestContainersDemo.IntegrationTests.Repositories;public class ProductRepositoryTests : BaseIntegrationTest
{private readonly Fixture _fixture;public ProductRepositoryTests(DatabaseFixture databaseFixture) : base(databaseFixture){_fixture = new Fixture();}[Fact]public async Task AddAsync_ShouldCreateProduct_WhenValidProductProvided(){// Arrangeawait using var context = DatabaseFixture.CreateDbContext();var repository = new ProductRepository(context);var product = _fixture.Build<Product>().Without(p => p.Id).Without(p => p.CreatedAt).Without(p => p.UpdatedAt).Create();// Actvar result = await repository.AddAsync(product);// AssertAssert.True(result.Id > 0);Assert.NotEqual(DateTime.MinValue, result.CreatedAt);Assert.NotEqual(DateTime.MinValue, result.UpdatedAt);// 验证数据库中确实存储了数据var savedProduct = await repository.GetByIdAsync(result.Id);Assert.NotNull(savedProduct);Assert.Equal(product.Name, savedProduct.Name);Assert.Equal(product.Price, savedProduct.Price);}[Fact]public async Task GetByIdAsync_ShouldReturnProduct_WhenProductExists(){// Arrangeawait using var context = DatabaseFixture.CreateDbContext();var repository = new ProductRepository(context);var product = _fixture.Build<Product>().Without(p => p.Id).Without(p => p.CreatedAt).Without(p => p.UpdatedAt).Create();var savedProduct = await repository.AddAsync(product);// Actvar result = await repository.GetByIdAsync(savedProduct.Id);// AssertAssert.NotNull(result);Assert.Equal(savedProduct.Id, result.Id);Assert.Equal(savedProduct.Name, result.Name);Assert.Equal(savedProduct.Price, result.Price);}[Fact]public async Task GetByIdAsync_ShouldReturnNull_WhenProductNotExists(){// Arrangeawait using var context = DatabaseFixture.CreateDbContext();var repository = new ProductRepository(context);// Actvar result = await repository.GetByIdAsync(999);// AssertAssert.Null(result);}[Fact]public async Task UpdateAsync_ShouldModifyProduct_WhenValidDataProvided(){// Arrangeawait using var context = DatabaseFixture.CreateDbContext();var repository = new ProductRepository(context);var product = _fixture.Build<Product>().Without(p => p.Id).Without(p => p.CreatedAt).Without(p => p.UpdatedAt).Create();var savedProduct = await repository.AddAsync(product);var originalUpdatedAt = savedProduct.UpdatedAt;// 等待一小段时间确保时间戳不同await Task.Delay(10);// ActsavedProduct.Name = "Updated Product Name";savedProduct.Price = 999.99m;await repository.UpdateAsync(savedProduct);// Assertvar updatedProduct = await repository.GetByIdAsync(savedProduct.Id);Assert.NotNull(updatedProduct);Assert.Equal("Updated Product Name", updatedProduct.Name);Assert.Equal(999.99m, updatedProduct.Price);Assert.True(updatedProduct.UpdatedAt > originalUpdatedAt);}[Fact]public async Task DeleteAsync_ShouldRemoveProduct_WhenProductExists(){// Arrangeawait using var context = DatabaseFixture.CreateDbContext();var repository = new ProductRepository(context);var product = _fixture.Build<Product>().Without(p => p.Id).Without(p => p.CreatedAt).Without(p => p.UpdatedAt).Create();var savedProduct = await repository.AddAsync(product);// Actawait repository.DeleteAsync(savedProduct.Id);// Assertvar deletedProduct = await repository.GetByIdAsync(savedProduct.Id);Assert.Null(deletedProduct);}[Fact]public async Task GetAllAsync_ShouldReturnAllProducts_WhenProductsExist(){// Arrangeawait using var context = DatabaseFixture.CreateDbContext();var repository = new ProductRepository(context);var products = _fixture.Build<Product>().Without(p => p.Id).Without(p => p.CreatedAt).Without(p => p.UpdatedAt).CreateMany(3).ToList();foreach (var product in products){await repository.AddAsync(product);}// Actvar result = await repository.GetAllAsync();// AssertAssert.Equal(3, result.Count);Assert.All(result, p => Assert.True(p.Id > 0));}[Theory][InlineData(true)][InlineData(false)]public async Task ExistsAsync_ShouldReturnCorrectResult_BasedOnProductExistence(bool shouldExist){// Arrangeawait using var context = DatabaseFixture.CreateDbContext();var repository = new ProductRepository(context);int testId = shouldExist ? await CreateTestProduct(repository) : 999;// Actvar result = await repository.ExistsAsync(testId);// AssertAssert.Equal(shouldExist, result);}private async Task<int> CreateTestProduct(ProductRepository repository){var product = _fixture.Build<Product>().Without(p => p.Id).Without(p => p.CreatedAt).Without(p => p.UpdatedAt).Create();var savedProduct = await repository.AddAsync(product);return savedProduct.Id;}
}
6.3 实现Order Repository测试
在 test/TestContainersDemo.IntegrationTests/Repositories/OrderRepositoryTests.cs:
using AutoFixture;
using TestContainersDemo.Core.Entities;
using TestContainersDemo.Infrastructure.Repositories;
using TestContainersDemo.IntegrationTests.Common;
using TestContainersDemo.IntegrationTests.Fixtures;namespace TestContainersDemo.IntegrationTests.Repositories;public class OrderRepositoryTests : BaseIntegrationTest
{private readonly Fixture _fixture;public OrderRepositoryTests(DatabaseFixture databaseFixture) : base(databaseFixture){_fixture = new Fixture();}[Fact]public async Task AddAsync_ShouldCreateOrder_WhenValidOrderProvided(){// Arrangeawait using var context = DatabaseFixture.CreateDbContext();var orderRepository = new OrderRepository(context);var productRepository = new ProductRepository(context);// 创建测试产品var product = await CreateTestProduct(productRepository);var order = _fixture.Build<Order>().Without(o => o.Id).Without(o => o.CreatedAt).Without(o => o.Items).Create();order.Items = new List<OrderItem>{new OrderItem{ProductId = product.Id,Quantity = 2,Price = product.Price}};order.TotalAmount = order.Items.Sum(i => i.Price * i.Quantity);// Actvar result = await orderRepository.AddAsync(order);// AssertAssert.True(result.Id > 0);Assert.NotEqual(DateTime.MinValue, result.CreatedAt);Assert.Single(result.Items);// 验证关联加载var savedOrder = await orderRepository.GetByIdAsync(result.Id);Assert.NotNull(savedOrder);Assert.Single(savedOrder.Items);Assert.Equal(product.Id, savedOrder.Items.First().ProductId);Assert.NotNull(savedOrder.Items.First().Product);}[Fact]public async Task GetByCustomerEmailAsync_ShouldReturnOrdersForCustomer_WhenOrdersExist(){// Arrangeawait using var context = DatabaseFixture.CreateDbContext();var orderRepository = new OrderRepository(context);var productRepository = new ProductRepository(context);var product = await CreateTestProduct(productRepository);var customerEmail = "test@example.com";// 创建同一客户的多个订单for (int i = 0; i < 3; i++){var order = CreateTestOrder(customerEmail, product.Id, product.Price);await orderRepository.AddAsync(order);}// 创建其他客户的订单var otherOrder = CreateTestOrder("other@example.com", product.Id, product.Price);await orderRepository.AddAsync(otherOrder);// Actvar result = await orderRepository.GetByCustomerEmailAsync(customerEmail);// AssertAssert.Equal(3, result.Count);Assert.All(result, o => Assert.Equal(customerEmail, o.CustomerEmail));}[Fact]public async Task UpdateAsync_ShouldModifyOrder_WhenValidDataProvided(){// Arrangeawait using var context = DatabaseFixture.CreateDbContext();var orderRepository = new OrderRepository(context);var productRepository = new ProductRepository(context);var product = await CreateTestProduct(productRepository);var order = CreateTestOrder("test@example.com", product.Id, product.Price);var savedOrder = await orderRepository.AddAsync(order);// ActsavedOrder.Status = OrderStatus.Processing;await orderRepository.UpdateAsync(savedOrder);// Assertvar updatedOrder = await orderRepository.GetByIdAsync(savedOrder.Id);Assert.NotNull(updatedOrder);Assert.Equal(OrderStatus.Processing, updatedOrder.Status);}private async Task<Product> CreateTestProduct(ProductRepository repository){var product = _fixture.Build<Product>().Without(p => p.Id).Without(p => p.CreatedAt).Without(p => p.UpdatedAt).Create();return await repository.AddAsync(product);}private Order CreateTestOrder(string customerEmail, int productId, decimal productPrice){var order = _fixture.Build<Order>().Without(o => o.Id).Without(o => o.CreatedAt).Without(o => o.Items).With(o => o.CustomerEmail, customerEmail).Create();order.Items = new List<OrderItem>{new OrderItem{ProductId = productId,Quantity = 1,Price = productPrice}};order.TotalAmount = order.Items.Sum(i => i.Price * i.Quantity);return order;}
}
7. 添加Web API集成测试
7.1 创建API控制器
在 src/TestContainersDemo.Api/Controllers/ProductsController.cs:
using Microsoft.AspNetCore.Mvc;
using TestContainersDemo.Core.Entities;
using TestContainersDemo.Core.Repositories;namespace TestContainersDemo.Api.Controllers;[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{private readonly IProductRepository _productRepository;public ProductsController(IProductRepository productRepository){_productRepository = productRepository;}[HttpGet]public async Task<ActionResult<List<Product>>> GetAll(){var products = await _productRepository.GetAllAsync();return Ok(products);}[HttpGet("{id}")]public async Task<ActionResult<Product>> GetById(int id){var product = await _productRepository.GetByIdAsync(id);if (product == null){return NotFound();}return Ok(product);}[HttpPost]public async Task<ActionResult<Product>> Create([FromBody] CreateProductRequest request){var product = new Product{Name = request.Name,Description = request.Description,Price = request.Price,StockQuantity = request.StockQuantity};var createdProduct = await _productRepository.AddAsync(product);return CreatedAtAction(nameof(GetById), new { id = createdProduct.Id }, createdProduct);}[HttpPut("{id}")]public async Task<IActionResult> Update(int id, [FromBody] UpdateProductRequest request){var product = await _productRepository.GetByIdAsync(id);if (product == null){return NotFound();}product.Name = request.Name;product.Description = request.Description;product.Price = request.Price;product.StockQuantity = request.StockQuantity;await _productRepository.UpdateAsync(product);return NoContent();}[HttpDelete("{id}")]public async Task<IActionResult> Delete(int id){if (!await _productRepository.ExistsAsync(id)){return NotFound();}await _productRepository.DeleteAsync(id);return NoContent();}
}public record CreateProductRequest(string Name, string Description, decimal Price, int StockQuantity);
public record UpdateProductRequest(string Name, string Description, decimal Price, int StockQuantity);
7.2 配置API项目
更新 src/TestContainersDemo.Api/Program.cs:
using Microsoft.EntityFrameworkCore;
using TestContainersDemo.Core.Repositories;
using TestContainersDemo.Infrastructure.Data;
using TestContainersDemo.Infrastructure.Repositories;var builder = WebApplication.CreateBuilder(args);// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();// Database
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");options.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0)));
});// Repositories
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();var app = builder.Build();// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{app.UseSwagger();app.UseSwaggerUI();
}app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();app.Run();// 为测试项目暴露Program类
public partial class Program { }
7.3 创建Web应用程序测试工厂
在 test/TestContainersDemo.IntegrationTests/Fixtures/WebApplicationTestFactory.cs:
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using TestContainersDemo.Infrastructure.Data;
using Testcontainers.MySql;namespace TestContainersDemo.IntegrationTests.Fixtures;public class WebApplicationTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{private readonly MySqlContainer _mySqlContainer;public WebApplicationTestFactory(){_mySqlContainer = new MySqlBuilder().WithImage("mysql:8.0").WithDatabase("testdb").WithUsername("testuser").WithPassword("testpass").WithEnvironment("TZ", "UTC").WithCleanUp(true).Build();}public string ConnectionString => _mySqlContainer.GetConnectionString();protected override void ConfigureWebHost(IWebHostBuilder builder){builder.ConfigureServices(services =>{// 移除原有的DbContext注册var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));if (descriptor != null){services.Remove(descriptor);}// 注册测试用的DbContextservices.AddDbContext<ApplicationDbContext>(options =>{options.UseMySql(ConnectionString, new MySqlServerVersion(new Version(8, 0)));options.EnableSensitiveDataLogging();options.EnableDetailedErrors();});});builder.UseEnvironment("Testing");base.ConfigureWebHost(builder);}public async Task InitializeAsync(){await _mySqlContainer.StartAsync();// 确保数据库架构已创建using var scope = Services.CreateScope();var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();await context.Database.EnsureCreatedAsync();}public new async Task DisposeAsync(){await _mySqlContainer.DisposeAsync();await base.DisposeAsync();}public async Task CleanDatabaseAsync(){using var scope = Services.CreateScope();var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();await context.OrderItems.ExecuteDeleteAsync();await context.Orders.ExecuteDeleteAsync();await context.Products.ExecuteDeleteAsync();}
}
7.4 创建API集成测试
在 test/TestContainersDemo.IntegrationTests/Controllers/ProductsControllerTests.cs:
using System.Net;
using System.Net.Http.Json;
using AutoFixture;
using TestContainersDemo.Api.Controllers;
using TestContainersDemo.Core.Entities;
using TestContainersDemo.IntegrationTests.Fixtures;namespace TestContainersDemo.IntegrationTests.Controllers;public class ProductsControllerTests : IClassFixture<WebApplicationTestFactory>, IAsyncLifetime
{private readonly WebApplicationTestFactory _factory;private readonly HttpClient _client;private readonly Fixture _fixture;public ProductsControllerTests(WebApplicationTestFactory factory){_factory = factory;_client = _factory.CreateClient();_fixture = new Fixture();}public Task InitializeAsync() => Task.CompletedTask;public async Task DisposeAsync(){await _factory.CleanDatabaseAsync();}[Fact]public async Task GetAll_ShouldReturnEmptyList_WhenNoProductsExist(){// Actvar response = await _client.GetAsync("/api/products");// Assertresponse.EnsureSuccessStatusCode();var products = await response.Content.ReadFromJsonAsync<List<Product>>();Assert.NotNull(products);Assert.Empty(products);}[Fact]public async Task Create_ShouldReturnCreatedProduct_WhenValidDataProvided(){// Arrangevar request = new CreateProductRequest("Test Product", "Test Description", 99.99m, 10);// Actvar response = await _client.PostAsJsonAsync("/api/products", request);// AssertAssert.Equal(HttpStatusCode.Created, response.StatusCode);var product = await response.Content.ReadFromJsonAsync<Product>();Assert.NotNull(product);Assert.True(product.Id > 0);Assert.Equal(request.Name, product.Name);Assert.Equal(request.Price, product.Price);// 验证Location headerAssert.NotNull(response.Headers.Location);Assert.Contains(product.Id.ToString(), response.Headers.Location.ToString());}[Fact]public async Task GetById_ShouldReturnProduct_WhenProductExists(){// Arrangevar createdProduct = await CreateTestProduct();// Actvar response = await _client.GetAsync($"/api/products/{createdProduct.Id}");// Assertresponse.EnsureSuccessStatusCode();var product = await response.Content.ReadFromJsonAsync<Product>();Assert.NotNull(product);Assert.Equal(createdProduct.Id, product.Id);Assert.Equal(createdProduct.Name, product.Name);}[Fact]public async Task GetById_ShouldReturnNotFound_WhenProductNotExists(){// Actvar response = await _client.GetAsync("/api/products/999");// AssertAssert.Equal(HttpStatusCode.NotFound, response.StatusCode);}[Fact]public async Task Update_ShouldReturnNoContent_WhenValidDataProvided(){// Arrangevar createdProduct = await CreateTestProduct();var updateRequest = new UpdateProductRequest("Updated Product", "Updated Description", 199.99m, 20);// Actvar response = await _client.PutAsJsonAsync($"/api/products/{createdProduct.Id}", updateRequest);// AssertAssert.Equal(HttpStatusCode.NoContent, response.StatusCode);// 验证产品确实已更新var getResponse = await _client.GetAsync($"/api/products/{createdProduct.Id}");var updatedProduct = await getResponse.Content.ReadFromJsonAsync<Product>();Assert.NotNull(updatedProduct);Assert.Equal("Updated Product", updatedProduct.Name);Assert.Equal(199.99m, updatedProduct.Price);}[Fact]public async Task Update_ShouldReturnNotFound_WhenProductNotExists(){// Arrangevar updateRequest = new UpdateProductRequest("Updated Product", "Updated Description", 199.99m, 20);// Actvar response = await _client.PutAsJsonAsync("/api/products/999", updateRequest);// AssertAssert.Equal(HttpStatusCode.NotFound, response.StatusCode);}[Fact]public async Task Delete_ShouldReturnNoContent_WhenProductExists(){// Arrangevar createdProduct = await CreateTestProduct();// Actvar response = await _client.DeleteAsync($"/api/products/{createdProduct.Id}");// AssertAssert.Equal(HttpStatusCode.NoContent, response.StatusCode);// 验证产品已被删除var getResponse = await _client.GetAsync($"/api/products/{createdProduct.Id}");Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);}[Fact]public async Task Delete_ShouldReturnNotFound_WhenProductNotExists(){// Actvar response = await _client.DeleteAsync("/api/products/999");// AssertAssert.Equal(HttpStatusCode.NotFound, response.StatusCode);}[Fact]public async Task GetAll_ShouldReturnAllProducts_WhenProductsExist(){// Arrangeawait CreateTestProduct();await CreateTestProduct();await CreateTestProduct();// Actvar response = await _client.GetAsync("/api/products");// Assertresponse.EnsureSuccessStatusCode();var products = await response.Content.ReadFromJsonAsync<List<Product>>();Assert.NotNull(products);Assert.Equal(3, products.Count);}private async Task<Product> CreateTestProduct(){var request = _fixture.Build<CreateProductRequest>().With(r => r.Price, _fixture.Create<decimal>() % 1000) // 限制价格范围.Create();var response = await _client.PostAsJsonAsync("/api/products", request);response.EnsureSuccessStatusCode();var product = await response.Content.ReadFromJsonAsync<Product>();Assert.NotNull(product);return product;}
}
8. 实现多容器编排
8.1 创建Redis缓存服务
在 src/TestContainersDemo.Core/Services/ICacheService.cs:
namespace TestContainersDemo.Core.Services;public interface ICacheService
{Task<T?> GetAsync<T>(string key) where T : class;Task SetAsync<T>(string key, T value, TimeSpan? expiration = null) where T : class;Task RemoveAsync(string key);Task<bool> ExistsAsync(string key);
}
在 src/TestContainersDemo.Infrastructure/Services/RedisCacheService.cs:
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using TestContainersDemo.Core.Services;namespace TestContainersDemo.Infrastructure.Services;public class RedisCacheService : ICacheService
{private readonly IDistributedCache _distributedCache;public RedisCacheService(IDistributedCache distributedCache){_distributedCache = distributedCache;}public async Task<T?> GetAsync<T>(string key) where T : class{var cachedValue = await _distributedCache.GetStringAsync(key);if (string.IsNullOrEmpty(cachedValue)){return null;}return JsonSerializer.Deserialize<T>(cachedValue);}public async Task SetAsync<T>(string key, T value, TimeSpan? expiration = null) where T : class{var serializedValue = JsonSerializer.Serialize(value);var options = new DistributedCacheEntryOptions();if (expiration.HasValue){options.SetAbsoluteExpiration(expiration.Value);}await _distributedCache.SetStringAsync(key, serializedValue, options);}public async Task RemoveAsync(string key){await _distributedCache.RemoveAsync(key);}public async Task<bool> ExistsAsync(string key){var value = await _distributedCache.GetStringAsync(key);return !string.IsNullOrEmpty(value);}
}
8.2 创建多容器测试固件
在 test/TestContainersDemo.IntegrationTests/Fixtures/MultiContainerFixture.cs:
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using TestContainersDemo.Infrastructure.Data;
using TestContainersDemo.Infrastructure.Services;
using Testcontainers.MySql;
using Testcontainers.Redis;namespace TestContainersDemo.IntegrationTests.Fixtures;public class MultiContainerFixture : IAsyncLifetime
{private readonly MySqlContainer _mySqlContainer;private readonly RedisContainer _redisContainer;private ServiceProvider? _serviceProvider;public MultiContainerFixture(){_mySqlContainer = new MySqlBuilder().WithImage("mysql:8.0").WithDatabase("testdb").WithUsername("testuser").WithPassword("testpass").WithEnvironment("TZ", "UTC").WithCleanUp(true).Build();_redisContainer = new RedisBuilder().WithImage("redis:7-alpine").WithCleanUp(true).Build();}public string MySqlConnectionString => _mySqlContainer.GetConnectionString();public string RedisConnectionString => _redisContainer.GetConnectionString();public async Task InitializeAsync(){// 并行启动所有容器await Task.WhenAll(_mySqlContainer.StartAsync(),_redisContainer.StartAsync());// 配置服务await ConfigureServices();// 确保数据库架构已创建await using var scope = _serviceProvider!.CreateAsyncScope();var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();await context.Database.EnsureCreatedAsync();}public async Task DisposeAsync(){_serviceProvider?.Dispose();await Task.WhenAll(_mySqlContainer.DisposeAsync().AsTask(),_redisContainer.DisposeAsync().AsTask());}public T GetService<T>() where T : notnull{return _serviceProvider!.GetRequiredService<T>();}public ApplicationDbContext CreateDbContext(){return GetService<ApplicationDbContext>();}public RedisCacheService GetCacheService(){return GetService<RedisCacheService>();}public async Task CleanAllAsync(){// 清理数据库await using var context = CreateDbContext();await context.OrderItems.ExecuteDeleteAsync();await context.Orders.ExecuteDeleteAsync();await context.Products.ExecuteDeleteAsync();// 清理Redis缓存var connectionMultiplexer = GetService<IConnectionMultiplexer>();var server = connectionMultiplexer.GetServer(connectionMultiplexer.GetEndPoints().First());await server.FlushAllDatabasesAsync();}private async Task ConfigureServices(){var services = new ServiceCollection();// 配置日志services.AddLogging(builder => builder.AddConsole());// 配置数据库services.AddDbContext<ApplicationDbContext>(options =>{options.UseMySql(MySqlConnectionString, new MySqlServerVersion(new Version(8, 0)));options.EnableSensitiveDataLogging();options.EnableDetailedErrors();});// 配置Redisservices.AddStackExchangeRedisCache(options =>{options.Configuration = RedisConnectionString;});// 注册Redis连接services.AddSingleton<IConnectionMultiplexer>(sp =>{var configuration = ConfigurationOptions.Parse(RedisConnectionString);return ConnectionMultiplexer.Connect(configuration);});// 注册缓存服务services.AddScoped<RedisCacheService>();_serviceProvider = services.BuildServiceProvider();// 等待Redis连接就绪var connectionMultiplexer = _serviceProvider.GetRequiredService<IConnectionMultiplexer>();var database = connectionMultiplexer.GetDatabase();await database.PingAsync();}
}
8.3 创建多容器集成测试
在 test/TestContainersDemo.IntegrationTests/Services/CacheServiceIntegrationTests.cs:
using AutoFixture;
using TestContainersDemo.Core.Entities;
using TestContainersDemo.IntegrationTests.Fixtures;namespace TestContainersDemo.IntegrationTests.Services;public class CacheServiceIntegrationTests : IClassFixture<MultiContainerFixture>, IAsyncLifetime
{private readonly MultiContainerFixture _fixture;private readonly Fixture _autoFixture;public CacheServiceIntegrationTests(MultiContainerFixture fixture){_fixture = fixture;_autoFixture = new Fixture();}public Task InitializeAsync() => Task.CompletedTask;public async Task DisposeAsync(){await _fixture.CleanAllAsync();}[Fact]public async Task SetAsync_ShouldStoreValue_WhenValidDataProvided(){// Arrangevar cacheService = _fixture.GetCacheService();var product = _autoFixture.Create<Product>();var key = $"product:{product.Id}";// Actawait cacheService.SetAsync(key, product);// Assertvar cachedProduct = await cacheService.GetAsync<Product>(key);Assert.NotNull(cachedProduct);Assert.Equal(product.Id, cachedProduct.Id);Assert.Equal(product.Name, cachedProduct.Name);Assert.Equal(product.Price, cachedProduct.Price);}[Fact]public async Task GetAsync_ShouldReturnNull_WhenKeyNotExists(){// Arrangevar cacheService = _fixture.GetCacheService();// Actvar result = await cacheService.GetAsync<Product>("non-existent-key");// AssertAssert.Null(result);}[Fact]public async Task SetAsync_WithExpiration_ShouldExpireAfterTimeout(){// Arrangevar cacheService = _fixture.GetCacheService();var product = _autoFixture.Create<Product>();var key = $"temp-product:{product.Id}";// Actawait cacheService.SetAsync(key, product, TimeSpan.FromMilliseconds(100));// 立即检查应该存在var immediateResult = await cacheService.GetAsync<Product>(key);Assert.NotNull(immediateResult);// 等待过期await Task.Delay(200);// Assertvar expiredResult = await cacheService.GetAsync<Product>(key);Assert.Null(expiredResult);}[Fact]public async Task RemoveAsync_ShouldDeleteValue_WhenKeyExists(){// Arrangevar cacheService = _fixture.GetCacheService();var product = _autoFixture.Create<Product>();var key = $"product:{product.Id}";await cacheService.SetAsync(key, product);// 确认值已存储var storedProduct = await cacheService.GetAsync<Product>(key);Assert.NotNull(storedProduct);// Actawait cacheService.RemoveAsync(key);// Assertvar removedProduct = await cacheService.GetAsync<Product>(key);Assert.Null(removedProduct);}[Fact]public async Task ExistsAsync_ShouldReturnTrue_WhenKeyExists(){// Arrangevar cacheService = _fixture.GetCacheService();var product = _autoFixture.Create<Product>();var key = $"product:{product.Id}";await cacheService.SetAsync(key, product);// Actvar exists = await cacheService.ExistsAsync(key);// AssertAssert.True(exists);}[Fact]public async Task ExistsAsync_ShouldReturnFalse_WhenKeyNotExists(){// Arrangevar cacheService = _fixture.GetCacheService();// Actvar exists = await cacheService.ExistsAsync("non-existent-key");// AssertAssert.False(exists);}[Fact]public async Task CacheService_ShouldHandleComplexObjects_WithNestedProperties(){// Arrangevar cacheService = _fixture.GetCacheService();var order = _autoFixture.Build<Order>().With(o => o.Items, _autoFixture.CreateMany<OrderItem>(3).ToList()).Create();var key = $"order:{order.Id}";// Actawait cacheService.SetAsync(key, order);// Assertvar cachedOrder = await cacheService.GetAsync<Order>(key);Assert.NotNull(cachedOrder);Assert.Equal(order.Id, cachedOrder.Id);Assert.Equal(order.CustomerName, cachedOrder.CustomerName);Assert.Equal(order.Items.Count, cachedOrder.Items.Count);Assert.Equal(order.TotalAmount, cachedOrder.TotalAmount);}
}
9. 性能优化和最佳实践
9.1 创建测试配置文件
在 test/TestContainersDemo.IntegrationTests/xunit.runner.json:
{"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json","parallelizeAssembly": true,"parallelizeTestCollections": false,"maxParallelThreads": 4,"preEnumerateTheories": true,"shadowCopy": false,"longRunningTestSeconds": 60
}
9.2 创建性能测试基类
在 test/TestContainersDemo.IntegrationTests/Common/PerformanceTestBase.cs:
using System.Diagnostics;
using TestContainersDemo.IntegrationTests.Fixtures;namespace TestContainersDemo.IntegrationTests.Common;[Collection(DatabaseCollection.CollectionName)]
public abstract class PerformanceTestBase : BaseIntegrationTest
{private readonly Stopwatch _stopwatch = new();protected PerformanceTestBase(DatabaseFixture databaseFixture) : base(databaseFixture){}public override Task InitializeAsync(){_stopwatch.Start();return base.InitializeAsync();}public override async Task DisposeAsync(){_stopwatch.Stop();// 记录测试执行时间var testName = GetType().Name;Console.WriteLine($"[{testName}] Execution time: {_stopwatch.ElapsedMilliseconds}ms");if (_stopwatch.ElapsedMilliseconds > 5000) // 5秒警告阈值{Console.WriteLine($"[WARNING] {testName} took longer than expected: {_stopwatch.ElapsedMilliseconds}ms");}await base.DisposeAsync();}protected async Task<T> MeasureAsync<T>(Func<Task<T>> action, string operationName){var sw = Stopwatch.StartNew();try{var result = await action();return result;}finally{sw.Stop();Console.WriteLine($"[{operationName}] Execution time: {sw.ElapsedMilliseconds}ms");}}
}
9.3 创建容器健康检查工具
在 test/TestContainersDemo.IntegrationTests/Common/ContainerHealthChecker.cs:
using Microsoft.Extensions.Logging;
using MySqlConnector;
using StackExchange.Redis;namespace TestContainersDemo.IntegrationTests.Common;public static class ContainerHealthChecker
{public static async Task<bool> WaitForMySqlHealthyAsync(string connectionString, TimeSpan timeout,ILogger? logger = null){var stopwatch = Stopwatch.StartNew();while (stopwatch.Elapsed < timeout){try{using var connection = new MySqlConnection(connectionString);await connection.OpenAsync();using var command = connection.CreateCommand();command.CommandText = "SELECT 1";await command.ExecuteScalarAsync();logger?.LogInformation("MySQL container is healthy");return true;}catch (Exception ex){logger?.LogWarning($"MySQL health check failed: {ex.Message}");await Task.Delay(500);}}logger?.LogError($"MySQL container failed to become healthy within {timeout}");return false;}public static async Task<bool> WaitForRedisHealthyAsync(string connectionString, TimeSpan timeout,ILogger? logger = null){var stopwatch = Stopwatch.StartNew();while (stopwatch.Elapsed < timeout){try{var options = ConfigurationOptions.Parse(connectionString);using var connection = await ConnectionMultiplexer.ConnectAsync(options);var database = connection.GetDatabase();await database.PingAsync();logger?.LogInformation("Redis container is healthy");return true;}catch (Exception ex){logger?.LogWarning($"Redis health check failed: {ex.Message}");await Task.Delay(500);}}logger?.LogError($"Redis container failed to become healthy within {timeout}");return false;}
}
9.4 更新数据库固件以包含健康检查
更新 test/TestContainersDemo.IntegrationTests/Fixtures/DatabaseFixture.cs:
// 在 InitializeAsync 方法中添加健康检查
public async Task InitializeAsync()
{await _mySqlContainer.StartAsync();// 等待容器健康var logger = LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger<DatabaseFixture>();var isHealthy = await ContainerHealthChecker.WaitForMySqlHealthyAsync(ConnectionString, TimeSpan.FromSeconds(30), logger);if (!isHealthy){throw new InvalidOperationException("MySQL container failed to start properly");}// 确保数据库架构已创建await using var context = CreateDbContext();await context.Database.EnsureCreatedAsync();
}
10. CI/CD集成配置
10.1 创建GitHub Actions工作流
在 .github/workflows/integration-tests.yml:
name: Integration Testson:push:branches: [ main, develop ]pull_request:branches: [ main, develop ]jobs:integration-tests:runs-on: ubuntu-lateststrategy:matrix:dotnet-version: [ '8.0.x' ]steps:- name: Checkout codeuses: actions/checkout@v4- name: Setup .NETuses: actions/setup-dotnet@v3with:dotnet-version: ${{ matrix.dotnet-version }}- name: Cache NuGet packagesuses: actions/cache@v3with:path: ~/.nuget/packageskey: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}restore-keys: |${{ runner.os }}-nuget-- name: Restore dependenciesrun: dotnet restore- name: Build solutionrun: dotnet build --no-restore --configuration Release- name: Run unit testsrun: |dotnet test test/TestContainersDemo.UnitTests \--no-build \--configuration Release \--verbosity normal \--logger trx \--results-directory TestResults/Unit- name: Run integration testsrun: |dotnet test test/TestContainersDemo.IntegrationTests \--no-build \--configuration Release \--verbosity normal \--logger trx \--results-directory TestResults/Integrationenv:# TestContainers 需要 DockerDOCKER_HOST: unix:///var/run/docker.sock- name: Upload test resultsuses: actions/upload-artifact@v3if: always()with:name: test-resultspath: TestResults/- name: Docker system pruneif: always()run: docker system prune -f
10.2 创建本地开发脚本
在 scripts/run-tests.sh:
#!/bin/bash# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Colorecho -e "${YELLOW}Starting TestContainers Demo Tests...${NC}"# 检查Docker是否运行
if ! docker info > /dev/null 2>&1; thenecho -e "${RED}Error: Docker is not running. Please start Docker and try again.${NC}"exit 1
fi# 清理之前的容器
echo -e "${YELLOW}Cleaning up previous containers...${NC}"
docker container prune -f
docker network prune -f# 构建解决方案
echo -e "${YELLOW}Building solution...${NC}"
dotnet build --configuration Release
if [ $? -ne 0 ]; thenecho -e "${RED}Build failed!${NC}"exit 1
fi# 运行单元测试
echo -e "${YELLOW}Running unit tests...${NC}"
dotnet test test/TestContainersDemo.UnitTests \--no-build \--configuration Release \--verbosity normalif [ $? -ne 0 ]; thenecho -e "${RED}Unit tests failed!${NC}"exit 1
fi# 运行集成测试
echo -e "${YELLOW}Running integration tests...${NC}"
dotnet test test/TestContainersDemo.IntegrationTests \--no-build \--configuration Release \--verbosity normalif [ $? -eq 0 ]; thenecho -e "${GREEN}All tests passed!${NC}"
elseecho -e "${RED}Integration tests failed!${NC}"exit 1
fi# 清理测试容器
echo -e "${YELLOW}Cleaning up test containers...${NC}"
docker container prune -fecho -e "${GREEN}Test execution completed successfully!${NC}"
在 scripts/run-tests.ps1 (Windows PowerShell):
# PowerShell script for WindowsWrite-Host "Starting TestContainers Demo Tests..." -ForegroundColor Yellow# 检查Docker是否运行
$dockerInfo = docker info 2>$null
if ($LASTEXITCODE -ne 0) {Write-Host "Error: Docker is not running. Please start Docker and try again." -ForegroundColor Redexit 1
}# 清理之前的容器
Write-Host "Cleaning up previous containers..." -ForegroundColor Yellow
docker container prune -f
docker network prune -f# 构建解决方案
Write-Host "Building solution..." -ForegroundColor Yellow
dotnet build --configuration Release
if ($LASTEXITCODE -ne 0) {Write-Host "Build failed!" -ForegroundColor Redexit 1
}# 运行单元测试
Write-Host "Running unit tests..." -ForegroundColor Yellow
dotnet test test/TestContainersDemo.UnitTests --no-build --configuration Release --verbosity normal
if ($LASTEXITCODE -ne 0) {Write-Host "Unit tests failed!" -ForegroundColor Redexit 1
}# 运行集成测试
Write-Host "Running integration tests..." -ForegroundColor Yellow
dotnet test test/TestContainersDemo.IntegrationTests --no-build --configuration Release --verbosity normalif ($LASTEXITCODE -eq 0) {Write-Host "All tests passed!" -ForegroundColor Green
} else {Write-Host "Integration tests failed!" -ForegroundColor Redexit 1
}# 清理测试容器
Write-Host "Cleaning up test containers..." -ForegroundColor Yellow
docker container prune -fWrite-Host "Test execution completed successfully!" -ForegroundColor Green
10.3 创建开发环境配置
在 .vscode/tasks.json:
{"version": "2.0.0","tasks": [{"label": "build","command": "dotnet","type": "process","args": ["build","${workspaceFolder}","/property:GenerateFullPaths=true","/consoleloggerparameters:NoSummary"],"problemMatcher": "$msCompile"},{"label": "run-unit-tests","command": "dotnet","type": "process","args": ["test","${workspaceFolder}/test/TestContainersDemo.UnitTests","--verbosity","normal"],"group": "test","presentation": {"echo": true,"reveal": "always","focus": false,"panel": "shared"}},{"label": "run-integration-tests","command": "dotnet","type": "process","args": ["test","${workspaceFolder}/test/TestContainersDemo.IntegrationTests","--verbosity","normal"],"group": "test","presentation": {"echo": true,"reveal": "always","focus": false,"panel": "shared"}},{"label": "run-all-tests","dependsOrder": "sequence","dependsOn": ["build","run-unit-tests", "run-integration-tests"]}]
}
在 .vscode/launch.json:
{"version": "0.2.0","configurations": [{"name": ".NET Core Launch (web)","type": "coreclr","request": "launch","preLaunchTask": "build","program": "${workspaceFolder}/src/TestContainersDemo.Api/bin/Debug/net8.0/TestContainersDemo.Api.dll","args": [],"cwd": "${workspaceFolder}/src/TestContainersDemo.Api","stopAtEntry": false,"serverReadyAction": {"action": "openExternally","pattern": "\\bNow listening on:\\s+(https?://\\S+)"},"env": {"ASPNETCORE_ENVIRONMENT": "Development"},"sourceFileMap": {"/Views": "${workspaceFolder}/Views"}}]
}
总结
通过这个分步教程,您已经完成了:
✅ 完成的功能
- 项目基础设施 - 完整的.NET 8解决方案架构
- 数据库集成 - MySQL + Entity Framework Core
- 缓存系统 - Redis集成和缓存服务
- 测试架构 - 单元测试 + 集成测试
- 容器编排 - 多容器TestContainers设置
- Web API测试 - 完整的HTTP API集成测试
- 性能优化 - 容器复用、并行测试、健康检查
- CI/CD集成 - GitHub Actions工作流
🎯 关键特性
- 完全容器化 - 无需本地数据库安装
- 并行测试 - 优化的测试执行性能
- 自动清理 - 测试间的数据隔离
- 健康检查 - 确保容器就绪状态
- 开发友好 - VS Code配置和本地脚本
📈 下一步建议
- 扩展中间件支持 - 添加RabbitMQ、Consul等
- 监控集成 - 添加日志聚合和指标收集
- 安全测试 - 集成安全扫描和认证测试
- 负载测试 - 使用NBomber等工具
- 数据库迁移 - 集成EF Core Migrations测试
这套完整的TestContainers解决方案为您提供了企业级的测试基础设施,可以直接应用到生产项目中。
