为什么写单元测试

单元测试把「代码能跑」变成「代码可验证」。改了排序逻辑、动了支付计算,回归测试在几秒钟内告诉你有没有破坏旧行为。没有测试的代码库,改一行都像在走钢丝。

测试还有一个隐藏价值:它逼着代码变得可测试。方法太长、依赖太乱、全局状态太多,写测试时最先暴露。写不出测试的代码,往往是设计需要重构的信号。

测试不是代码的负担,是代码的体检报告。
好测试三原则
  • 快秒级跑完。
  • 稳不依赖环境。
  • 准失败即定位。
C# 单元测试配图:发光绿色对勾徽章,周围是积木方块、试管与空白便签纸
验证通过,交付安心。

xUnit 基础

xUnit 是 .NET 主流的测试框架之一。测试类用 Fact 标记普通用例,用 Theory 标记参数化用例;运行器会扫描程序集,找出所有带 Fact 或 Theory 的公开方法执行。

using Xunit;

public class CalculatorTests
{
    [Fact]
    public void Add_TwoNumbers_ReturnsSum()
    {
        var calc = new Calculator();
        var result = calc.Add(2, 3);
        Assert.Equal(5, result);
    }
}

测试项目引用被测项目,命名约定通常是被测类名加 Tests 后缀。命令行执行 dotnet test 即可运行全部测试并输出结果,绿色代表全过,红色给出失败用例与断言差异。

断言与参数化测试

断言是测试的判定器:Equal 比较值、True 判断条件、Throws 验证异常、NotNull 检查空引用。参数化测试用同一段逻辑喂多组数据,把「一组输入一组断言」的重复代码收敛掉。

[Theory]
[InlineData(4, true)]
[InlineData(7, false)]
[InlineData(0, true)]
public void IsEven_ReturnsExpected(int n, bool expected)
{
    var result = NumberHelper.IsEven(n);
    Assert.Equal(expected, result);
}

边界值、空值、非法值都值得单独成用例。一个方法写五到十条用例并不算多,关键在于每条用例表达一个明确的业务约定,失败时能看懂「哪个约定被破坏了」。

用 Moq 模拟依赖

真实依赖里藏着数据库、文件系统、网络服务,测试不该碰它们。Moq 生成假对象:设定方法返回值、验证调用次数、抛出指定异常,把被测对象从外部世界里隔离出来。

var repo = new Mock<IOrderRepository>();
repo.Setup(r => r.GetById(1))
    .Returns(new Order { Id = 1, Amount = 99 });
var service = new OrderService(repo.Object);
var total = service.CalcTotal(1);
Assert.Equal(99, total);
repo.Verify(r => r.GetById(1), Times.Once);

依赖注入在这里是前提:被测对象通过接口拿依赖,Mock 才能顶替上去。构造器注入的代码天然可测,这也是单元测试反过来推动架构设计的最典型例子。

测试驱动开发流程

测试驱动开发(TDD)三步循环:先写一个会失败的测试、用最简代码让它通过、重构消除重复。循环往复,功能一点点长出来,每一步都有测试兜底。

// 第一步:先写失败测试
[Fact]
public void Price_WithDiscount_ReturnsReduced()
{
    var p = new Product { Price = 100 };
    Assert.Equal(90, p.GetFinalPrice(0.1m));
}
// 第二步:让测试通过
public decimal GetFinalPrice(decimal discount)
    => Price - Price * discount;

TDD 的关键不是快,而是节奏:红、绿、重构三步永远不跳。先写测试逼你思考接口长什么样,再写实现让你只做满足测试的最小功,最后重构把代码整理干净。