xUnit + Moq 单元测试
大约 9 分钟约 2653 字
xUnit + Moq 单元测试
简介
单元测试是保证代码质量的基础手段。xUnit 是 .NET 最流行的测试框架,Moq 是最常用的 Mock 库。掌握 Arrange-Act-Assert 模式、Mock 依赖注入、数据驱动测试,可以构建可靠且易维护的测试体系,在 CI/CD 中自动验证代码正确性。
特点
xUnit 基础
测试类与测试方法
/// <summary>
/// xUnit 基础用法
/// </summary>
using Xunit;
using Moq;
public class CalculatorTests
{
[Fact]
public void Add_TwoNumbers_ReturnsSum()
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(3, 5);
// Assert
Assert.Equal(8, result);
}
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
[InlineData(100, 200, 300)]
public void Add_MultipleCases_ReturnsExpected(int a, int b, int expected)
{
var calculator = new Calculator();
var result = calculator.Add(a, b);
Assert.Equal(expected, result);
}
[Fact]
public void Divide_ByZero_ThrowsException()
{
var calculator = new Calculator();
Assert.Throws<DivideByZeroException>(() => calculator.Divide(10, 0));
}
}Moq 模拟
Mock 依赖
/// <summary>
/// Moq 模拟接口和行为验证
/// </summary>
public class UserServiceTests
{
private readonly Mock<IUserRepository> _mockRepo;
private readonly Mock<ILogger<UserService>> _mockLogger;
private readonly UserService _service;
public UserServiceTests()
{
_mockRepo = new Mock<IUserRepository>();
_mockLogger = new Mock<ILogger<UserService>>();
_service = new UserService(_mockRepo.Object, _mockLogger.Object);
}
[Fact]
public async Task GetById_ExistingUser_ReturnsUser()
{
// Arrange
var user = new User { Id = 1, Name = "张三" };
_mockRepo.Setup(r => r.GetByIdAsync(1))
.ReturnsAsync(user);
// Act
var result = await _service.GetByIdAsync(1);
// Assert
Assert.NotNull(result);
Assert.Equal("张三", result!.Name);
_mockRepo.Verify(r => r.GetByIdAsync(1), Times.Once);
}
[Fact]
public async Task Create_ValidUser_CallsRepository()
{
// Arrange
var request = new CreateUserRequest { Name = "李四", Email = "li@test.com" };
_mockRepo.Setup(r => r.ExistsByEmailAsync(request.Email))
.ReturnsAsync(false);
_mockRepo.Setup(r => r.AddAsync(It.IsAny<User>()))
.ReturnsAsync(new User { Id = 1, Name = request.Name });
// Act
var result = await _service.CreateAsync(request);
// Assert
Assert.NotNull(result);
_mockRepo.Verify(r => r.AddAsync(It.Is<User>(
u => u.Name == "李四" && u.Email == "li@test.com")), Times.Once);
}
[Fact]
public async Task Create_DuplicateEmail_ThrowsException()
{
var request = new CreateUserRequest { Name = "张三", Email = "exist@test.com" };
_mockRepo.Setup(r => r.ExistsByEmailAsync(request.Email))
.ReturnsAsync(true);
await Assert.ThrowsAsync<BizException>(() => _service.CreateAsync(request));
_mockRepo.Verify(r => r.AddAsync(It.IsAny<User>()), Times.Never);
}
}高级 Mock 技巧
/// <summary>
/// Moq 高级用法
/// </summary>
// Callback — 捕获参数
var capturedUsers = new List<User>();
_mockRepo.Setup(r => r.AddAsync(It.IsAny<User>()))
.Callback<User>(u => capturedUsers.Add(u))
.ReturnsAsync((User u) => u with { Id = 99 });
await _service.CreateAsync(request);
Assert.Single(capturedUsers);
Assert.Equal("张三", capturedUsers[0].Name);
// Throw — 模拟异常
_mockRepo.Setup(r => r.GetByIdAsync(999))
.ThrowsAsync(new Exception("数据库连接失败"));
// SetupSequential — 连续返回不同值
_mockRepo.SetupSequence(r => r.GetNextId())
.Returns(1).Returns(2).Returns(3);
// Verify — 验证调用行为
_mockRepo.Verify(r => r.AddAsync(It.IsAny<User>()), Times.Once);
_mockRepo.Verify(r => r.DeleteAsync(It.IsAny<int>()), Times.Never);
_mockRepo.Verify(r => r.SaveChangesAsync(), Times.AtLeastOnce());
// Reset — 重置 Mock 状态
_mockRepo.Reset();数据驱动测试
MemberData 与 ClassData
/// <summary>
/// 数据驱动测试
/// /// </summary>
public class MathTests
{
// MemberData — 引用静态属性
public static TheoryData<int, int, int> AdditionData => new()
{
{ 1, 2, 3 },
{ -1, 1, 0 },
{ 100, -50, 50 }
};
[Theory]
[MemberData(nameof(AdditionData))]
public void Add_FromMemberData_ReturnsExpected(int a, int b, int expected)
{
Assert.Equal(expected, a + b);
}
}
// ClassData — 复杂数据集
public class UserValidationData : TheoryData<string, bool>
{
public UserValidationData()
{
Add("test@example.com", true);
Add("invalid-email", false);
Add("", false);
Add("a@b.c", false);
}
}
public class EmailValidatorTests
{
[Theory]
[ClassData(typeof(UserValidationData))]
public void Validate_Email_ReturnsExpected(string email, bool expected)
{
var validator = new EmailValidator();
Assert.Equal(expected, validator.IsValid(email));
}
}异步测试
异步方法测试
/// <summary>
/// 异步测试最佳实践
/// </summary>
public class AsyncServiceTests
{
private readonly Mock<IUserRepository> _mockRepo = new();
private readonly UserService _service;
public AsyncServiceTests()
{
_service = new UserService(_mockRepo.Object);
}
[Fact]
public async Task GetAll_ReturnsAllUsers()
{
// Arrange
var users = new List<User>
{
new() { Id = 1, Name = "张三" },
new() { Id = 2, Name = "李四" }
};
_mockRepo.Setup(r => r.GetAllAsync())
.ReturnsAsync(users);
// Act
var result = await _service.GetAllAsync();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
}
[Fact]
public async Task Delete_NonExistent_ThrowsNotFoundException()
{
// Arrange
_mockRepo.Setup(r => r.GetByIdAsync(999))
.ReturnsAsync((User?)null);
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(
() => _service.DeleteAsync(999));
}
// 使用 CancellationToken 测试超时场景
[Fact]
public async Task LongRunningOperation_CanBeCancelled()
{
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(100));
await Assert.ThrowsAsync<OperationCanceledException>(
() => _service.LongRunningOperationAsync(cts.Token));
}
}
// 测试超时设置(xUnit 特性)
[Fact(Timeout = 5000)] // 整个测试超时 5 秒
public async Task TimeoutExample_WillFail_IfTooSlow()
{
await Task.Delay(10000); // 这会超时失败
}测试并发场景
/// <summary>
/// 并发安全测试
/// </summary>
public class ConcurrencyTests
{
[Fact]
public async Task ConcurrentRequests_DoNotCorruptState()
{
var service = new ThreadSafeCounterService();
var tasks = Enumerable.Range(0, 100)
.Select(_ => service.IncrementAsync());
await Task.WhenAll(tasks);
Assert.Equal(100, service.Count);
}
[Fact]
public async Task SemaphoreSlim_LimitsConcurrency()
{
var service = new ConcurrencyLimitedService(maxConcurrency: 3);
var stopwatch = Stopwatch.StartNew();
var tasks = Enumerable.Range(0, 6)
.Select(_ => service.ExecuteAsync());
await Task.WhenAll(tasks);
stopwatch.Stop();
// 6个任务,最大3并发,每个500ms → 至少需要1秒
Assert.True(stopwatch.ElapsedMilliseconds >= 900);
}
}
public class ThreadSafeCounterService
{
private int _count;
public int Count => _count;
public async Task IncrementAsync()
{
await Task.Delay(10);
Interlocked.Increment(ref _count);
}
}测试异常与边界
异常测试模式
/// <summary>
/// 异常测试的各种模式
/// </summary>
public class ExceptionTestExamples
{
[Fact]
public void SyncMethod_ThrowsExactException()
{
var service = new OrderService();
var ex = Assert.Throws<InvalidOrderException>(
() => service.CreateOrder(null!));
Assert.Equal("订单不能为空", ex.Message);
}
[Fact]
public async Task AsyncMethod_ThrowsExactException()
{
var service = new OrderService();
var ex = await Assert.ThrowsAsync<InvalidOrderException>(
() => service.CreateOrderAsync(null!));
Assert.Equal("订单不能为空", ex.Message);
}
[Fact]
public void Exception_ContainsInnerException()
{
var service = new OrderService();
var ex = Assert.ThrowsAny<Exception>(
() => service.ProcessWithDatabaseError());
Assert.NotNull(ex.InnerException);
Assert.Contains("连接超时", ex.InnerException.Message);
}
// 验证异常属性
[Fact]
public void CustomException_HasCorrectProperties()
{
var ex = new BizException
{
Code = "ORDER_NOT_FOUND",
Message = "订单不存在",
StatusCode = 404
};
Assert.Equal("ORDER_NOT_FOUND", ex.Code);
Assert.Equal(404, ex.StatusCode);
}
}测试私有方法与内部状态
测试策略
/// <summary>
/// 测试私有方法的策略
/// </summary>
public class PrivateMethodTestStrategy
{
// 策略 1:通过公有方法间接测试(推荐)
[Fact]
public void PublicMethod_CoversPrivateLogic()
{
var calculator = new TaxCalculator();
// 不直接测试 private CalculateBaseTax(),而是测试公有方法
var result = calculator.CalculateTotal(100, "CN");
Assert.True(result > 0);
}
// 策略 2:通过 InternalsVisibleTo 暴露给测试项目
// 在生产项目中添加:
// [assembly: InternalsVisibleTo("MyProject.Tests")]
[Fact]
public void InternalMethod_CanBeTested()
{
var calculator = new TaxCalculator();
var baseTax = calculator.CalculateBaseTaxInternal(100);
Assert.Equal(13, baseTax);
}
}
// 策略 3:使用反射测试(最后手段)
[Fact]
public void PrivateMethod_ViaReflection()
{
var calculator = new TaxCalculator();
var method = typeof(TaxCalculator).GetMethod(
"CalculateBaseTax",
BindingFlags.NonPublic | BindingFlags.Instance);
var result = method?.Invoke(calculator, new object[] { 100 });
Assert.Equal(13, result);
}FluentAssertions 与断言增强
更可读的断言
/// <summary>
/// 使用 FluentAssertions 提升断言可读性
/// // NuGet: FluentAssertions
/// </summary>
public class FluentAssertionTests
{
[Fact]
public void UserProperties_MatchExpected()
{
var user = new User { Id = 1, Name = "张三", Email = "zhang@test.com" };
// FluentAssertions 风格
user.Should().NotBeNull();
user.Id.Should().Be(1);
user.Name.Should().Be("张三").And.NotBeEmpty();
user.Email.Should().Contain("@").And.EndWith(".com");
}
[Fact]
public void CollectionAssertions()
{
var users = new List<User>
{
new() { Id = 1, Name = "张三" },
new() { Id = 2, Name = "李四" },
new() { Id = 3, Name = "王五" }
};
users.Should().HaveCount(3);
users.Should().Contain(u => u.Name == "张三");
users.Should().BeInAscendingOrder(u => u.Id);
users.Should().OnlyHaveUniqueItems(u => u.Id);
}
[Fact]
public async Task ExceptionAssertions()
{
var service = new OrderService();
var act = () => service.CreateOrderAsync(null!);
await act.Should()
.ThrowAsync<InvalidOrderException>()
.WithMessage("*不能为空*")
.Where(ex => ex.StatusCode == 400);
}
[Fact]
public void ObjectEquivalence()
{
var dto = new UserDto { Id = 1, Name = "张三", Email = "z@test.com" };
var entity = new User { Id = 1, Name = "张三", Email = "z@test.com" };
dto.Should().BeEquivalentTo(entity, options =>
options.ExcludingMissingMembers());
}
}测试命名规范与组织
命名约定
/// <summary>
/// 测试命名规范:方法名_场景_预期结果
/// </summary>
public class OrderServiceNamingTests
{
// 好的命名:清晰描述测试场景
[Fact]
public void CalculateDiscount_WithVipUser_ReturnsTwentyPercent()
{
// ...
}
[Fact]
public void CalculateDiscount_WithExpiredCoupon_ReturnsZero()
{
// ...
}
[Fact]
public void CalculateDiscount_WhenOrderAmountBelowMinimum_ReturnsZero()
{
// ...
}
// 不好的命名(避免):
// [Fact]
// public void Test1() { } // 无意义
// [Fact]
// public void DiscountWorks() { } // 不够具体
// [Fact]
// public void TestDiscount() { } // 没有描述场景
}性能测试
BenchmarkDotNet 集成
/// <summary>
/// 性能测试(BenchmarkDotNet)
/// // NuGet: BenchmarkDotNet
/// </summary>
[MemoryDiagnoser]
public class SerializerBenchmark
{
private readonly User _testUser = new()
{
Id = 1,
Name = "张三",
Email = "zhangsan@example.com",
CreatedAt = DateTime.UtcNow
};
[Benchmark(Baseline = true)]
public string SystemTextJson()
{
return JsonSerializer.Serialize(_testUser);
}
[Benchmark]
public string NewtonsoftJson()
{
return Newtonsoft.Json.JsonConvert.SerializeObject(_testUser);
}
[Benchmark]
public byte[] MessagePack()
{
return MessagePackSerializer.Serialize(_testUser);
}
}
// 运行:dotnet run -c Release
// 输出:每种序列化方法的平均耗时、内存分配等指标集合夹具
共享测试上下文
/// <summary>
/// IClassFixture — 共享测试数据
/// </summary>
public class DatabaseFixture : IDisposable
{
public string ConnectionString { get; }
public IServiceProvider Services { get; }
public DatabaseFixture()
{
ConnectionString = "Server=localhost;Database=test_db;";
var services = new ServiceCollection();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IUserService, UserService>();
Services = services.BuildServiceProvider();
}
public void Dispose()
{
// 清理测试数据
}
}
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
[Collection("Database")]
public class UserRepositoryTests
{
private readonly DatabaseFixture _fixture;
public UserRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task AddAndRetrieve_WorksCorrectly()
{
using var scope = _fixture.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IUserRepository>();
var user = new User { Name = "测试用户" };
await repo.AddAsync(user);
var found = await repo.GetByIdAsync(user.Id);
Assert.NotNull(found);
}
}优点
缺点
总结
单元测试推荐 xUnit + Moq 组合。核心模式:Arrange-Act-Assert,Mock 外部依赖。数据驱动测试用 Theory + InlineData/MemberData。测试原则:测试行为而非实现,每个测试只验证一个场景,测试命名清晰描述预期结果。
关键知识点
- 先分清这个主题位于请求链路、后台任务链路还是基础设施链路。
- 服务端主题通常不只关心功能正确,还关心稳定性、性能和可观测性。
- 任何框架能力都要结合配置、生命周期、异常传播和外部依赖一起看。
项目落地视角
- 画清请求进入、业务执行、外部调用、日志记录和错误返回的完整路径。
- 为关键链路补齐超时、重试、熔断、追踪和结构化日志。
- 把配置与敏感信息分离,并明确不同环境的差异来源。
常见误区
- 只会堆中间件或组件,不知道它们在链路中的执行顺序。
- 忽略生命周期和线程池、连接池等运行时资源约束。
- 没有监控和测试就对性能或可靠性下结论。
进阶路线
- 继续向运行时行为、可观测性、发布治理和微服务协同深入。
- 把主题和数据库、缓存、消息队列、认证授权联动起来理解。
- 沉淀团队级模板,包括统一异常处理、配置约定和基础设施封装。
适用场景
- 当你准备把《xUnit + Moq 单元测试》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合 API 服务、后台任务、实时通信、认证授权和微服务协作场景。
- 当需求开始涉及稳定性、性能、可观测性和发布流程时,这类主题会成为基础设施能力。
落地建议
- 先定义请求链路与失败路径,再决定中间件、过滤器、服务边界和依赖方式。
- 为关键链路补日志、指标、追踪、超时与重试策略。
- 环境配置与敏感信息分离,避免把生产参数写死在代码或镜像里。
排错清单
- 先确认问题发生在路由、模型绑定、中间件、业务层还是基础设施层。
- 检查 DI 生命周期、配置来源、序列化规则和认证上下文。
- 查看线程池、连接池、缓存命中率和外部依赖超时。
复盘问题
- 如果把《xUnit + Moq 单元测试》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《xUnit + Moq 单元测试》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《xUnit + Moq 单元测试》最大的收益和代价分别是什么?
