.NET 单元测试(一):基础入门

写在前面

本文是 .NET 单元测试系列的第一篇,介绍单元测试的基本概念和 xUnit 框架入门。无论你之前有没有写过测试,读完这篇都能开始动手写。


一、什么是单元测试

1.1 定义

单元测试是对软件中最小可测试单元(通常是一个方法)进行验证的自动化测试。

1
2
3
4
5
6
核心特征:
1. 自动化 — 不需要人工干预,一条命令就能跑
2. 快速 — 毫秒级完成,不能依赖外部服务
3. 隔离 — 只测试目标方法,不连带测试依赖项
4. 可重复 — 每次运行结果一致
5. 自我检查 — 不需要人工判断结果对不对

1.2 为什么要写单元测试

1
2
3
4
5
尽早发现 Bug          — 写代码时就能发现问题,不是等到测试同学提
重构的安全网           — 有测试在,改代码不怕改坏
文档作用              — 测试用例就是最好的使用文档
设计反馈              — 难写的测试说明代码设计有问题
提升信心              — 上线前跑一遍全绿,心里踏实

1.3 测试金字塔

1
2
3
        /  E2E 测试  \           少量,慢,覆盖完整流程
       / 集成测试     \          适量,中速,覆盖组件交互
      /   单元测试     \         大量,快速,覆盖业务逻辑
1
2
3
单元测试   — 数量最多,速度最快,只测一个方法
集成测试   — 测多个组件协作(如服务 + 数据库)
E2E 测试   — 从用户视角测完整流程

本系列重点讲单元测试和集成测试。E2E 测试一般用 Playwright 等工具,不在本系列范围内。

1.4 .NET 测试框架对比

1
2
3
4
5
xUnit        — 最流行,社区推荐,ASP.NET Core 团队使用
NUnit        — 老牌框架,功能丰富,类似 JUnit
MSTest       — 微软官方,集成好但功能少

推荐:xUnit(本系列全程使用 xUnit)

二、环境准备

2.1 创建解决方案结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
MyApp/
├── src/
│   └── MyApp.Services/           # 业务代码
│       ├── MyApp.Services.csproj
│       └── Calculator.cs
├── tests/
│   └── MyApp.Services.Tests/     # 测试代码
│       ├── MyApp.Services.Tests.csproj
│       └── CalculatorTests.cs
└── MyApp.sln

2.2 创建项目

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 创建解决方案
dotnet new sln -n MyApp

# 创建业务类库
dotnet new classlib -n MyApp.Services -o src/MyApp.Services

# 创建测试项目(xUnit 模板)
dotnet new xunit -n MyApp.Services.Tests -o tests/MyApp.Services.Tests

# 添加引用
dotnet add tests/MyApp.Services.Tests reference src/MyApp.Services

# 添加到解决方案
dotnet sln add src/MyApp.Services
dotnet sln add tests/MyApp.Services.Tests

2.3 测试项目 csproj

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\MyApp.Services\MyApp.Services.csproj" />
  </ItemGroup>

</Project>

2.4 运行测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 运行所有测试
dotnet test

# 运行并显示详细输出
dotnet test --verbosity normal

# 只运行某个项目
dotnet test tests/MyApp.Services.Tests

# 持续运行(文件变化自动重跑)
dotnet test --watch

三、第一个测试

3.1 被测代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/MyApp.Services/Calculator.cs
namespace MyApp.Services;

public class Calculator
{
    public int Add(int a, int b) => a + b;

    public int Subtract(int a, int b) => a - b;

    public int Multiply(int a, int b) => a * b;

    public double Divide(int a, int b)
    {
        if (b == 0)
            throw new DivideByZeroException("除数不能为零");

        return (double)a / b;
    }
}

3.2 编写测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// tests/MyApp.Services.Tests/CalculatorTests.cs
using MyApp.Services;

namespace MyApp.Services.Tests;

public class CalculatorTests
{
    [Fact]
    public void Add_TwoNumbers_ReturnsSum()
    {
        // Arrange(准备)
        var calculator = new Calculator();

        // Act(执行)
        var result = calculator.Add(2, 3);

        // Assert(断言)
        Assert.Equal(5, result);
    }
}

3.3 关键概念

1
2
3
[Fact]       — 标记一个无参数的测试方法
AAA 模式     — Arrange(准备)→ Act(执行)→ Assert(断言)
Assert       — 验证结果是否符合预期

四、断言详解

4.1 相等性断言

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[Fact]
public void EqualityAssertions()
{
    // 相等
    Assert.Equal(5, 2 + 3);                    // 数值
    Assert.Equal("hello", "hello");             // 字符串

    // 不等
    Assert.NotEqual(5, 2 + 4);

    // 浮点数精度
    Assert.Equal(3.14, 3.141, precision: 2);    // 精确到小数点后2位
}

4.2 布尔断言

1
2
3
4
5
6
[Fact]
public void BooleanAssertions()
{
    Assert.True(1 > 0);
    Assert.False(1 < 0);
}

4.3 集合断言

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[Fact]
public void CollectionAssertions()
{
    var numbers = new List<int> { 1, 2, 3, 4, 5 };

    // 包含
    Assert.Contains(3, numbers);
    Assert.DoesNotContain(6, numbers);

    // 集合相等(顺序也要一致)
    Assert.Equal(new[] { 1, 2, 3, 4, 5 }, numbers);

    // 集合相等(不关心顺序)
    Assert.Equal(new[] { 5, 4, 3, 2, 1 }, numbers.OrderBy(_ => _));

    // 空集合
    Assert.Empty(new List<string>());
    Assert.NotEmpty(numbers);

    // 单个元素
    Assert.Single(new[] { 1 });

    // 所有元素满足条件
    Assert.All(numbers, n => Assert.True(n > 0));
}

4.4 异常断言

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
[Fact]
public void Divide_ByZero_ThrowsDivideByZeroException()
{
    var calculator = new Calculator();

    // 断言抛出异常
    var ex = Assert.Throws<DivideByZeroException>(
        () => calculator.Divide(10, 0));

    Assert.Equal("除数不能为零", ex.Message);
}

[Fact]
public void Constructor_NullName_ThrowsArgumentNullException()
{
    // 也可以用 Record.Exception
    var ex = Record.Exception(() => new User(null!));

    Assert.NotNull(ex);
    Assert.IsType<ArgumentNullException>(ex);
}

4.5 类型断言

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[Fact]
public void TypeAssertions()
{
    object obj = "hello";

    // 是某类型
    Assert.IsType<string>(obj);

    // 可以赋值给某类型(包含子类)
    Assert.IsAssignableFrom<object>(obj);

    // 不是某类型
    Assert.NotType<int>(obj);
}

4.6 范围和比较断言

1
2
3
4
5
6
7
8
[Fact]
public void RangeAssertions()
{
    // 注意:xUnit 的 Assert 不直接支持范围断言
    // 用 True 断言组合
    var age = 25;
    Assert.True(age >= 18 && age <= 65, $"年龄 {age} 不在 18-65 范围内");
}

4.7 null 断言

1
2
3
4
5
6
7
8
[Fact]
public void NullAssertions()
{
    string? name = null;

    Assert.Null(name);
    Assert.NotNull("hello");
}

4.8 事件断言

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[Fact]
public void PropertyChanged_Raised()
{
    var user = new User("张三");
    string? changedProperty = null;

    user.PropertyChanged += (_, e) => changedProperty = e.PropertyName;
    user.Name = "李四";

    Assert.Equal("Name", changedProperty);
}

五、AAA 模式详解

5.1 三段式结构

1
2
3
Arrange(准备)— 创建测试数据、Mock 对象、初始化状态
Act(执行)    — 调用被测方法
Assert(断言) — 验证结果、验证副作用

5.2 完整示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class DiscountServiceTests
{
    [Fact]
    public void CalculateDiscount_VipMember_Returns20Percent()
    {
        // Arrange
        var member = new Member { Level = MemberLevel.VIP };
        var order = new Order { Total = 1000m };
        var service = new DiscountService();

        // Act
        var discount = service.CalculateDiscount(member, order);

        // Assert
        Assert.Equal(200m, discount);         // 1000 * 20% = 200
        Assert.Equal(800m, order.FinalTotal); // 副作用验证
    }

    [Fact]
    public void CalculateDiscount_RegularMember_Returns5Percent()
    {
        var member = new Member { Level = MemberLevel.Regular };
        var order = new Order { Total = 1000m };
        var service = new DiscountService();

        var discount = service.CalculateDiscount(member, order);

        Assert.Equal(50m, discount);
    }

    [Fact]
    public void CalculateDiscount_NullMember_ThrowsArgumentNullException()
    {
        var order = new Order { Total = 1000m };
        var service = new DiscountService();

        Assert.Throws<ArgumentNullException>(
            () => service.CalculateDiscount(null!, order));
    }
}

5.3 AAA 的注意事项

1
2
3
4
1. Arrange 要完整 — 不要让读者猜测试前提是什么
2. Act 只有一个 — 一个测试只测一个行为
3. Assert 可以多个 — 但都和同一个行为相关
4. 不要在 Act 和 Assert 之间加逻辑

六、测试命名规范

6.1 常见命名风格

1
2
3
4
5
6
7
8
9
风格1:MethodName_Scenario_Expected(推荐)
  Add_TwoPositiveNumbers_ReturnsSum
  Divide_ByZero_ThrowsDivideByZeroException

风格2:Given_When_Then
  GivenTwoNumbers_WhenAdd_ThenReturnsSum

风格3:中文描述(适合团队内部)
  两个正数相加_返回正确结果

6.2 命名原则

1
2
3
4
1. 看名字就知道测什么(不用看代码)
2. 看名字就知道期望结果
3. 测试失败时,名字本身就是错误描述
4. 避免用 Test1、Test2 这种无意义名字

七、测试的组织结构

7.1 测试类组织

1
2
一个生产类 → 一个测试类
一个公共方法 → 一组测试(正常、边界、异常)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 按功能分组
public class CalculatorTests
{
    public class Add
    {
        [Fact] public void ReturnsSum() { }
        [Fact] public void HandlesNegativeNumbers() { }
        [Fact] public void HandlesOverflow() { }
    }

    public class Divide
    {
        [Fact] public void ReturnsQuotient() { }
        [Fact] public void ByZero_ThrowsException() { }
    }
}

7.2 项目结构

1
2
3
4
5
6
7
8
9
tests/
├── MyApp.Services.Tests/
│   ├── CalculatorTests.cs
│   ├── DiscountServiceTests.cs
│   └── UserServiceTests.cs
├── MyApp.Services.IntegrationTests/
│   └── UserRepositoryTests.cs
└── MyApp.Web.Tests/
    └── OrderControllerTests.cs

八、在 Visual Studio 和 VS Code 中运行测试

8.1 Visual Studio

1
2
3
4
测试资源管理器   — 菜单:测试 → 测试资源管理器
运行全部         — Ctrl+R, A
运行单个         — 右键测试方法 → 运行测试
调试测试         — 右键测试方法 → 调试测试(可以打断点)

8.2 VS Code

1
2
3
安装扩展:C# Dev Kit(自带测试资源管理器)
侧边栏会出现测试图标 → 展开运行
或者在测试方法上方点击 ▶ 运行按钮

8.3 命令行

1
2
3
4
dotnet test                                    # 运行所有
dotnet test --filter "FullyQualifiedName~Add"  # 按名称过滤
dotnet test --filter "Category=Slow"           # 按分类过滤
dotnet test --logger "console;verbosity=detailed"  # 详细输出

九、常见误区

9.1 什么不是单元测试

1
2
3
4
5
✗ 需要数据库的测试        → 集成测试
✗ 需要文件系统的测试      → 集成测试
✗ 需要网络的测试          → 集成测试
✗ 需要手动检查结果的测试  → 不算自动化测试
✗ 顺序依赖的测试          → 设计有问题

9.2 FIRST 原则

1
2
3
4
5
Fast         — 快速(毫秒级)
Independent  — 独立(不依赖其他测试)
Repeatable   — 可重复(任何环境结果一致)
SelfValidating — 自我验证(自动判断通过/失败)
Timely       — 及时(最好在写代码时同步写测试)

十、小结

本文学习了单元测试的基础:

  • 什么是单元测试和测试金字塔
  • xUnit 环境搭建
  • 第一个测试和 AAA 模式
  • 断言详解(相等、布尔、集合、异常、类型)
  • 测试命名和组织规范
  • FIRST 原则

下一篇将深入学习 xUnit 进阶特性:数据驱动测试、共享上下文和测试生命周期。