"这段代码我不是刚测过吗,怎么又挂了?"
如果你在团队中听过这句话,大概率你们的测试策略出了问题------要么测试覆盖的是错误的东西,要么测试本身不可信,要么根本没有自动化测试,全靠人肉点。
测试不是"写完代码后补的工序",而是设计的一部分。好的测试能让你敢于重构、敢于升级、敢于在周五下午发布。坏的测试比没有测试更糟------它给你虚假的安全感,让你在绿灯中自信地部署 Bug。
本文从测试金字塔出发,覆盖单元测试、集成测试、架构测试,以及 ASP.NET Core 特有的 WebApplicationFactory 和 TestContainers。所有示例结合 PMS 船舶管理系统的真实业务场景,可直接落地。
一、测试金字塔:先搞清楚该测什么
1.1 经典金字塔
/──────\
/ E2E \ ← 少量:端到端测试(浏览器/UI)
/──────────\
/ 集成测试 \ ← 适量:模块间协作、数据库、API
/──────────────\
/ 单元测试 \ ← 大量:单个类/方法的逻辑
/──────────────────\
| 层级 | 数量 | 速度 | 稳定性 | 测试对象 |
|---|---|---|---|---|
| 单元测试 | 多(70%) | 极快(ms级) | 高 | 单个类/方法 |
| 集成测试 | 中(20%) | 中(秒级) | 中 | 模块协作、数据库、API |
| E2E测试 | 少(10%) | 慢(十秒级) | 低 | 完整用户流程 |
1.2 反模式:冰淇淋蛋筒
/──────\
/ E2E \ ← 大量 E2E:慢、不稳定、难维护
/──────────\
/ 集成测试 \ ← 很少
/──────────────\
/ 单元测试 \ ← 几乎没有
/──────────────────\
如果你的项目是这种形状------E2E 测试写了几百个,单元测试几乎没有------那测试一定跑得慢且经常误报。重构的方向是把测试逻辑下沉到单元测试和集成测试。
1.3 PMS 项目的测试策略
csharp
// PMS 项目测试项目结构
/*
Pms.Tests/
├── Pms.UnitTests/ # 单元测试(~300个)
│ ├── Domain/ # 领域模型测试
│ │ ├── ShipTests.cs
│ │ ├── SparePartTests.cs
│ │ └── RequisitionTests.cs
│ ├── Services/ # 应用服务测试(Mock依赖)
│ │ ├── RequisitionServiceTests.cs
│ │ └── InventoryServiceTests.cs
│ └── Common/ # 工具类测试
│ ├── NumberGeneratorTests.cs
│ └── CurrencyConverterTests.cs
│
├── Pms.IntegrationTests/ # 集成测试(~80个)
│ ├── Api/ # API 端点测试
│ │ ├── RequisitionApiTests.cs
│ │ └── AuthApiTests.cs
│ ├── Data/ # 数据库集成测试
│ │ └── RepositoryTests.cs
│ └── Workflow/ # 工作流引擎测试
│ └── ApprovalFlowTests.cs
│
├── Pms.ArchitectureTests/ # 架构测试(~30个)
│ └── ArchitectureRules.cs
│
└── Pms.TestCommon/ # 测试共享工具
├── TestDataBuilder.cs
├── MockRepository.cs
└── TestWebApplicationFactory.cs
*/
💬 互动一下: 你的项目测试金字塔是什么形状?是金字塔、冰淇淋蛋筒,还是干脆没有测试?如果没有,今天就从给一个核心方法写单元测试开始。
二、单元测试:xUnit + NSubstitute 实战
2.1 为什么选 NSubstitute 而不是 Moq
2024 年 Moq 的 SponsorLink 事件让很多团队重新评估 Mock 框架。NSubstitute 以其简洁的 API 和无争议的开源协议成为首选替代:
csharp
// Moq 写法
var repo = new Mock<IRequisitionRepository>();
repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>()))
.ReturnsAsync(new Requisition());
// NSubstitute 写法------更简洁
var repo = Substitute.For<IRequisitionRepository>();
repo.GetByIdAsync(Arg.Any<Guid>())
.Returns(new Requisition());
2.2 领域模型单元测试(无依赖)
领域模型是最应该被测试的,因为它们包含核心业务规则,且通常没有外部依赖,测试极快:
csharp
public class RequisitionTests
{
[Fact]
public void Submit_WhenDraft_ChangesStatusToPending()
{
// Arrange
var requisition = new RequisitionBuilder()
.WithStatus(RequisitionStatus.Draft)
.Build();
// Act
requisition.Submit("user001");
// Assert
requisition.Status.Should().Be(RequisitionStatus.Pending);
requisition.SubmittedAt.Should().NotBeNull();
requisition.SubmittedBy.Should().Be("user001");
}
[Fact]
public void Submit_WhenAlreadySubmitted_ThrowsException()
{
var requisition = new RequisitionBuilder()
.WithStatus(RequisitionStatus.Pending)
.Build();
var act = () => requisition.Submit("user001");
act.Should().Throw<InvalidOperationException>()
.WithMessage("*already submitted*");
}
[Theory]
[InlineData(RequisitionStatus.Approved)]
[InlineData(RequisitionStatus.Rejected)]
[InlineData(RequisitionStatus.Completed)]
public void Submit_WhenNotDraft_ThrowsException(RequisitionStatus status)
{
var requisition = new RequisitionBuilder()
.WithStatus(status)
.Build();
var act = () => requisition.Submit("user001");
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void Approve_WithFourLevelApproval_ReachesFourthApprover()
{
// 备件申领需要四级审批
var requisition = new RequisitionBuilder()
.WithStatus(RequisitionStatus.Pending)
.WithApprovalLevel(0)
.Build();
requisition.Approve("manager1", "同意");
requisition.Approve("manager2", "同意");
requisition.Approve("manager3", "同意");
requisition.Approve("manager4", "最终批准");
requisition.Status.Should().Be(RequisitionStatus.Approved);
requisition.CurrentLevel.Should().Be(4);
requisition.Approvers.Should().HaveCount(4);
}
[Fact]
public void Reject_AtAnyLevel_SetsRejectedStatus()
{
var requisition = new RequisitionBuilder()
.WithStatus(RequisitionStatus.Pending)
.WithApprovalLevel(2)
.Build();
requisition.Reject("manager3", "预算不足");
requisition.Status.Should().Be(RequisitionStatus.Rejected);
requisition.RejectReason.Should().Be("预算不足");
}
[Fact]
public void AddItem_WhenDraft_IncreasesTotalAmount()
{
var requisition = new RequisitionBuilder()
.WithStatus(RequisitionStatus.Draft)
.WithCurrency("USD")
.WithExchangeRate(7.2m)
.Build();
requisition.AddItem("SP001", "轴承", 10, 150.00m, "USD");
requisition.AddItem("SP002", "密封圈", 100, 5.00m, "USD");
requisition.TotalAmount.Should().Be(2000.00m);
requisition.TotalAmountRMB.Should().Be(14400.00m); // 2000 × 7.2
}
[Fact]
public void AddItem_WhenNotDraft_ThrowsException()
{
var requisition = new RequisitionBuilder()
.WithStatus(RequisitionStatus.Pending)
.Build();
var act = () => requisition.AddItem("SP001", "轴承", 1, 100m, "USD");
act.Should().Throw<InvalidOperationException>();
}
}
2.3 Test Data Builder 模式
手写构造测试对象又长又容易重复。Builder 模式让测试数据构造变得声明式:
csharp
public class RequisitionBuilder
{
private Guid _id = Guid.NewGuid();
private string _requisitionNo = "REQ-2026-001";
private string _shipId = "SHIP001";
private RequisitionStatus _status = RequisitionStatus.Draft;
private int _currentLevel = 0;
private string _currency = "CNY";
private decimal _exchangeRate = 1.0m;
private readonly List<RequisitionItem> _items = new();
public RequisitionBuilder WithId(Guid id)
{
_id = id;
return this;
}
public RequisitionBuilder WithStatus(RequisitionStatus status)
{
_status = status;
return this;
}
public RequisitionBuilder WithApprovalLevel(int level)
{
_currentLevel = level;
return this;
}
public RequisitionBuilder WithCurrency(string currency, decimal rate)
{
_currency = currency;
_exchangeRate = rate;
return this;
}
public RequisitionBuilder WithItem(string partNo, string name,
int qty, decimal price, string currency)
{
_items.Add(new RequisitionItem(partNo, name, qty, price, currency));
return this;
}
public Requisition Build()
{
var requisition = new Requisition(_id, _requisitionNo, _shipId);
// 使用反射或内部方法设置测试状态
typeof(Requisition)
.GetProperty(nameof(Requisition.Status))!
.SetValue(requisition, _status);
typeof(Requisition)
.GetProperty(nameof(Requisition.CurrentLevel))!
.SetValue(requisition, _currentLevel);
foreach (var item in _items)
{
requisition.AddItem(item.PartNo, item.Name,
item.Quantity, item.UnitPrice, item.Currency);
}
if (_currency != "CNY")
{
typeof(Requisition)
.GetProperty(nameof(Requisition.Currency))!
.SetValue(requisition, _currency);
typeof(Requisition)
.GetProperty(nameof(Requisition.ExchangeRate))!
.SetValue(requisition, _exchangeRate);
}
return requisition;
}
}
2.4 应用服务测试(Mock 外部依赖)
csharp
public class RequisitionServiceTests
{
private readonly IRequisitionRepository _repo;
private readonly IInventoryService _inventory;
private readonly IWorkflowEngine _workflow;
private readonly IPublishEndpoint _publisher;
private readonly RequisitionService _service;
public RequisitionServiceTests()
{
_repo = Substitute.For<IRequisitionRepository>();
_inventory = Substitute.For<IInventoryService>();
_workflow = Substitute.For<IWorkflowEngine>();
_publisher = Substitute.For<IPublishEndpoint>();
_service = new RequisitionService(
_repo, _inventory, _workflow, _publisher);
}
[Fact]
public async Task CreateAsync_ValidDto_SavesAndPublishesEvent()
{
// Arrange
var dto = new CreateRequisitionDtoBuilder()
.WithShipId("SHIP001")
.WithItem("SP001", 10)
.Build();
// Act
var result = await _service.CreateAsync(dto);
// Assert
await _repo.Received(1).AddAsync(Arg.Any<Requisition>());
await _publisher.Received(1).Publish(
Arg.Is<RequisitionCreatedEvent>(e =>
e.ShipId == "SHIP001"));
}
[Fact]
public async Task CreateAsync_InventoryShortage_ThrowsAndDoesNotSave()
{
var dto = new CreateRequisitionDtoBuilder().Build();
_inventory.CheckAvailabilityAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(false);
var act = () => _service.CreateAsync(dto);
await act.Should().ThrowAsync<InventoryShortageException>();
await _repo.DidNotReceive().AddAsync(Arg.Any<Requisition>());
await _publisher.DidNotReceive().Publish(Arg.Any<object>());
}
[Fact]
public async Task ApproveAsync_LastLevel_SetsApprovedAndStartsWorkflow()
{
var requisition = new RequisitionBuilder()
.WithStatus(RequisitionStatus.Pending)
.WithApprovalLevel(3) // 第四级(0-indexed)
.Build();
_repo.GetByIdAsync(Arg.Any<Guid>()).Returns(requisition);
await _service.ApproveAsync(requisition.Id, "manager4", "同意");
requisition.Status.Should().Be(RequisitionStatus.Approved);
await _workflow.Received(1).StartFulfillmentAsync(requisition.Id);
}
}
2.5 测试命名规范
好的测试名应该像文档一样描述行为:
csharp
// ❌ 模糊的命名
public void Test1()
public void SubmitTest()
public void Approve_Throws()
// ✅ 清晰的命名:方法名_条件_预期结果
public void Submit_WhenDraft_ChangesStatusToPending()
public void Submit_WhenAlreadySubmitted_ThrowsInvalidOperationException()
public async Task CreateAsync_WithValidData_PublishesRequisitionCreatedEvent()
public async Task CreateAsync_WhenInventoryShort_DoesNotSaveOrPublish()
三、集成测试:WebApplicationFactory 实战
单元测试 Mock 了所有外部依赖,但 Mock 的世界和真实运行环境之间有一道鸿沟------配置绑定、DI 注册、中间件管道、序列化、路由、数据库查询......这些只能通过集成测试验证。
3.1 基本配置
csharp
// 自定义 WebApplicationFactory,替换外部依赖
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
public Mock<IRequisitionRepository> RequisitionRepository { get; }
= new();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureTestServices(services =>
{
// 移除真实的 DbContext,替换为内存数据库或 TestContainers
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<PmsDbContext>));
if (descriptor != null) services.Remove(descriptor);
services.AddDbContext<PmsDbContext>(options =>
{
options.UseInMemoryDatabase("TestDb_" + Guid.NewGuid());
});
// 替换外部服务为 Mock
services.AddSingleton(RequisitionRepository.Object);
});
}
}
// 测试基类
public abstract class IntegrationTestBase : IClassFixture<TestWebApplicationFactory>
{
protected readonly TestWebApplicationFactory Factory;
protected readonly HttpClient Client;
protected readonly PmsDbContext Db;
protected IntegrationTestBase(TestWebApplicationFactory factory)
{
Factory = factory;
Client = factory.CreateClient();
var scope = factory.Services.CreateScope();
Db = scope.ServiceProvider.GetRequiredService<PmsDbContext>();
Db.Database.EnsureCreated();
}
}
3.2 API 端点测试
csharp
public class RequisitionApiTests : IntegrationTestBase
{
public RequisitionApiTests(TestWebApplicationFactory factory)
: base(factory) { }
[Fact]
public async Task Create_ReturnsCreatedWithRequisitionId()
{
// Arrange
var dto = new CreateRequisitionDto
{
ShipId = "SHIP001",
Items = new List<CreateRequisitionItemDto>
{
new() { PartNo = "SP001", Quantity = 10 }
}
};
// Act
var response = await Client.PostAsJsonAsync("/api/requisitions", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content
.ReadFromJsonAsync<RequisitionResponse>();
result!.Id.Should().NotBeEmpty();
result.RequisitionNo.Should().StartWith("REQ-");
// 验证数据库确实写入了
var saved = await Db.Requisitions.FindAsync(result.Id);
saved.Should().NotBeNull();
saved.ShipId.Should().Be("SHIP001");
}
[Fact]
public async Task GetById_WhenNotExists_Returns404()
{
var response = await Client.GetAsync(
$"/api/requisitions/{Guid.NewGuid()}");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Create_WithInvalidData_Returns400()
{
var dto = new CreateRequisitionDto
{
ShipId = "", // 空 ShipId
Items = new List<CreateRequisitionItemDto>() // 空列表
};
var response = await Client.PostAsJsonAsync("/api/requisitions", dto);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Approve_WithValidToken_ReturnsOk()
{
// 先创建一个申领单
var createDto = new CreateRequisitionDtoBuilder().Build();
var createResponse = await Client.PostAsJsonAsync(
"/api/requisitions", createDto);
var created = await createResponse.Content
.ReadFromJsonAsync<RequisitionResponse>();
// 审批
var approveDto = new ApproveDto
{
Approver = "manager1",
Comment = "同意"
};
var response = await Client.PostAsJsonAsync(
$"/api/requisitions/{created!.Id}/approve", approveDto);
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
3.3 认证测试
csharp
public static class TestAuthExtensions
{
/// <summary>
/// 为测试客户端添加模拟 JWT 认证
/// </summary>
public static HttpClient CreateAuthenticatedClient(
this WebApplicationFactory<Program> factory,
string userId = "testuser",
string shipId = "SHIP001",
params string[] roles)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new("ship_id", shipId)
};
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var token = new JwtSecurityTokenHandler().WriteToken(
new JwtSecurityToken(
issuer: "TestIssuer",
audience: "TestAudience",
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: new SigningCredentials(
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("TestSecretKey1234567890!")),
SecurityAlgorithms.HmacSha256)));
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
return client;
}
}
// 使用
[Fact]
public async Task Delete_WithoutAdminRole_Returns403()
{
var client = Factory.CreateAuthenticatedClient(
roles: "Viewer"); // 只有查看权限
var response = await client.DeleteAsync(
$"/api/requisitions/{Guid.NewGuid()}");
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
四、TestContainers:真实数据库的集成测试
InMemoryDatabase 虽然快,但它不是真正的关系型数据库------不支持事务、不支持原生 SQL、不支持某些查询操作。TestContainers 让你用 Docker 容器跑真实的数据库进行测试。
4.1 安装与配置
bash
dotnet add package Testcontainers.PostgreSql
dotnet add package Testcontainers.MsSql
dotnet add package Testcontainers.Redis
csharp
public class PostgresIntegrationTestBase : IAsyncLifetime
{
private readonly PostgreSqlContainer _dbContainer;
private readonly RedisContainer _redisContainer;
protected PmsDbContext Db { get; private set; }
protected HttpClient Client { get; private set; }
private TestWebApplicationFactory _factory;
public PostgresIntegrationTestBase()
{
_dbContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("pms_test")
.WithUsername("test")
.WithPassword("test123")
.WithPortBinding(5432, true)
.Build();
_redisContainer = new RedisBuilder()
.WithImage("redis:7-alpine")
.Build();
}
public async Task InitializeAsync()
{
// 启动 Docker 容器
await _dbContainer.StartAsync();
await _redisContainer.StartAsync();
_factory = new TestWebApplicationFactory(
_dbContainer.GetConnectionString(),
_redisContainer.GetConnectionString());
Client = _factory.CreateClient();
var scope = _factory.Services.CreateScope();
Db = scope.ServiceProvider.GetRequiredService<PmsDbContext>();
// 执行迁移
await Db.Database.MigrateAsync();
// 初始化种子数据
await SeedAsync();
}
public async Task DisposeAsync()
{
await _dbContainer.DisposeAsync();
await _redisContainer.DisposeAsync();
await _factory.DisposeAsync();
}
private async Task SeedAsync()
{
Db.Ships.Add(new Ship("SHIP001", "测试船舶1"));
Db.Ships.Add(new Ship("SHIP002", "测试船舶2"));
await Db.SaveChangesAsync();
}
}
4.2 Repository 集成测试
csharp
public class SparePartRepositoryTests : PostgresIntegrationTestBase
{
[Fact]
public async Task GetLowStock_ReturnsPartsBelowSafetyLevel()
{
// Arrange
Db.SpareParts.AddRange(
new SparePart("SP001", "轴承", shipId: "SHIP001",
stock: 5, safetyStock: 10), // 低于安全库存
new SparePart("SP002", "密封圈", shipId: "SHIP001",
stock: 100, safetyStock: 10), // 正常
new SparePart("SP003", "过滤器", shipId: "SHIP001",
stock: 0, safetyStock: 5) // 缺货
);
await Db.SaveChangesAsync();
// Act
var repo = new SparePartRepository(Db);
var lowStock = await repo.GetLowStockAsync("SHIP001");
// Assert
lowStock.Should().HaveCount(2);
lowStock.Should().Contain(p => p.PartNo == "SP001");
lowStock.Should().Contain(p => p.PartNo == "SP003");
}
[Fact]
public async Task DeductStock_WithConcurrency_ThrowsDbUpdateConcurrencyException()
{
// 测试乐观锁并发控制
var part = new SparePart("SP001", "轴承", "SHIP001", stock: 10);
Db.SpareParts.Add(part);
await Db.SaveChangesAsync();
// 两个并发请求同时扣减
using var scope1 = _factory.Services.CreateScope();
using var scope2 = _factory.Services.CreateScope();
var db1 = scope1.ServiceProvider.GetRequiredService<PmsDbContext>();
var db2 = scope2.ServiceProvider.GetRequiredService<PmsDbContext>();
var p1 = await db1.SpareParts.FindAsync(part.Id);
var p2 = await db2.SpareParts.FindAsync(part.Id);
p1!.Deduct(3);
await db1.SaveChangesAsync();
p2!.Deduct(4);
var act = () => db2.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateConcurrencyException>();
}
}
4.3 TestContainers vs InMemoryDatabase
| 维度 | InMemoryDatabase | TestContainers |
|---|---|---|
| 速度 | 极快(<10ms) | 较慢(容器启动 5-15s) |
| 真实性 | 不支持事务/原生SQL/迁移 | 完整数据库引擎 |
| 并发测试 | 不支持 | 支持 |
| CI 要求 | 无 | 需要 Docker |
| 适合场景 | 快速验证业务逻辑 | 验证 SQL/迁移/并发/索引 |
最佳实践: 两层结合------日常开发用 InMemory 快速跑,CI Pipeline 用 TestContainers 做真实数据库验证。
五、架构测试:用代码守护代码
5.1 NetArchTest 入门
架构测试验证的是"代码结构是否遵守团队约定"------比如"控制器不能直接访问 Repository"、"领域层不能依赖基础设施层":
bash
dotnet add package NetArchTest.Rules
csharp
public class ArchitectureRules
{
private static readonly Architecture PmsArchitecture =
Types.InAssembly(typeof(PmsDbContext).Assembly)
.GetArchitecture();
[Fact]
public void Domain_ShouldNotDependOn_Infrastructure()
{
var result = Types.InAssembly(typeof(Ship).Assembly) // Domain
.ShouldNot()
.HaveDependencyOnAny(
"Pms.Infrastructure",
"Pms.DataAccess",
"Microsoft.EntityFrameworkCore")
.GetResult();
result.IsSuccessful.Should().BeTrue(
$"领域层不能依赖基础设施层,违规: {string.Join(", ", result.FailingTypeNames)}");
}
[Fact]
public void Controllers_ShouldNot_DirectlyAccessRepository()
{
var result = Types.InAssembly(typeof(RequisitionsController).Assembly)
.That()
.ResideInNamespace("Pms.Api.Controllers")
.ShouldNot()
.HaveDependencyOn("Pms.DataAccess.IRepository")
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
[Fact]
public void Services_ShouldHave_AsyncSuffix()
{
var result = Types.InAssembly(typeof(RequisitionService).Assembly)
.That()
.ResideInNamespace("Pms.Application.Services")
.And()
.ArePublic()
.Should()
.HaveNameEndingWith("Service", StringComparison.Ordinal)
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
[Fact]
public void Entities_ShouldHave_ParameterlessConstructor()
{
var result = Types.InAssembly(typeof(Ship).Assembly)
.That()
.Inherit(typeof(PmsBaseEntity))
.Should()
.HaveParameterlessConstructor() // EF Core 需要
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
[Fact]
public void Controllers_ShouldBeSealed_OrInheritBaseController()
{
var result = Types.InAssembly(typeof(RequisitionsController).Assembly)
.That()
.ResideInNamespace("Pms.Api.Controllers")
.Should()
.Inherit(typeof(ControllerBase))
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
}
六、Moq 框架的替代品与自动生成
6.1 NSubstitute 常用模式速查
csharp
// 1. 基本返回值
repo.GetByIdAsync(id).Returns(ship);
// 2. 按参数匹配
repo.GetByShipIdAsync(Arg.Is("SHIP001")).Returns(ships);
repo.GetByIdAsync(Arg.Any<Guid>()).Returns(ship);
// 3. 多参数条件匹配
repo.FindAsync(
Arg.Is<string>(s => s.StartsWith("SHIP")),
Arg.Is<int>(i => i > 0)
).Returns(ships);
// 4. 抛异常
repo.SaveChangesAsync().Throws<DbUpdateException>();
// 5. 按调用次序返回不同结果
repo.GetCountAsync()
.Returns(10, 20, 30); // 第一次10,第二次20,第三次30
// 6. 验证调用次数
await repo.Received(1).AddAsync(Arg.Any<Requisition>());
await repo.DidNotReceive().DeleteAsync(Arg.Any<Guid>());
// 7. 验证调用顺序(Received.InOrder)
Received.InOrder(async () =>
{
await repo.AddAsync(Arg.Any<Requisition>());
await publisher.Publish(Arg.Any<RequisitionCreatedEvent>());
await repo.SaveChangesAsync();
});
// 8. 回调------在 Mock 方法中执行自定义逻辑
repo.When(x => x.AddAsync(Arg.Any<Requisition>()))
.Do(callInfo =>
{
var req = callInfo.Arg<Requisition>();
req.Id.Should().NotBe(Guid.Empty);
});
6.2 AutoBuilder:用 AutoBogus 自动生成测试数据
csharp
// 安装:AutoBogus
// PM> dotnet add package AutoBogus
// 自动生成随机测试数据
var requisition = AutoFaker.Generate<Requisition>();
// 自定义规则
var faker = new AutoFaker<Requisition>()
.RuleFor(x => x.ShipId, f => $"SHIP{f.Random.Int(1, 100):D3}")
.RuleFor(x => x.Status, f => f.PickRandom<RequisitionStatus>())
.RuleFor(x => x.TotalAmount, f => f.Finance.Amount(100, 50000));
var requisitions = faker.Generate(100); // 一次生成100条
七、测试中的常见陷阱
7.1 测试脆弱:过度依赖实现细节
csharp
// ❌ 脆弱测试:验证了 Mock 的调用顺序,但这是实现细节
[Fact]
public async Task Create_CallsRepoThenPublisherThenSave()
{
Received.InOrder(async () =>
{
await _repo.AddAsync(Arg.Any<Requisition>());
await _publisher.Publish(Arg.Any<object>());
await _repo.SaveChangesAsync();
});
}
// 如果把 SaveChanges 放到 Publish 前面(不影响正确性),
// 这个测试就挂了------但业务行为完全没变!
// ✅ 好的测试:验证行为和结果,不验证实现细节
[Fact]
public async Task Create_PersistsRequisitionAndPublishesEvent()
{
await _service.CreateAsync(dto);
await _repo.Received(1).AddAsync(Arg.Is<Requisition>(
r => r.ShipId == "SHIP001"));
await _publisher.Received(1).Publish(
Arg.Any<RequisitionCreatedEvent>());
}
7.2 多个断言耦合
csharp
// ❌ 一个测试验证了太多东西,失败时不知道哪个出了问题
[Fact]
public void Submit_Works()
{
var req = new Requisition();
req.Submit("user");
Assert.Equal("pending", req.Status);
Assert.Equal("user", req.SubmittedBy);
Assert.NotNull(req.SubmittedAt);
Assert.Equal(1, req.Approvers.Count);
Assert.Equal("manager1", req.Approvers[0].Name);
}
// ✅ 每个测试只验证一个行为
[Fact]
public void Submit_ChangesStatusToPending() { ... }
[Fact]
public void Submit_SetsSubmittedByAndTimestamp() { ... }
[Fact]
public void Submit_AddsFirstApprover() { ... }
7.3 测试间共享状态
csharp
// ❌ 静态变量在测试间共享,导致顺序依赖
private static Requisition _sharedRequisition;
// ✅ 每个测试独立构造
public RequisitionTests()
{
_requisition = new RequisitionBuilder().Build();
}
7.4 时间相关测试
csharp
// ❌ 直接用 DateTime.Now,测试结果随时间变化
public bool IsExpired => DateTime.UtcNow > ExpiryDate;
// ✅ 注入时间抽象
public interface IClock
{
DateTime UtcNow { get; }
}
// 测试中控制时间
var clock = Substitute.For<IClock>();
clock.UtcNow.Returns(new DateTime(2026, 12, 31));
八、CI/CD 中的测试策略
8.1 分层测试 Pipeline
yaml
# .github/workflows/test.yml 示例
name: Test
on: [push, pull_request]
jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet test Pms.UnitTests --no-restore \
--logger "trx;LogFileName=unit.trx" \
--collect:"XPlat Code Coverage"
# 单元测试不需要 Docker,快速反馈
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: pms_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test123
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet test Pms.IntegrationTests
8.2 代码覆盖率目标
bash
# 使用 coverlet 收集覆盖率
dotnet test --collect:"XPlat Code Coverage" \
--results-directory ./coverage
# 生成报告
dotnet reportgenerator \
-reports:"./coverage/**/coverage.cobertura.xml" \
-targetdir:"./coverage/report" \
-reporttypes:Html
合理的覆盖率目标:
| 层级 | 目标覆盖率 | 说明 |
|---|---|---|
| 领域层 | 90%+ | 核心业务规则,必须充分测试 |
| 应用服务层 | 70%+ | 主要流程覆盖,错误路径也要测 |
| API 层 | 50%+ | 关键端点集成测试 |
| 基础设施层 | 40%+ | 数据库/外部服务,集成测试覆盖 |
⚠️ 覆盖率是参考指标不是目标。100% 覆盖率不代表没有 Bug------覆盖了代码行不代表覆盖了所有输入组合和边界条件。
九、Checklist:测试质量评估
单元测试:
□ 测试命名清晰:方法名_条件_预期结果
□ 每个测试只验证一个行为
□ AAA 模式(Arrange-Act-Assert)
□ 测试间无状态依赖
□ 不连接数据库/文件系统/网络
□ Mock 只 Mock 自己拥有的接口(不要 Mock 第三方库)
□ 验证异常消息和类型
□ 边界条件和空值都有覆盖
□ 使用 Test Data Builder 构造测试数据
集成测试:
□ 覆盖关键 API 端点的成功和失败路径
□ 验证 HTTP 状态码和响应体
□ 数据库操作后验证实际数据
□ 认证/授权场景有测试
□ 并发和事务场景用 TestContainers
□ 测试数据隔离(每个测试独立数据库或事务回滚)
架构测试:
□ 分层依赖规则
□ 命名约定
□ 实体构造函数要求
□ API 端点路由规范
整体:
□ CI 自动运行所有测试
□ 测试运行时间 < 10 分钟(单元 < 1 分钟)
□ 覆盖率趋势可追踪
□ 没有被 [Ignore] 跳过的测试(或有明确原因)
□ Flaky Test 有记录和修复计划
十、写在最后
测试的价值不在于"覆盖率数字好看",而在于让你有信心修改代码。当你需要重构一个有 300 个单元测试覆盖的领域模型时,改完按一下运行键,绿灯亮起来的那一刻,你会真正理解"测试是安全网"这句话的含义。
从投入产出比来看,最值得写的测试依次是:
- 领域模型单元测试------核心业务逻辑,零依赖,写起来最快,价值最高
- 应用服务测试------验证业务流程编排,用 Mock 隔离外部依赖
- 关键 API 集成测试------验证整个管道(路由→认证→模型验证→服务→数据库)
- 架构测试------一劳永逸,防止代码腐化
- E2E 测试------只覆盖最核心的用户旅程
不要追求一步到位。如果项目现在没有任何测试,从一个核心领域方法开始,写第一个单元测试。然后是第二个、第三个。当测试数量积累到一定程度,你会发现代码质量在不知不觉中提升了------因为可测试的代码天然就是低耦合、高内聚的。
💬 互动一下: 你写过最有价值的测试是什么?是那个在上线前拦住了严重 Bug 的测试,还是那个让你敢大改祖传代码的测试?欢迎在评论区分享你的测试故事。