ASP.NET Core 测试实战:从单元测试到集成测试的全链路质量保障

"这段代码我不是刚测过吗,怎么又挂了?"

如果你在团队中听过这句话,大概率你们的测试策略出了问题------要么测试覆盖的是错误的东西,要么测试本身不可信,要么根本没有自动化测试,全靠人肉点。

测试不是"写完代码后补的工序",而是设计的一部分。好的测试能让你敢于重构、敢于升级、敢于在周五下午发布。坏的测试比没有测试更糟------它给你虚假的安全感,让你在绿灯中自信地部署 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 个单元测试覆盖的领域模型时,改完按一下运行键,绿灯亮起来的那一刻,你会真正理解"测试是安全网"这句话的含义。

从投入产出比来看,最值得写的测试依次是:

  1. 领域模型单元测试------核心业务逻辑,零依赖,写起来最快,价值最高
  2. 应用服务测试------验证业务流程编排,用 Mock 隔离外部依赖
  3. 关键 API 集成测试------验证整个管道(路由→认证→模型验证→服务→数据库)
  4. 架构测试------一劳永逸,防止代码腐化
  5. E2E 测试------只覆盖最核心的用户旅程

不要追求一步到位。如果项目现在没有任何测试,从一个核心领域方法开始,写第一个单元测试。然后是第二个、第三个。当测试数量积累到一定程度,你会发现代码质量在不知不觉中提升了------因为可测试的代码天然就是低耦合、高内聚的。

💬 互动一下: 你写过最有价值的测试是什么?是那个在上线前拦住了严重 Bug 的测试,还是那个让你敢大改祖传代码的测试?欢迎在评论区分享你的测试故事。

相关推荐
hh95017 小时前
gent Plan × DeepSeek Harness Agent 单元测试与行为回归框架
人工智能·数据挖掘·回归·单元测试·agent plan·adg成都社区·adg社区
川石课堂软件测试1 天前
自动化测试常见的异常处理
python·jmeter·mysql·docker·容器·单元测试·grafana
七仔啊1 天前
C#、.NET 、ASP.NET Core、Visual Studio全系列快速入门
c#·asp.net·.net
萧瑟余晖3 天前
Java深入解析篇三十九之集成测试
java·开发语言·集成测试
智码看视界3 天前
Day 56:AI辅助开发全面提效:Copilot + Cursor的Java开发
java·单元测试·copilot·cursor·后端开发·代码审查·ai辅助开发
Lost of 程序猿4 天前
AOP 实战:面向切面编程从理论到落地
后端·asp.net·aop·面向切片变成
Lost of 程序猿4 天前
ASP.NET Core 认证授权实战:从 JWT 到 Policy,设计一套企业级 RBAC 权限系统
后端·asp.net
Lost of 程序猿4 天前
ASP.NET Core 后台任务全景:从 BackgroundService 到 Channel 队列,再到分布式调度
后端·asp.net·.netcore
QH139292318804 天前
Keysight N5183B N5183B 微波模拟信号发生器
网络·单片机·嵌入式硬件·集成测试·信息与通信