.NET 单元测试(二):xUnit 进阶

写在前面

本文是 .NET 单元测试系列的第二篇,深入 xUnit 的高级特性:数据驱动测试、共享上下文、并行控制和自定义扩展。前置知识:单元测试基础(第一篇)。


一、数据驱动测试

上一篇的 [Fact] 只能测固定数据。当你需要用多组数据验证同一个逻辑时,用 [Theory]

1.1 InlineData — 少量固定数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
[InlineData(100, 200, 300)]
public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected)
{
    var calculator = new Calculator();
    var result = calculator.Add(a, b);

    Assert.Equal(expected, result);
}
1
2
3
优势:一个测试方法覆盖多组数据
每个 [InlineData] 生成一个独立的测试用例
测试资源管理器中可以看到每组数据

1.2 MemberData — 从属性/方法获取数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public static IEnumerable<object[]> AdditionData =>
    new List<object[]>
    {
        new object[] { 1, 2, 3 },
        new object[] { -1, 1, 0 },
        new object[] { 100, 200, 300 },
        new object[] { int.MaxValue, 0, int.MaxValue },
    };

[Theory]
[MemberData(nameof(AdditionData))]
public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected)
{
    var calculator = new Calculator();
    var result = calculator.Add(a, b);

    Assert.Equal(expected, result);
}

1.3 MemberData 从其他类获取

 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
// 集中管理测试数据
public static class CalculatorTestData
{
    public static IEnumerable<object[]> DivisionData =>
        new List<object[]>
        {
            new object[] { 10, 2, 5.0 },
            new object[] { 9, 3, 3.0 },
            new object[] { 7, 2, 3.5 },
            new object[] { -10, 2, -5.0 },
        };

    public static IEnumerable<object[]> EdgeCaseData =>
        new List<object[]>
        {
            new object[] { 0, 1, 0.0 },
            new object[] { 1, 1, 1.0 },
        };
}

[Theory]
[MemberData(nameof(CalculatorTestData.DivisionData), MemberType = typeof(CalculatorTestData))]
[MemberData(nameof(CalculatorTestData.EdgeCaseData), MemberType = typeof(CalculatorTestData))]
public void Divide_TwoNumbers_ReturnsQuotient(int a, int b, double expected)
{
    var calculator = new Calculator();
    var result = calculator.Divide(a, b);

    Assert.Equal(expected, result);
}

1.4 ClassData — 从类获取数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class AdditionTestData : IEnumerable<object[]>
{
    public IEnumerator<object[]> GetEnumerator()
    {
        yield return new object[] { 1, 2, 3 };
        yield return new object[] { -1, 1, 0 };
        yield return new object[] { 100, 200, 300 };
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

[Theory]
[ClassData(typeof(AdditionTestData))]
public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected)
{
    var calculator = new Calculator();
    Assert.Equal(expected, calculator.Add(a, b));
}

1.5 TheoryData — 强类型数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// TheoryData<T1, T2, ...> 提供类型安全
public static TheoryData<string, int, bool> UserValidationData =>
    new()
    {
        { "张三", 25, true },          // 正常
        { "", 25, false },             // 名字为空
        { null!, 25, false },          // 名字为 null
        { "李四", 17, false },         // 未成年
        { "王五", 150, false },        // 年龄不合理
    };

[Theory]
[MemberData(nameof(UserValidationData))]
public void ValidateUser_VariousInputs_ReturnsExpected(
    string name, int age, bool expected)
{
    var result = UserValidator.Validate(name, age);
    Assert.Equal(expected, result);
}

1.6 自定义 DataAttribute — 从数据库/CSV/JSON 加载

 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
// 从 CSV 文件加载测试数据
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class CsvDataAttribute : DataAttribute
{
    private readonly string _filePath;

    public CsvDataAttribute(string filePath) => _filePath = filePath;

    public override IEnumerable<object[]> GetData(MethodInfo testMethod)
    {
        var lines = File.ReadAllLines(_filePath);
        foreach (var line in lines.Skip(1)) // 跳过标题行
        {
            var parts = line.Split(',');
            yield return parts.Select(p => (object)int.Parse(p)).ToArray();
        }
    }
}

[Theory]
[CsvData("TestData/addition.csv")]
public void Add_FromCsv_ReturnsSum(int a, int b, int expected)
{
    Assert.Equal(expected, new Calculator().Add(a, b));
}

1.7 数据驱动测试的选择

1
2
3
4
5
InlineData    — 3-5 组简单数据,直接写在方法上
MemberData    — 中等数量,需要代码生成或复用
ClassData     — 大量数据,需要复杂初始化逻辑
TheoryData    — 强类型,编译期检查
自定义         — 从文件/数据库加载(慎用,测试不应依赖外部)

二、共享上下文

测试经常需要共享昂贵的资源(数据库连接、文件系统、大对象)。xUnit 提供了三种级别的共享。

2.1 三种共享级别

1
2
3
无共享(默认)         — 每个测试方法创建新的测试类实例
ClassFixture           — 同一个测试类中所有测试共享一个实例
CollectionFixture      — 多个测试类共享一个实例

2.2 默认行为(无共享)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// xUnit 默认:每个 [Fact] 方法执行前都会 new 一个新的测试类实例
// 这保证了测试之间完全隔离

public class ExampleTests
{
    private int _counter = 0;  // 每个测试方法看到的是不同的实例

    [Fact]
    public void Test1() => Assert.Equal(0, _counter++);

    [Fact]
    public void Test2() => Assert.Equal(0, _counter++);  // 也是 0,不是 1
}

2.3 ClassFixture — 类级别共享

适用于:创建代价大但只读的共享对象。

 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 1. 定义 Fixture
public class DatabaseFixture : IDisposable
{
    public SqlConnection Connection { get; }

    public DatabaseFixture()
    {
        // 所有测试方法执行前,只执行一次
        Connection = new SqlConnection("Server=.;Database=TestDb;Trusted_Connection=true;");
        Connection.Open();

        // 初始化测试数据
        InitTestData();
    }

    private void InitTestData()
    {
        using var cmd = Connection.CreateCommand();
        cmd.CommandText = "INSERT INTO Users (Name, Age) VALUES ('测试用户', 25)";
        cmd.ExecuteNonQuery();
    }

    public void Dispose()
    {
        // 所有测试方法执行完后,清理资源
        Connection?.Dispose();
    }
}

// 2. 测试类实现 IClassFixture<T>
public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;

    public UserRepositoryTests(DatabaseFixture fixture)
    {
        _fixture = fixture;  // 通过构造函数注入
    }

    [Fact]
    public void GetUserById_ExistingUser_ReturnsUser()
    {
        var repo = new UserRepository(_fixture.Connection);
        var user = repo.GetById(1);

        Assert.NotNull(user);
        Assert.Equal("测试用户", user.Name);
    }

    [Fact]
    public void GetAll_ReturnsUsers()
    {
        var repo = new UserRepository(_fixture.Connection);
        var users = repo.GetAll();

        Assert.NotEmpty(users);
    }
}

2.4 CollectionFixture — 集合级别共享

适用于:多个测试类需要共享同一个 Fixture。

 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 1. 定义 Fixture
public class SharedContext : IDisposable
{
    public StringWriter LogOutput { get; }

    public SharedContext()
    {
        LogOutput = new StringWriter();
    }

    public void Dispose()
    {
        LogOutput?.Dispose();
    }
}

// 2. 定义 Collection Definition(注意不能有构造函数)
[CollectionDefinition("SharedContextCollection")]
public class SharedContextCollection : ICollectionFixture<SharedContext>
{
    // 空类,只用来标记 Collection 名称
}

// 3. 使用 Collection 的测试类
[Collection("SharedContextCollection")]
public class OrderServiceTests
{
    private readonly SharedContext _context;

    public OrderServiceTests(SharedContext context)
    {
        _context = context;
    }

    [Fact]
    public void CreateOrder_WritesLog()
    {
        var service = new OrderService(_context.LogOutput);
        service.CreateOrder(new Order());

        Assert.Contains("订单已创建", _context.LogOutput.ToString());
    }
}

[Collection("SharedContextCollection")]
public class PaymentServiceTests
{
    private readonly SharedContext _context;

    public PaymentServiceTests(SharedContext context)
    {
        _context = context;
    }

    [Fact]
    public void ProcessPayment_WritesLog()
    {
        var service = new PaymentService(_context.LogOutput);
        service.ProcessPayment(new Payment());

        Assert.Contains("支付已处理", _context.LogOutput.ToString());
    }
}

2.5 共享级别选择

1
2
3
不需要共享          → 默认行为(最简单,最安全)
同一类中共享        → ClassFixture
跨多个类共享        → CollectionFixture

三、构造函数和 Dispose

3.1 构造函数 = TestInitialize

 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
public class UserServiceTests : IDisposable
{
    private readonly UserService _service;
    private readonly string _testDir;

    public UserServiceTests()
    {
        // 每个测试方法执行前都会调用
        _testDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
        Directory.CreateDirectory(_testDir);
        _service = new UserService(_testDir);
    }

    [Fact]
    public void SaveUser_CreatesFile()
    {
        _service.SaveUser(new User { Name = "张三" });

        Assert.True(File.Exists(Path.Combine(_testDir, "张三.json")));
    }

    [Fact]
    public void LoadUser_ReturnsSavedUser()
    {
        _service.SaveUser(new User { Name = "张三", Age = 25 });
        var user = _service.LoadUser("张三");

        Assert.Equal(25, user.Age);
    }

    public void Dispose()
    {
        // 每个测试方法执行后都会调用
        if (Directory.Exists(_testDir))
            Directory.Delete(_testDir, true);
    }
}

3.2 生命周期总结

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
xUnit 生命周期:
┌──────────────────────────────────┐
│ ClassFixture 构造函数(一次)      │
│ ┌──────────────────────────────┐ │
│ │ 测试类构造函数(每个方法前)   │ │
│ │ 执行测试方法                  │ │
│ │ 测试类 Dispose(每个方法后)  │ │
│ └──────────────────────────────┘ │
│ ClassFixture Dispose(一次)      │
└──────────────────────────────────┘

四、并行测试

4.1 xUnit 并行策略

1
2
3
4
默认行为:
- 不同测试类并行执行
- 同一测试类内串行执行
- 同一 Collection 内串行执行

4.2 控制并行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 程序集级别:禁止并行
[assembly: CollectionBehavior(DisableTestParallelization = true)]

// 程序集级别:设置最大并行度
[assembly: CollectionBehavior(MaxParallelThreads = 4)]

// 类级别:禁止并行(测试类内串行)
// 默认就是串行,不需要额外设置

// 标记不互相干扰的测试类可以并行
[Collection("Sequential")]
public class TestClass1 { }

[Collection("Sequential")]
public class TestClass2 { }
// 同一个 Collection 的测试类不会并行执行

4.3 并行测试注意事项

1
2
3
4
1. 测试之间不能有共享可变状态
2. 不要依赖文件系统的固定路径
3. 不要依赖数据库的固定数据
4. 需要并行的测试类不要放在同一个 Collection

五、跳过测试

5.1 条件跳过

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[Fact(Skip = "Bug #123 未修复,暂时跳过")]
public void BrokenTest()
{
    // 这个测试不会运行
}

[Fact]
public void OnlyOnWindows()
{
    if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        return; // 或者用 Skip

    // Windows 专属测试
}

5.2 条件编译

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 只在 Debug 模式下运行
[Fact]
public void DebugOnlyTest()
{
#if DEBUG
    // 测试代码
#else
    Assert.True(true); // Release 模式下直接通过
#endif
}

六、自定义 xUnit 扩展

6.1 自定义 FactAttribute

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 只在特定条件下运行测试
public class WindowsOnlyFactAttribute : FactAttribute
{
    public WindowsOnlyFactAttribute()
    {
        if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            Skip = "只在 Windows 上运行";
    }
}

// 使用
[WindowsOnlyFact]
public void RegistryTest()
{
    // Windows 专属测试
}

6.2 自定义 TheoryAttribute

1
2
3
4
5
6
7
8
9
// 只在 CI 环境运行
public class CiOnlyTheoryAttribute : TheoryAttribute
{
    public CiOnlyTheoryAttribute()
    {
        if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")))
            Skip = "只在 CI 环境运行";
    }
}

七、FluentAssertions(可选增强)

虽然 xUnit 自带的 Assert 够用,但 FluentAssertions 让断言更易读。

7.1 安装

1
dotnet add package FluentAssertions

7.2 使用对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// xUnit 原生
Assert.Equal(5, result);
Assert.True(list.Contains(3));
Assert.Throws<ArgumentException>(() => method());

// FluentAssertions
result.Should().Be(5);
list.Should().Contain(3);
Action act = () => method();
act.Should().Throw<ArgumentException>();

7.3 更多示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 字符串
name.Should().StartWith("张").And.EndWith("三");
name.Should().NotBeNullOrEmpty();

// 集合
numbers.Should().HaveCount(5).And.ContainInOrder(1, 2, 3);
numbers.Should().OnlyContain(n => n > 0);
numbers.Should().BeInAscendingOrder();

// 异常
var act = () => calculator.Divide(10, 0);
act.Should().Throw<DivideByZeroException>()
   .WithMessage("除数不能为零*");

// 对象
user.Should().BeEquivalentTo(new { Name = "张三", Age = 25 });

// 日期
date.Should().BeCloseTo(DateTime.Now, TimeSpan.FromSeconds(1));

八、小结

本文学习了 xUnit 的进阶特性:

  • 数据驱动测试(Theory + InlineData/MemberData/ClassData/TheoryData)
  • 共享上下文(ClassFixture、CollectionFixture)
  • 测试生命周期(构造函数、Dispose)
  • 并行测试控制
  • 自定义扩展
  • FluentAssertions 增强

下一篇将学习 Mock 与隔离:如何隔离外部依赖,专注测试业务逻辑。