【AI Generate】TestContainers从零开始分步教程

使用Claude Code分析了开源框架netcorepal-cloud-framework的集成测试部分,并出具了搭建教程。方便日后开发时搭建

TestContainers初学者详细教程 (SQL Server版本)

从零开始学习容器化测试,包含详细的概念解释和实践指导

📚 目录

  1. 基础概念讲解
  2. 环境准备和项目初始化
  3. 创建基础项目结构
  4. 配置依赖包管理
  5. 实现基础设施层
  6. 创建单容器测试固件
  7. 实现Repository层集成测试
  8. 添加Web API集成测试
  9. 实现多容器编排测试
  10. 性能优化和最佳实践
  11. CI/CD集成配置

1. 基础概念讲解

1.1 什么是TestContainers?

TestContainers 是一个测试库,它让我们可以在测试中使用真实的外部依赖(如数据库、缓存、消息队列等),而不是模拟(Mock)它们。

🤔 为什么需要TestContainers?

传统测试方式的问题:

// ❌ 传统方式:使用Mock模拟数据库
[Test]
public void Test_SaveProduct()
{
    // 这只是假的数据库,不能测试真实的SQL语句、事务等
    var mockRepository = new Mock<IProductRepository>();
    mockRepository.Setup(r => r.Add(It.IsAny<Product>())).Returns(new Product { Id = 1 });
}

TestContainers方式的优势:

// ✅ TestContainers方式:使用真实的SQL Server数据库
[Test]
public void Test_SaveProduct()
{
    // 这是真实的SQL Server数据库,可以测试真实的SQL语句、事务、索引等
    using var sqlContainer = new MsSqlBuilder().Build();
    await sqlContainer.StartAsync();
    // ... 使用真实数据库进行测试
}

📊 对比表格

特性 Mock测试 TestContainers
真实性 模拟行为,可能与实际不符 使用真实数据库,100%真实
SQL测试 无法测试复杂SQL 可以测试所有SQL特性
事务测试 无法测试事务行为 可以测试完整事务逻辑
性能测试 无法测试真实性能 可以进行性能基准测试
维护成本 需要维护Mock逻辑 自动与生产环境保持一致

1.2 什么是容器(Container)?

容器 就像一个"轻量级的虚拟机",它包含了运行应用程序所需的一切:代码、运行时、系统工具、系统库等。

🏠 生活中的类比

想象容器就像集装箱

  • 标准化:无论装什么货物,集装箱的尺寸都是标准的
  • 隔离性:不同集装箱里的货物不会相互影响
  • 可移植:可以在船上、卡车上、火车上使用
  • 一致性:在任何地方打开集装箱,里面的货物都是一样的

💻 技术层面的理解

# 这就是启动一个SQL Server容器
docker run -d \
  --name my-sqlserver \
  -e ACCEPT_EULA=Y \
  -e SA_PASSWORD=MyPassword123! \
  -p 1433:1433 \
  mcr.microsoft.com/mssql/server:2022-latest

# 解释每个参数:
# -d: 在后台运行(detached)
# --name: 给容器起个名字
# -e: 设置环境变量(SQL Server需要这些配置)
# -p: 端口映射(容器内1433端口映射到主机1433端口)

1.3 什么是多容器编排?

多容器编排 是指同时运行和管理多个容器,让它们协同工作。

🎭 剧院演出的类比

想象一场音乐剧演出:

  • 数据库容器 = 乐团:提供数据存储服务
  • 缓存容器 = 灯光师:提供快速访问服务
  • 消息队列容器 = 音响师:处理异步消息
  • 编排 = 导演:协调所有角色同时开始、同时结束

💡 为什么需要多容器编排?

现代应用通常需要多个服务:

graph LR A[我的应用] --> B[SQL Server数据库] A --> C[Redis缓存] A --> D[RabbitMQ消息队列] A --> E[其他服务...]

单独启动的问题:

// ❌ 问题:需要手动管理每个容器的生命周期
var sqlContainer = new MsSqlBuilder().Build();
var redisContainer = new RedisBuilder().Build();

// 如果SQL Server启动失败了,Redis还在运行,造成资源浪费
await sqlContainer.StartAsync(); // 可能失败
await redisContainer.StartAsync(); // 还是会执行

编排的优势:

// ✅ 解决方案:统一管理所有容器
public class MultiContainerFixture 
{
    public async Task InitializeAsync()
    {
        // 同时启动所有容器,任何一个失败都会停止所有容器
        await Task.WhenAll(
            _sqlContainer.StartAsync(),
            _redisContainer.StartAsync(),
            _rabbitContainer.StartAsync()
        );
    }
}

1.4 什么是集成测试 vs 单元测试?

🏗️ 建筑施工的类比

单元测试 = 测试每一块砖头的质量

[Test]
public void Test_Product_Creation()
{
    // 只测试Product类本身的逻辑
    var product = new Product("iPhone", 999.99m);
    Assert.Equal("iPhone", product.Name);
    Assert.Equal(999.99m, product.Price);
}

集成测试 = 测试整面墙的稳固性

[Test]
public void Test_Product_Save_To_Database()
{
    // 测试Product类 + Repository类 + SQL Server数据库的整体协作
    var product = new Product("iPhone", 999.99m);
    await _repository.SaveAsync(product);
    
    var saved = await _repository.GetByIdAsync(product.Id);
    Assert.NotNull(saved);
}

📊 对比表格

测试类型 测试范围 运行速度 发现问题类型 何时使用
单元测试 单个类/方法 很快(毫秒级) 逻辑错误 开发过程中频繁运行
集成测试 多个组件协作 较慢(秒级) 组件间协作问题 提交代码前运行

1.5 什么是Repository模式?

Repository模式 是一种设计模式,它把数据访问逻辑封装起来,让业务代码不需要知道数据是如何存储的。

🏪 商店购物的类比

想象你去商店买东西:

  • 你(业务代码) 只需要说:"我要买一瓶可乐"
  • 店员(Repository) 知道可乐放在哪个货架,如何找到它
  • 仓库(数据库) 是实际存放商品的地方

你不需要知道:

  • 可乐放在第几排第几列
  • 仓库的布局如何
  • 进货和库存管理的细节

💻 代码层面的理解

// ❌ 没有Repository模式:业务代码直接操作数据库
public class OrderService
{
    public async Task CreateOrder(Order order)
    {
        // 业务代码需要知道数据库的细节
        using var connection = new SqlConnection(_connectionString);
        await connection.OpenAsync();
        var command = new SqlCommand("INSERT INTO Orders...", connection);
        // 大量的数据库操作代码...
    }
}

// ✅ 使用Repository模式:业务代码只关心业务逻辑
public class OrderService
{
    private readonly IOrderRepository _orderRepository;
    
    public async Task CreateOrder(Order order)
    {
        // 业务代码只需要调用简单的方法
        await _orderRepository.SaveAsync(order);
    }
}

2. 环境准备和项目初始化

2.1 为什么需要这些工具?

🛠️ 工具箱类比

就像木匠需要不同的工具一样,我们也需要准备开发工具:

工具 作用 类比
.NET 8.0 SDK 编译和运行C#代码 木匠的基本工具箱
Docker Desktop 运行容器 电动工具(自动化)
Visual Studio 代码编辑器 工作台
Git 版本控制 图纸存档系统

🔍 详细安装说明

2.1.1 安装.NET 8.0 SDK

# 验证安装
dotnet --version
# 应该看到类似:8.0.100 的输出

# 如果没有安装,请访问:
# https://dotnet.microsoft.com/download

📝 知识点:什么是SDK?

  • SDK = Software Development Kit(软件开发工具包)
  • 包含了编译器、运行时、调试器等开发需要的所有工具
  • 就像买了一套完整的乐高积木,而不是只买几个零件

2.1.2 安装Docker Desktop

# 验证Docker是否正常运行
docker --version
docker ps

# 如果看到类似输出说明安装成功:
# Docker version 24.0.0
# CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

📝 知识点:为什么需要Docker?

  • 传统方式:在你的电脑上直接安装SQL Server(占用空间,难以卸载)
  • 容器方式:SQL Server运行在隔离的容器中(用完就删除,不留痕迹)

2.2 创建解决方案结构

🏗️ 建筑蓝图类比

创建项目结构就像绘制建筑蓝图,需要先规划好各个房间的用途。

# 创建解决方案目录(这是你的"地基")
mkdir TestContainersDemo
cd TestContainersDemo

# 创建解决方案文件(这是你的"总蓝图")
dotnet new sln -n TestContainersDemo

📝 知识点:什么是解决方案(Solution)?

  • 解决方案 是一个容器,可以包含多个项目
  • 就像一个小区包含多栋楼一样
  • .sln 文件记录了这个小区里有哪些楼(项目)

🏠 项目结构设计原理

我们要创建的项目结构:

TestContainersDemo/                 # 小区
├── src/                           # 住宅区
│   ├── TestContainersDemo.Api/          # 门面房(接待客户)
│   ├── TestContainersDemo.Core/         # 核心区(重要业务)
│   └── TestContainersDemo.Infrastructure/ # 基础设施(水电煤)
└── test/                          # 质检区
    ├── TestContainersDemo.UnitTests/     # 单元质检
    └── TestContainersDemo.IntegrationTests/ # 整体质检

📝 知识点:为什么要这样分层?

这叫做 "Clean Architecture"(干净架构)

  1. Core(核心层)

    • 包含业务逻辑和实体
    • 不依赖任何外部技术
    • 就像房子的承重墙,最重要且最稳定
  2. Infrastructure(基础设施层)

    • 处理数据库、文件系统等外部资源
    • 就像房子的水电管道,为核心提供支持
  3. Api(接口层)

    • 处理用户请求
    • 就像房子的门窗,是外界与内部沟通的通道

优点:

  • 职责清晰:每层只做自己该做的事
  • 易于测试:可以单独测试每一层
  • 易于维护:修改一层不会影响其他层

3. 创建基础项目结构

3.1 逐步创建项目

📝 详细命令解释

# 创建Web API项目(门面)
dotnet new webapi -n TestContainersDemo.Api -o src/TestContainersDemo.Api

# 命令解释:
# dotnet new: 创建新项目的命令
# webapi: 项目模板(已经包含了Web API的基本结构)
# -n: name,项目名称
# -o: output,输出目录

📝 知识点:什么是Web API?

  • API = Application Programming Interface(应用程序接口)
  • 就像餐厅的菜单,告诉客户可以点什么菜
  • Web API 通过HTTP协议提供服务

例子:

客户请求:GET /api/products/1
服务器响应:{ "id": 1, "name": "iPhone", "price": 999.99 }
# 创建核心业务项目(大脑)
dotnet new classlib -n TestContainersDemo.Core -o src/TestContainersDemo.Core

# 知识点:classlib = 类库,包含可重用的代码
# 创建基础设施项目(手脚)
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

# 知识点:xunit 是 .NET 中流行的测试框架

3.2 设置项目依赖关系

🔗 依赖关系的重要性

依赖关系 就像食物链,规定了谁可以使用谁:

graph TD A[Api层] --> B[Core层] A --> C[Infrastructure层] C --> B D[测试项目] --> A D --> B D --> C

📝 原则:依赖方向应该向内

  • ✅ 外层可以依赖内层(Api可以使用Core)
  • ❌ 内层不能依赖外层(Core不能使用Api)

🔨 设置依赖的命令

# API项目需要使用Core和Infrastructure
dotnet add src/TestContainersDemo.Api reference src/TestContainersDemo.Core
dotnet add src/TestContainersDemo.Api reference src/TestContainersDemo.Infrastructure

# 为什么?
# - API需要调用业务逻辑(Core)
# - API需要进行数据库操作(Infrastructure)

📝 知识点:为什么这样设置依赖?

想象一个餐厅:

  • Api层 = 服务员:接待客户,传递订单
  • Core层 = 厨师:处理业务逻辑(做菜)
  • Infrastructure层 = 采购员:获取原材料(数据)

服务员需要知道厨师在哪(依赖Core),也需要知道原材料在哪(依赖Infrastructure),但厨师不需要知道服务员的具体工作方式。


4. 配置依赖包管理

4.1 什么是NuGet包?

📦 包裹快递类比

NuGet包 就像网购的包裹:

  • 有人已经写好了有用的代码
  • 打包发布到NuGet仓库
  • 你可以"下单"安装到自己的项目中

例子:

# 就像在网上买了一个"数据库连接工具包"
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

4.2 为什么需要集中化包管理?

🏪 连锁店管理类比

想象你有多家连锁店:

❌ 没有统一管理的问题:

  • 北京店:使用A品牌的收银系统v1.0
  • 上海店:使用A品牌的收银系统v2.0
  • 广州店:使用B品牌的收银系统v1.5

结果: 版本混乱,难以维护,可能出现兼容性问题

✅ 集中化管理的好处:

  • 所有店都使用统一版本的系统
  • 升级时一次性升级所有店
  • 避免版本冲突

💻 代码层面的实现

创建 Directory.Packages.props 文件:

<Project>
  <PropertyGroup>
    <!-- 启用集中化包管理 -->
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  
  <ItemGroup>
    <!-- 在这里定义所有包的版本 -->
    <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
    <!-- ... 更多包 -->
  </ItemGroup>
</Project>

📝 知识点:这样做的好处

  1. 版本一致性:所有项目使用相同版本
  2. 易于升级:只需要改一个地方
  3. 减少冲突:避免版本不兼容问题

4.3 SQL Server相关的包

🔧 我们需要的工具包

<!-- Entity Framework Core SQL Server包 -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />

<!-- 为什么需要? -->
<!-- 这个包让我们的C#代码可以与SQL Server数据库通信 -->

📝 知识点:什么是Entity Framework Core (EF Core)?

EF Core 是一个 ORM(Object-Relational Mapping,对象关系映射)工具。

生活类比:

  • = C#程序员(只会说中文)
  • 数据库 = 外国人(只会说SQL语言)
  • EF Core = 翻译官

没有EF Core时:

// 你需要写复杂的SQL语句
var command = new SqlCommand(
    "INSERT INTO Products (Name, Price) VALUES (@name, @price)", 
    connection);
command.Parameters.Add("@name", product.Name);
command.Parameters.Add("@price", product.Price);

使用EF Core后:

// 你只需要用简单的C#代码
await _context.Products.AddAsync(product);
await _context.SaveChangesAsync();

🧪 TestContainers相关的包

<!-- TestContainers核心包 -->
<PackageVersion Include="Testcontainers" Version="3.10.0" />

<!-- SQL Server容器包 -->
<PackageVersion Include="Testcontainers.MsSql" Version="3.10.0" />

<!-- Redis容器包(用于缓存测试) -->
<PackageVersion Include="Testcontainers.Redis" Version="3.10.0" />

📝 知识点:为什么需要这些包?

  1. Testcontainers - 核心功能:

    • 启动和停止容器
    • 管理容器生命周期
    • 处理端口映射
  2. Testcontainers.MsSql - SQL Server专用功能:

    • 预配置好的SQL Server容器
    • 自动处理SQL Server特有的启动参数
    • 提供连接字符串生成
  3. Testcontainers.Redis - Redis专用功能:

    • 用于测试缓存功能
    • 后面我们会用到

5. 实现基础设施层

5.1 什么是实体(Entity)?

🏗️ 建筑图纸类比

实体 就像建筑图纸,描述了"东西"的结构和属性。

在我们的电商系统中:

  • Product(产品) = 商品的基本信息
  • Order(订单) = 客户购买记录
  • OrderItem(订单项) = 订单中的具体商品

💻 Product实体详解

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; }  // 最后更新时间
}

📝 知识点:为什么要设计这些属性?

  1. Id(主键)

    • 每个商品的唯一标识
    • 就像身份证号码,永远不重复
  2. Name和Description

    • string.Empty 而不是 null
    • 避免空引用异常(NullReferenceException)
  3. Price使用decimal

    • 而不是double或float
    • 因为decimal精度更高,适合金钱计算
    • 避免浮点数计算误差(如0.1 + 0.2 ≠ 0.3)
  4. CreatedAt和UpdatedAt

    • 审计字段,记录数据的生命周期
    • 方便调试和数据分析

5.2 什么是DbContext?

🏢 公司办公室类比

DbContext 就像一个公司的办公室:

  • 办公室 = DbContext
  • 各个部门 = DbSet(表)
  • 员工 = 实体对象
  • 公司制度 = 数据库配置
public class ApplicationDbContext : DbContext
{
    // 这些就是"部门"
    public DbSet<Product> Products => Set<Product>();     // 产品部门
    public DbSet<Order> Orders => Set<Order>();           // 订单部门
    public DbSet<OrderItem> OrderItems => Set<OrderItem>(); // 订单详情部门
}

🔧 SQL Server特定配置

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // 配置Product表
    modelBuilder.Entity<Product>(entity =>
    {
        entity.HasKey(e => e.Id);  // 设置主键
        entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
        
        // SQL Server特定:设置decimal精度
        entity.Property(e => e.Price).HasColumnType("decimal(18,2)");
        
        // SQL Server特定:设置datetime2类型(更高精度)
        entity.Property(e => e.CreatedAt).HasColumnType("datetime2");
        
        // 创建索引(加快查询速度)
        entity.HasIndex(e => e.Name);
    });
}

📝 知识点:为什么需要这些配置?

  1. HasColumnType("decimal(18,2)")

    • 告诉SQL Server:这个字段总共18位,小数点后2位
    • 如:999999999999999.99(16位整数 + 2位小数)
  2. HasColumnType("datetime2")

    • SQL Server的新日期类型,精度更高
    • 支持微秒级精度
  3. HasIndex(e => e.Name)

    • 在Name字段上创建索引
    • 就像书的目录,加快查找速度

5.3 什么是Repository模式详解

🏪 图书馆管理员类比

想象你去图书馆借书:

没有Repository的情况:

// 你需要自己去书架找书
public class StudentService
{
    public Book FindBook(string title)
    {
        // 你需要知道书在哪个楼层、哪个区域、哪个书架
        // 你需要知道图书馆的分类系统
        // 你需要处理借书卡、登记等细节
        // 非常复杂!
    }
}

有Repository的情况:

// 管理员帮你找书
public class StudentService
{
    private readonly IBookRepository _bookRepository;
    
    public async Task<Book> FindBook(string title)
    {
        // 你只需要告诉管理员书名
        return await _bookRepository.GetByTitleAsync(title);
    }
}

💻 Repository接口设计

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);        // 根据ID查找
    Task<List<Product>> GetAllAsync();          // 获取所有产品
    Task<Product> AddAsync(Product product);    // 添加产品
    Task UpdateAsync(Product product);         // 更新产品
    Task DeleteAsync(int id);                  // 删除产品
    Task<bool> ExistsAsync(int id);            // 检查产品是否存在
}

📝 知识点:为什么用接口?

  1. 抽象化:业务代码不需要知道数据如何存储
  2. 可测试性:可以创建假的Repository进行测试
  3. 可替换性:可以换成不同的数据库而不影响业务代码

生活例子:

  • 接口 = 充电线接口(USB-C)
  • 实现 = 具体的充电器

无论是华为、小米、还是苹果的充电器,只要符合USB-C接口标准,都可以给你的手机充电。


6. 创建单容器测试固件

6.1 什么是测试固件(Test Fixture)?

🎬 电影拍摄类比

测试固件 就像电影拍摄的布景:

  • 拍摄前:搭建布景、准备道具、调试灯光
  • 拍摄时:演员在布景中表演(测试在固件中运行)
  • 拍摄后:拆除布景、清理现场

代码类比:

public class DatabaseFixture
{
    // 搭建布景
    public async Task InitializeAsync()
    {
        await _sqlContainer.StartAsync();  // 启动数据库容器
        await CreateDatabase();            // 创建数据库表
    }
    
    // 拆除布景
    public async Task DisposeAsync()
    {
        await _sqlContainer.StopAsync();   // 停止容器
    }
}

6.2 SQL Server容器配置详解

🐳 容器启动参数解释

_msSqlContainer = new MsSqlBuilder()
    // 使用微软官方SQL Server 2022镜像
    .WithImage("mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04")
    
    // 设置SA用户密码(SA = System Administrator)
    .WithPassword("StrongPass123!")
    
    // 接受SQL Server许可协议
    .WithEnvironment("ACCEPT_EULA", "Y")
    
    // 使用Express版本(免费版,适合开发测试)
    .WithEnvironment("MSSQL_PID", "Express")
    
    // 测试完成后自动清理容器
    .WithCleanUp(true)
    .Build();

📝 知识点:每个参数的作用

  1. WithImage

    • 指定Docker镜像
    • 就像指定要下载哪个版本的软件安装包
  2. WithPassword

    • SQL Server需要管理员密码
    • 密码必须符合复杂性要求(大小写+数字+特殊字符)
  3. ACCEPT_EULA

    • EULA = End User License Agreement(最终用户许可协议)
    • 必须设置为"Y"表示同意微软的使用条款
  4. MSSQL_PID

    • PID = Product ID(产品标识)
    • Express = 免费版本,足够测试使用
  5. WithCleanUp

    • 自动清理,避免测试后留下垃圾容器

6.3 容器健康检查的重要性

🏥 体检类比

问题场景:

// ❌ 危险的做法:不检查容器是否真正就绪
await _sqlContainer.StartAsync();
// 这时候容器可能还在启动中...
var context = CreateDbContext();  // 可能失败!

就像病人刚进医院,医生还没检查就开始手术一样危险!

💊 健康检查的实现

private async Task WaitForSqlServerReady()
{
    var maxAttempts = 30;      // 最多尝试30次
    var delay = TimeSpan.FromSeconds(2);  // 每次等待2秒

    for (int attempt = 1; attempt <= maxAttempts; attempt++)
    {
        try
        {
            // 尝试连接数据库
            await using var context = CreateDbContext();
            await context.Database.CanConnectAsync();
            
            Console.WriteLine($"SQL Server ready! (attempt {attempt})");
            return;  // 连接成功,退出循环
        }
        catch (Exception ex)
        {
            Console.WriteLine($"SQL Server not ready (attempt {attempt}/{maxAttempts}): {ex.Message}");
            
            if (attempt == maxAttempts)
            {
                throw new InvalidOperationException("SQL Server failed to start!");
            }
            
            await Task.Delay(delay);  // 等待2秒再试
        }
    }
}

📝 知识点:为什么需要重试循环?

SQL Server启动过程:

  1. 容器启动 (1-2秒)
  2. SQL Server进程启动 (5-10秒)
  3. 数据库引擎初始化 (10-20秒)
  4. 准备接受连接 (总共可能需要30秒)

如果不等待就尝试连接,就会失败!

6.4 数据清理策略

🧹 清洁工作类比

为什么需要清理数据?

想象你是实验室的研究员:

  • 实验1:测试化学反应A
  • 实验2:测试化学反应B

如果实验1的残留物质影响了实验2,那么实验2的结果就不准确了!

💻 SQL Server特定的清理方法

public async Task CleanDatabaseAsync()
{
    await using var context = CreateDbContext();
    
    // 步骤1:删除所有数据
    await context.Database.ExecuteSqlRawAsync("DELETE FROM OrderItems");
    await context.Database.ExecuteSqlRawAsync("DELETE FROM Orders");  
    await context.Database.ExecuteSqlRawAsync("DELETE FROM Products");
    
    // 步骤2:重置自增ID(Identity种子)
    await context.Database.ExecuteSqlRawAsync("DBCC CHECKIDENT ('Products', RESEED, 0)");
    await context.Database.ExecuteSqlRawAsync("DBCC CHECKIDENT ('Orders', RESEED, 0)");
    await context.Database.ExecuteSqlRawAsync("DBCC CHECKIDENT ('OrderItems', RESEED, 0)");
}

📝 知识点:为什么要重置Identity种子?

没有重置的情况:

测试1创建产品 → ID=1, 2, 3
清理数据,但ID计数器还在3
测试2创建产品 → ID=4, 5, 6  (不是从1开始!)

重置后的情况:

测试1创建产品 → ID=1, 2, 3  
清理数据并重置计数器
测试2创建产品 → ID=1, 2, 3  (从1开始,更可预测)

7. 实现Repository层集成测试

7.1 什么是集成测试?

🚗 汽车测试类比

单元测试 = 测试单个零件

[Test]  
public void Test_Engine_Starts()
{
    var engine = new Engine();
    Assert.True(engine.Start());  // 只测试引擎能否启动
}

集成测试 = 测试整车性能

[Test]
public void Test_Car_Can_Drive()
{
    var car = new Car(engine, wheels, transmission, brakes);
    car.Start();
    car.Drive();
    Assert.Equal(60, car.Speed);  // 测试所有零件协作的结果
}

7.2 测试基类的作用

🏫 学校班级管理类比

没有基类的问题:

// 每个测试类都要重复相同的代码
public class MathTestClass 
{
    private DatabaseFixture _fixture;
    public async Task Setup() { /* 重复的设置代码 */ }
    public async Task Cleanup() { /* 重复的清理代码 */ }
}

public class EnglishTestClass 
{
    private DatabaseFixture _fixture;  // 重复!
    public async Task Setup() { /* 重复的设置代码 */ }  // 重复!
    public async Task Cleanup() { /* 重复的清理代码 */ }  // 重复!
}

使用基类的好处:

public abstract class BaseIntegrationTest  // 就像"班级管理规则"
{
    protected readonly SqlServerDatabaseFixture DatabaseFixture;
    
    // 统一的设置和清理逻辑
    public virtual async Task DisposeAsync() 
    { 
        await DatabaseFixture.CleanDatabaseAsync(); 
    }
}

public class MathTestClass : BaseIntegrationTest  // 数学课遵循班级规则
{
    // 只需要写数学相关的测试,不用重复基础代码
}

7.3 AutoFixture的作用

🏭 工厂流水线类比

手工创建测试数据的问题:

[Test]
public void Test_Product_Creation()
{
    // 每次都要手工编写这些数据,很累!
    var product1 = new Product 
    { 
        Name = "iPhone 15", 
        Price = 999.99m, 
        Description = "Latest iPhone",
        StockQuantity = 10 
    };
    
    var product2 = new Product 
    { 
        Name = "Samsung Galaxy", 
        Price = 899.99m, 
        Description = "Latest Samsung phone",
        StockQuantity = 5 
    };
    // ... 需要更多数据时更痛苦
}

使用AutoFixture自动生成:

[Test]  
public void Test_Product_Creation()
{
    // 工厂自动生成测试数据
    var products = _fixture.Build<Product>()
        .Without(p => p.Id)          // 不设置ID(数据库自动生成)
        .Without(p => p.CreatedAt)   // 不设置创建时间(代码自动设置)
        .CreateMany(100);            // 一次生成100个产品!

    // 专注于测试逻辑,而不是数据准备
}

📝 知识点:AutoFixture的智能之处

  1. 自动生成合理的值

    • 字符串:会生成"Name123456"这样的值
    • 数字:会生成随机但合理的数字
    • 日期:会生成合理的日期值
  2. 避免重复:每次生成的数据都不一样

  3. 可控制:可以指定哪些字段不要自动生成

7.4 测试用例设计原则

🎯 射箭训练类比

好的测试就像射箭训练,需要:

  1. 明确目标:知道要测试什么
  2. 准备环境:设置靶子和弓箭
  3. 执行动作:射箭
  4. 验证结果:看是否命中目标

💻 AAA模式详解

[Fact]
public async Task AddAsync_ShouldCreateProduct_WhenValidProductProvided()
{
    // Arrange(准备)- 设置测试环境和数据
    await 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();

    // Act(执行)- 执行要测试的动作
    var result = await repository.AddAsync(product);

    // Assert(验证)- 检查结果是否符合预期
    Assert.True(result.Id > 0);  // ID应该大于0
    Assert.NotEqual(DateTime.MinValue, result.CreatedAt);  // 创建时间应该被设置
    
    // 进一步验证:数据真的保存到数据库了吗?
    var savedProduct = await repository.GetByIdAsync(result.Id);
    Assert.NotNull(savedProduct);
    Assert.Equal(product.Name, savedProduct.Name);
}

📝 知识点:为什么要验证两次?

  1. 第一次验证:检查方法的返回值是否正确
  2. 第二次验证:检查数据是否真的保存到数据库

这确保了我们不是在测试一个"假"的保存操作。

7.5 Theory测试的强大功能

🧪 化学实验类比

普通测试 = 做一次实验

[Fact]
public void Test_Water_Boils_At_100_Degrees()
{
    var result = Water.Heat(100);
    Assert.Equal("Steam", result.State);
}

Theory测试 = 做多组对比实验

[Theory]
[InlineData(true)]   // 产品存在的情况
[InlineData(false)]  // 产品不存在的情况
public async Task ExistsAsync_ShouldReturnCorrectResult_BasedOnProductExistence(bool shouldExist)
{
    // 一个测试方法,测试两种情况
    // 这样可以确保逻辑在各种情况下都正确
}

好处:

  1. 减少重复代码:一个测试方法覆盖多种场景
  2. 提高覆盖率:确保边界条件都被测试
  3. 容易维护:修改测试逻辑只需要改一个地方

8. 添加Web API集成测试

8.1 什么是Web API集成测试?

🏪 餐厅服务测试类比

Repository测试 = 测试厨房(后厨能否做菜)
Web API测试 = 测试整个餐厅服务流程

从客户进门到吃完饭的完整流程:

  1. 客户点菜(HTTP请求)
  2. 服务员记录(Controller接收)
  3. 厨房做菜(Repository操作)
  4. 上菜(HTTP响应)
[Fact]
public async Task Create_Product_Should_Work_End_To_End()
{
    // 模拟客户行为:通过HTTP调用API
    var request = new CreateProductRequest("iPhone", "Latest phone", 999.99m, 10);
    var response = await _client.PostAsJsonAsync("/api/products", request);
    
    // 检查服务是否成功
    response.EnsureSuccessStatusCode();
    
    // 检查返回的产品信息是否正确
    var product = await response.Content.ReadFromJsonAsync<Product>();
    Assert.NotNull(product);
    Assert.Equal("iPhone", product.Name);
}

8.2 WebApplicationFactory的作用

🎭 戏剧彩排类比

WebApplicationFactory 就像戏剧彩排的舞台:

真实演出舞台(生产环境):

  • 有真实的观众
  • 使用正式的道具
  • 连接真实的数据库

彩排舞台(测试环境):

  • 没有观众(不对外提供服务)
  • 使用临时道具(TestContainers数据库)
  • 可以随时重置场景
public class WebApplicationTestFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // 替换生产环境的数据库配置
        builder.ConfigureServices(services =>
        {
            // 移除生产数据库配置
            var descriptor = services.SingleOrDefault(d => 
                d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
            if (descriptor != null)
                services.Remove(descriptor);

            // 使用测试容器数据库
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(ConnectionString));
        });

        // 使用测试环境配置
        builder.UseEnvironment("Testing");
    }
}

📝 知识点:为什么要替换数据库配置?

  1. 隔离性:测试不能影响生产数据
  2. 可控性:可以创建特定的测试场景
  3. 可重复性:每次测试都是干净的环境

8.3 HTTP状态码的含义

🚦 交通信号灯类比

HTTP状态码就像交通信号灯,告诉你发生了什么:

状态码 含义 交通灯类比 示例场景
200 OK 成功 🟢 绿灯 - 通行 成功获取产品信息
201 Created 创建成功 🟢 绿灯 - 新路开通 成功创建新产品
404 Not Found 找不到 🔴 红灯 - 此路不通 产品不存在
400 Bad Request 请求错误 🟡 黄灯 - 注意 提交的数据格式错误
[Fact]
public async Task GetById_ShouldReturn404_WhenProductNotExists()
{
    // Act - 请求不存在的产品
    var response = await _client.GetAsync("/api/products/999");

    // Assert - 应该返回404状态码
    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

8.4 JSON序列化和反序列化

📦 包装和拆包类比

序列化 = 打包邮寄

// 把C#对象"打包"成JSON字符串,通过网络发送
var product = new Product { Name = "iPhone", Price = 999.99m };
var json = JsonSerializer.Serialize(product);
// 结果:{"Name":"iPhone","Price":999.99}

反序列化 = 拆包取货

// 把收到的JSON字符串"拆包"成C#对象
var json = """{"Name":"iPhone","Price":999.99}""";
var product = JsonSerializer.Deserialize<Product>(json);
// 结果:Product对象,Name="iPhone", Price=999.99m

在测试中的应用:

[Fact]
public async Task Create_ShouldReturnCreatedProduct_WhenValidDataProvided()
{
    // 序列化:把请求对象转换为JSON发送
    var request = new CreateProductRequest("iPhone", "Description", 999.99m, 10);
    var response = await _client.PostAsJsonAsync("/api/products", request);

    // 反序列化:把响应JSON转换为C#对象
    var product = await response.Content.ReadFromJsonAsync<Product>();
    Assert.NotNull(product);
}

9. 实现多容器编排测试

9.1 什么是多容器编排?深度解析

🎼 交响乐团类比

想象一场交响音乐会:

单容器测试 = 钢琴独奏

  • 只有一台钢琴(SQL Server容器)
  • 简单直接,但功能有限

多容器编排 = 完整交响乐团

  • 钢琴(SQL Server)- 提供主要的数据存储
  • 小提琴(Redis缓存)- 提供快速的数据访问
  • 大鼓(RabbitMQ消息队列)- 处理异步通信
  • 指挥(编排系统)- 协调所有乐器同步演奏

🏗️ 现代应用架构

现代应用很少只使用一个数据库:

graph TD A[用户请求] --> B[Web API] B --> C[SQL Server 数据库] B --> D[Redis 缓存] B --> E[消息队列] B --> F[其他服务...] C --> |慢,但可靠| B D --> |快,但临时| B E --> |异步处理| B

为什么需要多个服务?

  1. SQL Server

    • 存储重要的业务数据
    • 支持复杂查询和事务
    • 但是相对较慢
  2. Redis缓存

    • 存储频繁访问的数据
    • 访问速度极快(内存存储)
    • 但是数据可能丢失
  3. 消息队列

    • 处理异步任务
    • 解耦不同的服务
    • 提高系统可靠性

9.2 缓存的作用和原理

🏪 便利店类比

没有缓存的情况:

客户:我要买可乐
店员:好的,我去仓库帮您找(走到后面仓库,翻箱倒柜)
店员:找到了!(返回前台)
时间:5分钟

有缓存的情况:

客户:我要买可乐
店员:好的!(直接从收银台旁边的小冰箱拿出可乐)
时间:10秒

💻 代码层面的缓存

public class ProductService
{
    private readonly IProductRepository _repository;
    private readonly ICacheService _cache;

    public async Task<Product> GetProductAsync(int id)
    {
        // 1. 先检查缓存
        var cacheKey = $"product:{id}";
        var cachedProduct = await _cache.GetAsync<Product>(cacheKey);
        if (cachedProduct != null)
        {
            Console.WriteLine("从缓存获取数据 - 很快!");
            return cachedProduct;  // 从缓存返回,超快!
        }

        // 2. 缓存没有,查询数据库
        Console.WriteLine("从数据库获取数据 - 较慢");
        var product = await _repository.GetByIdAsync(id);
        
        // 3. 存入缓存,下次就快了
        if (product != null)
        {
            await _cache.SetAsync(cacheKey, product, TimeSpan.FromMinutes(15));
        }

        return product;
    }
}

📝 知识点:缓存的优缺点

优点:

  • 访问速度极快(内存 vs 磁盘,差距几千倍)
  • 减少数据库压力
  • 提升用户体验

缺点:

  • 数据可能不是最新的
  • 需要处理缓存失效的情况
  • 占用内存空间

9.3 容器编排的挑战

🚦 交通协调类比

想象你要协调多个路口的红绿灯:

挑战1:启动顺序

❌ 错误的启动顺序:
1. 先启动应用程序
2. 数据库还没准备好 → 应用程序崩溃!

✅ 正确的启动顺序:
1. 先启动所有基础设施(数据库、缓存)
2. 等待它们完全就绪
3. 最后启动应用程序

挑战2:健康检查

// 不能只检查容器是否启动了
if (container.State == ContainerState.Running)  // 这还不够!

// 还要检查服务是否真正可用
await database.CanConnectAsync();  // 数据库能连接吗?
await redis.PingAsync();           // Redis能响应吗?

挑战3:资源清理

public async Task DisposeAsync()
{
    // 必须正确清理所有容器,避免资源泄露
    await Task.WhenAll(
        _sqlContainer.DisposeAsync().AsTask(),
        _redisContainer.DisposeAsync().AsTask(),
        _rabbitMqContainer.DisposeAsync().AsTask()
    );
}

9.4 多容器测试的实际场景

🛒 电商购物车场景

让我们测试一个真实的业务场景:用户添加商品到购物车

[Fact]
public async Task AddToCart_ShouldWork_WithDatabaseAndCache()
{
    // Arrange - 准备数据
    var product = await CreateProductInDatabase();  // 在SQL Server中创建商品
    
    // Act - 用户操作:添加到购物车
    var cartItem = new CartItem { ProductId = product.Id, Quantity = 2 };
    
    // 1. 数据库操作:记录购物车项(持久化存储)
    await _cartRepository.AddItemAsync(cartItem);
    
    // 2. 缓存操作:快速访问(临时存储)
    var cacheKey = $"cart:user123";
    await _cacheService.SetAsync(cacheKey, cartItem, TimeSpan.FromMinutes(30));

    // Assert - 验证两个系统都工作正常
    
    // 验证数据库:数据被持久化保存
    var savedCartItem = await _cartRepository.GetItemAsync(cartItem.Id);
    Assert.NotNull(savedCartItem);
    Assert.Equal(2, savedCartItem.Quantity);
    
    // 验证缓存:数据可以快速访问
    var cachedCartItem = await _cacheService.GetAsync<CartItem>(cacheKey);
    Assert.NotNull(cachedCartItem);
    Assert.Equal(2, cachedCartItem.Quantity);
}

这个测试验证了:

  1. 数据一致性:数据库和缓存中的数据是否一致
  2. 性能协作:数据库负责持久化,缓存负责快速访问
  3. 故障处理:如果缓存失败,数据库数据是否还在

9.5 容器间通信测试

📞 电话会议类比

在多容器环境中,不同的服务需要相互通信:

[Fact]
public async Task DatabaseTransaction_ShouldNot_AffectCache()
{
    // 这个测试验证一个重要概念:事务边界
    
    // Arrange
    var product = CreateTestProduct();
    
    // Act - 在事务中操作数据库,同时操作缓存
    try
    {
        using var transaction = await _dbContext.Database.BeginTransactionAsync();
        
        // 1. 数据库操作(在事务中)
        await _productRepository.AddAsync(product);
        
        // 2. 缓存操作(不在事务中)
        await _cacheService.SetAsync($"product:{product.Id}", product);
        
        // 3. 回滚事务(模拟业务失败场景)
        await transaction.RollbackAsync();
    }
    catch
    {
        // 忽略异常,这是预期的
    }

    // Assert - 验证事务边界的正确性
    
    // 数据库中应该没有数据(事务被回滚了)
    var dbProduct = await _productRepository.GetByIdAsync(product.Id);
    Assert.Null(dbProduct);
    
    // 缓存中应该有数据(缓存不受数据库事务影响)
    var cachedProduct = await _cacheService.GetAsync<Product>($"product:{product.Id}");
    Assert.NotNull(cachedProduct);
}

📝 知识点:这个测试的重要性

  1. 理解事务边界:数据库事务不能控制缓存操作
  2. 数据一致性问题:如果处理不当,可能导致数据不一致
  3. 实际业务场景:帮助开发者理解分布式系统的复杂性

10. 性能优化和最佳实践

10.1 为什么性能优化很重要?

⏰ 时间就是金钱类比

想象你经营一家工厂:

慢速度的测试:

每个测试需要2分钟
100个测试 = 200分钟 = 3.3小时
开发者每天运行3次 = 10小时!

优化后的测试:

每个测试需要10秒
100个测试 = 1000秒 = 16.7分钟
开发者每天运行3次 = 50分钟

结果: 每天节省9小时!开发者更愿意频繁运行测试。

10.2 SQL Server容器优化技巧

🏎️ 赛车调校类比

就像赛车需要调校才能跑得更快,SQL Server容器也需要优化:

public static async Task ConfigureForTestingAsync(ApplicationDbContext context)
{
    // 1. 设置恢复模式为简单模式(减少日志开销)
    await context.Database.ExecuteSqlRawAsync(@"
        ALTER DATABASE [{0}] SET RECOVERY SIMPLE;
    ", context.Database.GetDbConnection().Database);
    
    // 2. 设置自动收缩(测试环境,节省空间)
    await context.Database.ExecuteSqlRawAsync(@"
        ALTER DATABASE [{0}] SET AUTO_SHRINK ON;
    ");
    
    // 3. 预热连接池(第一次连接通常最慢)
    await context.Database.ExecuteSqlRawAsync("SELECT 1");
}

📝 知识点:每个优化的作用

  1. RECOVERY SIMPLE

    • 简化事务日志管理
    • 测试环境不需要复杂的恢复策略
    • 类比:考试时用铅笔而不是钢笔,可以擦除修改
  2. AUTO_SHRINK ON

    • 自动释放未使用的空间
    • 测试环境数据量变化大
    • 类比:自动整理房间,不要的东西自动清理
  3. 预热连接

    • 第一次连接最慢(需要建立连接池)
    • 提前执行简单查询建立连接
    • 类比:汽车启动后先热车,后续行驶更顺畅

10.3 容器复用策略

♻️ 回收利用类比

传统方式:每次用完就扔

[Fact]
public async Task Test1() 
{
    var container = new MsSqlBuilder().Build();
    await container.StartAsync();  // 启动需要30秒
    // 进行测试...
    await container.StopAsync();   // 用完就停止,浪费!
}

[Fact] 
public async Task Test2()
{
    var container = new MsSqlBuilder().Build();  
    await container.StartAsync();  // 又要启动30秒!
    // 进行测试...
    await container.StopAsync();
}

优化方式:回收利用

// 使用xUnit的ICollectionFixture,在多个测试间共享容器
[CollectionDefinition("Database Collection")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
}

[Collection("Database Collection")]  // 这些测试共享同一个容器
public class ProductRepositoryTests { }

[Collection("Database Collection")]  // 复用同一个容器
public class OrderRepositoryTests { }

效果:

  • 容器只启动一次(30秒)
  • 多个测试复用(节省时间)
  • 但是要注意数据隔离(每个测试后清理数据)

10.4 并行测试配置

🏭 工厂生产线类比

串行测试 = 单条生产线

测试1 → 测试2 → 测试3 → 测试4
总时间 = 所有测试时间之和

并行测试 = 多条生产线

生产线1:测试1 → 测试3
生产线2:测试2 → 测试4
总时间 = 最慢那条线的时间

⚙️ xUnit配置

xunit.runner.json 中:

{
  "parallelizeAssembly": true,        // 启用并行测试
  "parallelizeTestCollections": false, // 但同一Collection内不并行
  "maxParallelThreads": 4,            // 最多4个线程
  "longRunningTestSeconds": 60        // 超过60秒算长时间运行
}

📝 知识点:为什么有些设置为false?

  1. parallelizeTestCollections: false

    • 同一Collection共享容器
    • 如果并行运行会互相干扰
    • 类比:同一个厨房不能同时做两道菜
  2. maxParallelThreads: 4

    • 不是越多越好
    • 太多线程会争抢资源(CPU、内存、磁盘IO)
    • 类比:厨房只有一个灶台,派10个厨师也没用

10.5 性能监控和基准测试

📊 体检报告类比

定期检查测试性能,就像定期体检:

public abstract class PerformanceTestBase : BaseIntegrationTest
{
    private readonly Stopwatch _stopwatch = new();

    public override Task InitializeAsync()
    {
        _stopwatch.Start();  // 开始计时
        return base.InitializeAsync();
    }

    public override async Task DisposeAsync()
    {
        _stopwatch.Stop();   // 停止计时
        
        var testName = GetType().Name;
        var elapsedMs = _stopwatch.ElapsedMilliseconds;
        
        // 记录执行时间
        Console.WriteLine($"[{testName}] 执行时间: {elapsedMs}ms");
        
        // 警告慢测试
        if (elapsedMs > 5000) // 超过5秒
        {
            Console.WriteLine($"[警告] {testName} 运行过慢: {elapsedMs}ms");
        }

        await base.DisposeAsync();
    }
}

基准测试示例:

[Fact]
public async Task BulkInsert_ShouldComplete_WithinTimeLimit()
{
    // Arrange
    var products = CreateTestProducts(1000);  // 创建1000个测试产品
    var stopwatch = Stopwatch.StartNew();

    // Act
    foreach (var product in products)
    {
        await _repository.AddAsync(product);
    }

    stopwatch.Stop();

    // Assert - 性能要求
    Console.WriteLine($"插入1000个产品耗时: {stopwatch.ElapsedMilliseconds}ms");
    Assert.True(stopwatch.ElapsedMilliseconds < 30000, 
        $"批量插入耗时过长: {stopwatch.ElapsedMilliseconds}ms");

    // Assert - 数据正确性
    var savedCount = await _repository.CountAsync();
    Assert.Equal(1000, savedCount);
}

📝 知识点:性能测试的价值

  1. 发现性能退化:新代码是否让测试变慢了?
  2. 建立基准线:知道正常的性能水平
  3. 优化指导:哪些部分需要优化?

11. CI/CD集成配置

11.1 什么是CI/CD?

🏭 自动化工厂类比

传统软件发布 = 手工作坊

开发者写代码 → 手工测试 → 手工打包 → 手工部署
问题:容易出错,效率低下,不一致

CI/CD = 自动化工厂

开发者提交代码 → 自动测试 → 自动打包 → 自动部署
好处:减少人为错误,提高效率,保证一致性

🔄 CI/CD流程详解

CI (Continuous Integration) - 持续集成:

graph LR A[开发者提交代码] --> B[自动触发构建] B --> C[运行测试] C --> D[测试通过?] D -->|是| E[合并代码] D -->|否| F[通知开发者修复]

CD (Continuous Deployment) - 持续部署:

graph LR A[代码合并] --> B[自动打包] B --> C[部署到测试环境] C --> D[部署到生产环境]

11.2 GitHub Actions配置详解

🤖 机器人助手类比

GitHub Actions就像雇了一个机器人助手,每当你提交代码时:

name: SQL Server Integration Tests  # 机器人的名字

on:                                 # 什么时候工作?
  push:
    branches: [ main, develop ]     # 当推送到主分支时
  pull_request:
    branches: [ main, develop ]     # 当创建拉取请求时

jobs:                              # 具体要做什么工作?
  integration-tests:               # 工作名称
    runs-on: ubuntu-latest         # 在Ubuntu系统上运行

每个步骤的作用:

steps:
- name: Checkout code             # 1. 获取代码
  uses: actions/checkout@v4       #    就像从仓库取出图纸

- name: Setup .NET               # 2. 准备工具
  uses: actions/setup-dotnet@v3   #    安装.NET编译器
  with:
    dotnet-version: '8.0.x'

- name: Build solution           # 3. 编译项目
  run: dotnet build              #    把源代码编译成程序

- name: Run integration tests    # 4. 运行测试
  run: dotnet test               #    验证程序是否正确
  env:
    DOCKER_HOST: unix:///var/run/docker.sock  # 启用Docker支持

11.3 为什么需要Docker支持?

🏠 租房类比

在自己电脑上测试:

  • 就像在自己家里做饭
  • 你知道厨具在哪里,调料怎么放
  • 但是客人来你家不一定适应

在CI环境中测试:

  • 就像在朋友家做饭
  • 环境完全不同,可能没有你需要的工具
  • 需要Docker容器提供"便携厨房"
# CI环境通常没有安装SQL Server
# 但有Docker,可以运行SQL Server容器
docker run mcr.microsoft.com/mssql/server:2022-latest

11.4 本地开发脚本

🛠️ 瑞士军刀类比

一个好的测试脚本就像瑞士军刀,集成了多种功能:

# PowerShell脚本 (Windows)
Write-Host "开始SQL Server容器测试..." -ForegroundColor Yellow

# 1. 健康检查 - 检查Docker是否运行
$dockerInfo = docker info 2>$null
if ($LASTEXITCODE -ne 0) {
    Write-Host "错误:Docker未运行,请启动Docker" -ForegroundColor Red
    exit 1
}

# 2. 环境准备 - 拉取必要的镜像
Write-Host "准备SQL Server镜像..." -ForegroundColor Yellow
docker pull mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04

# 3. 清理 - 避免之前测试的影响
Write-Host "清理之前的容器..." -ForegroundColor Yellow
docker container prune -f

# 4. 构建 - 编译代码
Write-Host "构建解决方案..." -ForegroundColor Yellow
dotnet build --configuration Release

# 5. 测试 - 运行测试套件
Write-Host "运行集成测试..." -ForegroundColor Yellow
dotnet test test/TestContainersDemo.IntegrationTests `
    --no-build `
    --configuration Release `
    --verbosity normal

# 6. 结果报告
if ($LASTEXITCODE -eq 0) {
    Write-Host "所有测试通过!" -ForegroundColor Green
} else {
    Write-Host "测试失败!" -ForegroundColor Red
    # 显示容器日志帮助调试
    docker logs $(docker ps -q --filter "ancestor=mcr.microsoft.com/mssql/server")
    exit 1
}

📝 知识点:脚本的价值

  1. 一致性:每次运行都执行相同的步骤
  2. 自动化:减少手工操作的错误
  3. 可重复:新团队成员可以轻松运行测试
  4. 调试友好:出错时提供有用的信息

11.5 测试结果报告

📋 考试成绩单类比

就像考试后要看成绩单,测试后也要看报告:

- name: Upload test results        # 上传测试结果
  uses: actions/upload-artifact@v3
  if: always()                     # 无论成功失败都上传
  with:
    name: test-results
    path: TestResults/

测试报告包含:

  1. 通过/失败数量:多少个测试成功了?
  2. 执行时间:哪些测试运行缓慢?
  3. 错误详情:失败的测试具体什么问题?
  4. 代码覆盖率:多少代码被测试覆盖了?

使用测试配置文件:

<!-- test.runsettings -->
<RunSettings>
  <RunConfiguration>
    <MaxCpuCount>4</MaxCpuCount>          <!-- 最多用4个CPU核心 -->
    <TestSessionTimeout>600000</TestSessionTimeout>  <!-- 10分钟超时 -->
  </RunConfiguration>
  
  <TestRunParameters>
    <Parameter name="SqlServerContainerTimeout" value="120" />  <!-- 自定义参数 -->
  </TestRunParameters>
</RunSettings>

11.6 本地开发环境 vs CI环境

🏠 在家 vs 🏢 在公司类比

特性 本地环境(在家) CI环境(在公司)
运行速度 较快(熟悉环境) 较慢(需要准备环境)
调试能力 很强(可以断点调试) 有限(只能看日志)
环境一致性 因人而异 完全一致
资源限制 取决于个人电脑 有固定的资源配额
网络环境 取决于个人网络 稳定的数据中心网络

最佳实践:

  1. 本地环境:用于快速开发和调试
  2. CI环境:用于最终验证和质量保证
  3. 两者结合:确保代码在各种环境下都能工作

🎯 总结和下一步

你已经学会了什么?

🎓 知识清单

基础概念:

  • 容器技术:理解了什么是Docker容器,为什么比传统虚拟机更轻量
  • TestContainers:学会了用真实数据库替代Mock进行测试
  • 多容器编排:理解了现代应用需要多个服务协作的复杂性

架构设计:

  • Clean Architecture:学会了分层设计的原则和好处
  • Repository模式:理解了如何抽象数据访问层
  • 依赖注入:学会了如何管理对象之间的依赖关系

测试策略:

  • 单元测试 vs 集成测试:理解了不同测试的适用场景
  • 测试固件:学会了如何准备和清理测试环境
  • AAA模式:掌握了测试用例的标准结构

性能优化:

  • 容器复用:学会了如何提高测试执行速度
  • 并行测试:理解了如何平衡速度和资源使用
  • 性能监控:学会了如何发现和解决性能问题

DevOps实践:

  • CI/CD流程:理解了自动化的重要性和实现方式
  • 环境一致性:学会了如何保证开发、测试、生产环境的一致

🚀 下一步建议

🏗️ 继续深入学习

技术深化:

  1. 微服务测试

    • 学习如何测试服务间的通信
    • 掌握契约测试(Contract Testing)
    • 理解分布式系统的复杂性
  2. 性能测试进阶

    • 负载测试:模拟大量用户同时访问
    • 压力测试:测试系统的极限
    • 混沌工程:故意制造故障测试系统韧性
  3. 安全测试

    • SQL注入测试
    • 身份认证和授权测试
    • 敏感数据保护测试

工具扩展:

  1. 更多数据库

    • PostgreSQL容器测试
    • MongoDB文档数据库测试
    • Elasticsearch搜索引擎测试
  2. 消息队列测试

    • RabbitMQ消息测试
    • Apache Kafka流处理测试
    • Redis发布订阅测试
  3. 监控和日志

    • 集成Prometheus监控
    • 使用ELK Stack进行日志分析
    • 分布式链路追踪

📚 推荐学习资源

书籍推荐:

  1. 《测试驱动开发》- Kent Beck
  2. 《重构:改善既有代码的设计》- Martin Fowler
  3. 《领域驱动设计》- Eric Evans
  4. 《微服务架构设计模式》- Chris Richardson

在线资源:

  1. Microsoft文档https://docs.microsoft.com/aspnet/core
  2. TestContainers官网https://www.testcontainers.org/
  3. Docker官方教程https://docs.docker.com/get-started/
  4. xUnit文档https://xunit.net/docs/getting-started/netcore/cmdline

🎊 恭喜你!

通过这个详细的教程,你已经从TestContainers的初学者成长为能够:

  • 🛠️ 独立搭建完整的容器化测试环境
  • 🧪 编写高质量的集成测试用例
  • 优化测试性能,提高开发效率
  • 🚀 集成CI/CD,实现自动化测试
  • 🎯 理解现代软件开发的最佳实践

这些技能将在你的职业生涯中非常宝贵,不仅适用于.NET项目,也适用于其他技术栈的项目。

记住: 好的测试不仅是为了发现错误,更是为了给你信心去重构、优化和扩展你的代码。有了这套完整的测试基础设施,你可以更加自信地面对复杂的业务需求和技术挑战!

继续练习,持续改进,你一定会成为一名优秀的软件工程师!🌟

posted @ 2025-08-24 23:30  南山有榛  阅读(25)  评论(0)    收藏  举报