单元测试

6 阅读7分钟

1. 核心理念 (Why & What)

1.1 什么是单元测试?

单元测试是对软件中的最小可测试单元(通常是类中的方法)进行检查和验证。

  • 目标: 确保逻辑在隔离环境中按预期运行。
  • 特点: 不依赖数据库、文件系统、网络或其他外部环境。

1.2 FIRST 原则 (黄金法则)

所有优秀的单元测试都必须遵守 FIRST 原则:

  • F (Fast) 快速: 运行速度必须极快(毫秒级)。
  • I (Isolated) 隔离: 测试之间互不干扰,不依赖执行顺序。
  • R (Repeatable) 可重复: 在任何环境、任何时间运行结果一致(不依赖 DateTime.Now 或随机数)。
  • S (Self-validating) 自验证: 测试结果只有 Pass 或 Fail,不需要人工查看日志。
  • T (Timely) 及时: 随代码编写同时完成,不要等到项目结束再补。

2. 技术栈与工具 (Tools)

在现代 .NET / C# 开发中,推荐使用以下组合:

组件推荐工具说明
测试框架xUnit现代、简洁、社区支持最强(优于 MSTest)。
模拟框架Moq用于模拟接口行为(Mocking)。
断言库xUnit 自带 / FluentAssertions用于验证结果(Assert.Equal)。
运行工具Visual Studio Test Explorer快捷键 Ctrl + E, T

3. 编写规范 (How to Write)

3.1 3A 模式 (代码结构)

每个测试方法内部必须清晰地分为三部分:

  1. Arrange (准备): 初始化对象、设置 Mock 行为、准备数据。
  2. Act (执行): 调用被测试的方法。
  3. Assert (断言): 验证返回值是否正确,或是否调用了预期的方法。

3.2 命名规范

测试方法的名称应当像“句子”一样清晰,描述 [测什么] + [什么条件下] + [期望结果]

  • 格式: MethodName_StateUnderTesting_ExpectedBehavior
  • 示例: Register_WithExistingEmail_ThrowsException

3.3 Mock (模拟) 的边界

这是区分“单元测试”与“集成测试”的关键。

  • 需要 Mock 的(外部依赖):

    • 数据库操作 (Repository, DbContext)
    • 第三方 API 调用 (HttpClient)
    • 文件 I/O、系统时间、随机数
  • 不需要 Mock 的(内部逻辑):

    • DTO / Entity 实体对象
    • LINQ 查询(内存中)
    • 私有方法(通过测试 Public 方法覆盖)
    • 工具类方法(如 String.Split

4. 测试策略与范围 (Strategy)

4.1 测试粒度

  • 重点测试: Service 层 的 Public 方法。这里是业务逻辑最密集的地方。

  • 轻量测试/不测试:

    • Controller 层: 仅验证 HTTP 状态码转换,不测业务逻辑。
    • Repository 层: 通常由集成测试覆盖。
    • 纯 CRUD: 如果方法只是简单的一行透传,不值得测试。

4.2 覆盖率与 ROI (投入产出比)

  • 不要追求 100% 覆盖率。 推荐目标:核心业务模块 80%+

  • 必测路径:

    1. Happy Path: 一切正常的流程。
    2. Edge Cases: 关键的边界条件(如空值、负数、重复数据)。
  • 效率神器: 使用 xUnit 的 [Theory][InlineData] 来合并重复的测试逻辑。


5. 实战代码速查表 (Cheat Sheet)

场景:测试一个用户注册服务

using Xunit;
using Moq;
using MyProject.Services;
using MyProject.Interfaces;

public class UserServiceTests
{
    // 定义 Mocks (模拟对象)
    private readonly Mock<IUserRepository> _mockRepo;
    private readonly Mock<IEmailService> _mockEmail;
    // 定义被测对象
    private readonly UserService _service;

    public UserServiceTests()
    {
        // Arrange: 初始化 (会在每个测试运行前执行)
        _mockRepo = new Mock<IUserRepository>();
        _mockEmail = new Mock<IEmailService>();
        _service = new UserService(_mockRepo.Object, _mockEmail.Object);
    }

    // 案例 1: 快乐路径 (Happy Path)
    [Fact]
    public async Task Register_ValidUser_ReturnsTrueAndSendsEmail()
    {
        // Arrange
        var email = "new@test.com";
        // 模拟:数据库里找不到这个邮箱 (返回 null)
        _mockRepo.Setup(r => r.GetByEmail(email)).ReturnsAsync((User)null);

        // Act
        var result = await _service.Register(email);

        // Assert
        Assert.True(result); // 验证返回值
        // 验证:是否确实调用了数据库保存方法?(验证行为)
        _mockRepo.Verify(r => r.Add(It.IsAny<User>()), Times.Once);
        // 验证:是否确实发了邮件?
        _mockEmail.Verify(e => e.SendWelcome(email), Times.Once);
    }

    // 案例 2: 异常路径 (Edge Case) - 邮箱已存在
    [Fact]
    public async Task Register_ExistingEmail_ThrowsException()
    {
        // Arrange
        var email = "exist@test.com";
        // 模拟:数据库里已经有这个用户了
        _mockRepo.Setup(r => r.GetByEmail(email)).ReturnsAsync(new User());

        // Act & Assert
        // 验证是否抛出了特定异常
        await Assert.ThrowsAsync<InvalidOperationException>(() => _service.Register(email));

        // 验证:确保没有调用保存,也没有发邮件
        _mockRepo.Verify(r => r.Add(It.IsAny<User>()), Times.Never);
        _mockEmail.Verify(e => e.SendWelcome(It.IsAny<string>()), Times.Never);
    }
    
    // 案例 3: 参数化测试 (多组数据)
    [Theory]
    [InlineData("")]
    [InlineData(null)]
    public async Task Register_InvalidInput_ThrowsArgumentException(string invalidEmail)
    {
         await Assert.ThrowsAsync<ArgumentException>(() => _service.Register(invalidEmail));
    }
}

6. 自检清单 (Checklist)

在提交代码前,问自己几个问题:

  • 我的测试跑得快吗?(是否连了真实数据库?)
  • 测试名能让我一眼看懂在测什么吗?
  • 我是否只测试了 Public 方法?
  • 我是否覆盖了“成功”和“失败”两种主要情况?
  • 如果我重构代码内部实现(不改功能),测试会挂吗?(如果不改功能测试却挂了,说明测试写得太关注细节了)。

7. 测试策略

1. 企业一般的要求标准是什么?

在大多数正规的软件开发团队(非只有 1-2 人的初创团队)中,通常有以下硬性或软性指标:

A. 代码覆盖率 (Code Coverage) —— 核心指标

这是最直观的数据。Visual Studio 企业版或 DevOps 平台(如 Azure DevOps, Jenkins)都会统计这个数字。

  • 及格线:60% - 70%

    • 这通常意味着覆盖了主要的业务逻辑(Happy Path)。
  • 行业标准/优秀线:80%

    • 这是大多数成熟互联网公司和外企追求的目标。如果有 CI/CD 流水线,低于 80% 的覆盖率可能会导致代码合并被拒绝
  • 金融/医疗/航空:90% - 100%

    • 这些领域极其严苛,但这通常不适用于普通商业项目。

B. 必测范围

  • 核心业务 (Core Domain): 涉及金钱计算、订单状态流转、权限校验的代码,必须有测试。
  • 公共库 (Shared Libraries): 给其他团队用的工具类,必须高覆盖率。

2. 每个 Public 方法都要写一个测试吗?

答案是:绝对不需要。 盲目追求“每个方法都有测试”是低效的源头。

❌ 不需要测试的方法(低价值)

  1. 琐碎代码 (Trivial Code):

    • 简单的 Getter/Setter 属性。
    • 没有任何逻辑判断,只是把数据从 A 传给 B 的方法(纯透传)。
    • 构造函数(除非里面有复杂的初始化逻辑)。
  2. 框架生成的代码: 比如 Entity Framework 的 Migration 文件,或者自动生成的 DTO。

  3. 简单的 CRUD: 如果你的 Service 方法只是调用了一下 Repository.GetById() 然后直接返回,不要测它。你是在测微软的编译器,这没有意义。

✅ 必须测试的方法(高价值)

  1. 有条件分支的: 包含 if, switch, while 的方法。
  2. 有计算逻辑的: 包含 + - * / 或字符串处理的方法。
  3. 有异常抛出的: 业务规则校验(例如:余额不足抛出异常)。

3. 如何平衡开发效率与测试详细度?

如果你觉得写测试太慢,通常是因为你试图覆盖“所有可能”,或者测试写得太难维护。

策略一:遵循 "80/20 法则" (重要!)

只写两个维度的测试,就能覆盖 80% 的风险:

  1. Happy Path (快乐路径): 输入完全正确,流程一切正常,最后返回成功。这是必须要有的。
  2. Edge Cases (关键边缘路径): 最容易出错的 1-2 种情况(例如输入为 null,ID 不存在,金额为负数)。

放弃什么?

放弃那些极低概率发生的边界情况,或者需要极其复杂的 Mock 才能模拟出来的场景。

策略二:使用参数化测试 (Parameterized Tests)

这是 C# xUnit 提升效率的神器。不要为每种输入都复制粘贴写一个新方法

低效写法:

C#

[Fact] void Test_Age_10_Return_False() { ... }
[Fact] void Test_Age_20_Return_True() { ... }
[Fact] void Test_Age_18_Return_True() { ... }

高效写法 (使用 [Theory][InlineData]):

写一个测试方法,测 5 种情况,代码量几乎不增加。

C#

[Theory]
[InlineData(10, false)] // 输入10,期望 false
[InlineData(17, false)]
[InlineData(18, true)]  // 临界点
[InlineData(20, true)]
public void CheckAdult_ReturnsExpectedResult(int age, bool expected)
{
    // Arrange
    var service = new UserService();
    
    // Act
    var result = service.IsAdult(age);
    
    // Assert
    Assert.Equal(expected, result);
}

策略三:避免测试“实现细节”

这是导致测试难以维护、开发效率低下的最大原因。

  • 糟糕的测试: 验证“Service 是否先读取了缓存,缓存没有再读取数据库,然后把数据写入缓存”。(如果以后你删除了缓存逻辑,测试就挂了,虽然功能没变)。
  • 好的测试: 验证“我输入 ID,Service 是否返回了正确的用户”。(不管你内部怎么折腾,只要结果对就行)。

原则:像用户一样测试你的接口,而不是像做手术一样去测试内部变量。


总结:给你的行动指南

如果你现在还在“从 0 到 1”的阶段,我建议你采取**“防御性测试策略”**:

  1. 新功能必测: 以后新写的核心 Service 方法,必须写一个 Happy Path 测试。
  2. Bug 修复必测: 每次修复一个 Bug,先写一个测试复现这个 Bug,修复后测试通过。这能防止 Bug 复发(回归测试)。
  3. 旧代码随缘: 不要试图回头去给几年前的旧代码补测试,除非你要重构它。