单元测试 (xUnit)
为什么需要单元测试?
单元测试的价值
单元测试可以验证代码的正确性,提高代码质量,方便重构,并作为代码的活文档。
✅ 快速反馈 - 及时发现代码问题
🔄 支持重构 - 安全地修改代码
📝 代码文档 - 测试即文档
快速开始
安装 NuGet 包
# xUnit 测试框架
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
# Moq 模拟框架
dotnet add package Moq
# FluentAssertions 断言库
dotnet add package FluentAssertions
# 测试工具
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package coverlet.collector # 代码覆盖率
创建测试项目
# 创建测试项目
dotnet new xunit -n MyApp.Tests
# 添加项目引用
dotnet add MyApp.Tests reference MyApp
基础测试
简单测试
CalculatorTests.cs
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 int Divide(int a, int b) => a / b;
}
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);
}
[Theory]
[InlineData(2, 3, 5)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
[InlineData(10, -5, 5)]
public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public void Divide_ByZero_ThrowsException()
{
// Arrange
var calculator = new Calculator();
// Act & Assert
Assert.Throws<DivideByZeroException>(() => calculator.Divide(10, 0));
}
}
使用 FluentAssertions
using FluentAssertions;
public class UserTests
{
[Fact]
public void User_WithValidData_ShouldBeCreated()
{
// Arrange & Act
var user = new User
{
Id = 1,
Username = "zhangsan",
Email = "zhangsan@example.com",
Age = 25
};
// Assert
user.Should().NotBeNull();
user.Id.Should().Be(1);
user.Username.Should().Be("zhangsan");
user.Email.Should().Contain("@");
user.Age.Should().BeInRange(18, 100);
}
[Fact]
public void GetUsers_ShouldReturnNonEmptyList()
{
// Arrange
var userService = new UserService();
// Act
var users = userService.GetUsers();
// Assert
users.Should().NotBeNull();
users.Should().HaveCountGreaterThan(0);
users.Should().OnlyContain(u => u.Age >= 18);
}
}
使用 Moq 模拟依赖
基础模拟
public interface IUserRepository
{
Task<User> GetByIdAsync(int id);
Task<List<User>> GetAllAsync();
Task<User> AddAsync(User user);
}
public class UserService
{
private readonly IUserRepository _repository;
public UserService(IUserRepository repository)
{
_repository = repository;
}
public async Task<User> GetUserAsync(int id)
{
var user = await _repository.GetByIdAsync(id);
if (user == null)
{
throw new KeyNotFoundException($"User {id} not found");
}
return user;
}
}
public class UserServiceTests
{
[Fact]
public async Task GetUserAsync_UserExists_ReturnsUser()
{
// Arrange
var expectedUser = new User { Id = 1, Username = "zhangsan" };
var mockRepository = new Mock<IUserRepository>();
mockRepository
.Setup(r => r.GetByIdAsync(1))
.ReturnsAsync(expectedUser);
var service = new UserService(mockRepository.Object);
// Act
var result = await service.GetUserAsync(1);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(1);
result.Username.Should().Be("zhangsan");
// 验证方法被调用
mockRepository.Verify(r => r.GetByIdAsync(1), Times.Once);
}
[Fact]
public async Task GetUserAsync_UserNotExists_ThrowsException()
{
// Arrange
var mockRepository = new Mock<IUserRepository>();
mockRepository
.Setup(r => r.GetByIdAsync(It.IsAny<int>()))
.ReturnsAsync((User)null);
var service = new UserService(mockRepository.Object);
// Act & Assert
await Assert.ThrowsAsync<KeyNotFoundException>(
() => service.GetUserAsync(999));
mockRepository.Verify(r => r.GetByIdAsync(999), Times.Once);
}
}
高级模拟
public class OrderServiceTests
{
[Fact]
public async Task CreateOrder_ValidOrder_CallsRepositoryAndReturnsOrder()
{
// Arrange
var mockRepository = new Mock<IOrderRepository>();
var capturedOrder = (Order)null;
mockRepository
.Setup(r => r.AddAsync(It.IsAny<Order>()))
.Callback<Order>(order => capturedOrder = order)
.ReturnsAsync((Order o) => o);
var service = new OrderService(mockRepository.Object);
// Act
var result = await service.CreateOrderAsync(new CreateOrderRequest
{
UserId = 1,
Amount = 100
});
// Assert
result.Should().NotBeNull();
capturedOrder.Should().NotBeNull();
capturedOrder.UserId.Should().Be(1);
capturedOrder.Amount.Should().Be(100);
mockRepository.Verify(r => r.AddAsync(It.Is<Order>(o =>
o.UserId == 1 && o.Amount == 100)), Times.Once);
}
[Fact]
public async Task ProcessOrders_MultipleOrders_ProcessesInOrder()
{
// Arrange
var callSequence = new List<int>();
var mockRepository = new Mock<IOrderRepository>();
mockRepository
.Setup(r => r.GetByIdAsync(It.IsAny<int>()))
.Callback<int>(id => callSequence.Add(id))
.ReturnsAsync(new Order());
var service = new OrderService(mockRepository.Object);
// Act
await service.ProcessOrdersAsync(new[] { 1, 2, 3 });
// Assert
callSequence.Should().Equal(1, 2, 3);
}
}
测试 ASP.NET Core
Controller 测试
public class UsersControllerTests
{
[Fact]
public async Task GetUser_UserExists_ReturnsOkWithUser()
{
// Arrange
var expectedUser = new User { Id = 1, Username = "zhangsan" };
var mockService = new Mock<IUserService>();
mockService
.Setup(s => s.GetUserByIdAsync(1))
.ReturnsAsync(expectedUser);
var controller = new UsersController(mockService.Object);
// Act
var result = await controller.GetUser(1);
// Assert
var okResult = result.Result as OkObjectResult;
okResult.Should().NotBeNull();
okResult.StatusCode.Should().Be(200);
var user = okResult.Value as User;
user.Should().BeEquivalentTo(expectedUser);
}
[Fact]
public async Task GetUser_UserNotExists_ReturnsNotFound()
{
// Arrange
var mockService = new Mock<IUserService>();
mockService
.Setup(s => s.GetUserByIdAsync(It.IsAny<int>()))
.ReturnsAsync((User)null);
var controller = new UsersController(mockService.Object);
// Act
var result = await controller.GetUser(999);
// Assert
result.Result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task CreateUser_ValidRequest_ReturnsCreated()
{
// Arrange
var request = new CreateUserRequest
{
Username = "zhangsan",
Email = "zhangsan@example.com"
};
var createdUser = new User { Id = 1, Username = request.Username };
var mockService = new Mock<IUserService>();
mockService
.Setup(s => s.CreateUserAsync(It.IsAny<CreateUserRequest>()))
.ReturnsAsync(createdUser);
var controller = new UsersController(mockService.Object);
// Act
var result = await controller.CreateUser(request);
// Assert
var createdResult = result.Result as CreatedAtActionResult;
createdResult.Should().NotBeNull();
createdResult.StatusCode.Should().Be(201);
createdResult.ActionName.Should().Be(nameof(UsersController.GetUser));
}
}
中间件测试
public class ExceptionHandlingMiddlewareTests
{
[Fact]
public async Task InvokeAsync_NoException_CallsNext()
{
// Arrange
var context = new DefaultHttpContext();
var nextCalled = false;
RequestDelegate next = (ctx) =>
{
nextCalled = true;
return Task.CompletedTask;
};
var middleware = new ExceptionHandlingMiddleware(next);
// Act
await middleware.InvokeAsync(context);
// Assert
nextCalled.Should().BeTrue();
}
[Fact]
public async Task InvokeAsync_ExceptionThrown_Returns500()
{
// Arrange
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
RequestDelegate next = (ctx) => throw new Exception("Test exception");
var middleware = new ExceptionHandlingMiddleware(next);
// Act
await middleware.InvokeAsync(context);
// Assert
context.Response.StatusCode.Should().Be(500);
context.Response.ContentType.Should().Be("application/json");
}
}
测试模式
AAA 模式 (Arrange-Act-Assert)
[Fact]
public void Pattern_AAA_Example()
{
// Arrange - 准备测试数据和依赖
var calculator = new Calculator();
var a = 10;
var b = 5;
// Act - 执行被测试的方法
var result = calculator.Add(a, b);
// Assert - 验证结果
Assert.Equal(15, result);
}
Given-When-Then 模式
[Fact]
public void Pattern_GivenWhenThen_Example()
{
// Given - 给定初始条件
var orderService = new OrderService();
var order = new Order { Amount = 100 };
// When - 当执行某个操作
var discount = orderService.CalculateDiscount(order);
// Then - 那么应该得到某个结果
discount.Should().Be(10);
}
测试数据
使用 Theory 和 InlineData
[Theory]
[InlineData("", false)]
[InlineData("a", false)]
[InlineData("abc", true)]
[InlineData("abcdef", true)]
public void IsValidUsername_VariousInputs_ReturnsExpectedResult(string username, bool expected)
{
var result = Validator.IsValidUsername(username);
result.Should().Be(expected);
}
使用 MemberData
public class CalculatorTests
{
public static IEnumerable<object[]> GetTestData()
{
yield return new object[] { 2, 3, 5 };
yield return new object[] { 0, 0, 0 };
yield return new object[] { -1, 1, 0 };
yield return new object[] { 10, -5, 5 };
}
[Theory]
[MemberData(nameof(GetTestData))]
public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
var calculator = new Calculator();
var result = calculator.Add(a, b);
result.Should().Be(expected);
}
}
使用 ClassData
public class AddTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { 2, 3, 5 };
yield return new object[] { 0, 0, 0 };
yield return new object[] { -1, 1, 0 };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Theory]
[ClassData(typeof(AddTestData))]
public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
var calculator = new Calculator();
var result = calculator.Add(a, b);
result.Should().Be(expected);
}
最佳实践
单元测试最佳实践
1. 测试命名规范
// 模式: MethodName_Scenario_ExpectedBehavior
// ✅ 好
[Fact]
public void Add_PositiveNumbers_ReturnsSum() { }
[Fact]
public void GetUser_UserNotExists_ThrowsException() { }
[Fact]
public void Login_InvalidCredentials_ReturnsFalse() { }
// ❌ 差
[Fact]
public void Test1() { }
[Fact]
public void AddTest() { }
2. 一个测试只测一件事
// ✅ 好:分开测试
[Fact]
public void CreateUser_ValidData_ReturnsUser() { }
[Fact]
public void CreateUser_DuplicateUsername_ThrowsException() { }
[Fact]
public void CreateUser_InvalidEmail_ThrowsException() { }
// ❌ 差:一个测试测试多个场景
[Fact]
public void CreateUser_AllScenarios()
{
// 测试成功场景
// 测试异常场景1
// 测试异常场景2
}
3. 避免测试实现细节
// ✅ 好:测试行为
[Fact]
public void ProcessOrder_ValidOrder_ReturnsSuccess()
{
var result = orderService.ProcessOrder(order);
result.Success.Should().BeTrue();
}
// ❌ 差:测试实现
[Fact]
public void ProcessOrder_CallsRepositoryAndLogger()
{
orderService.ProcessOrder(order);
mockRepository.Verify(r => r.Save(It.IsAny<Order>()));
mockLogger.Verify(l => l.Log(It.IsAny<string>()));
}
4. 使用 Fixture 减少重复
public class UserServiceTestsFixture : IDisposable
{
public Mock<IUserRepository> MockRepository { get; }
public UserService Service { get; }
public UserServiceTestsFixture()
{
MockRepository = new Mock<IUserRepository>();
Service = new UserService(MockRepository.Object);
}
public void Dispose()
{
// 清理资源
}
}
public class UserServiceTests : IClassFixture<UserServiceTestsFixture>
{
private readonly UserServiceTestsFixture _fixture;
public UserServiceTests(UserServiceTestsFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task GetUserAsync_Test()
{
// 使用 _fixture.Service 和 _fixture.MockRepository
}
}
运行测试
# 运行所有测试
dotnet test
# 运行特定测试项目
dotnet test MyApp.Tests
# 运行特定测试
dotnet test --filter "FullyQualifiedName~UserServiceTests"
# 生成代码覆盖率报告
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
# 详细输出
dotnet test --logger "console;verbosity=detailed"
总结
关键要点
- 使用 xUnit 作为测试框架
- 使用 Moq 模拟依赖项
- 使用 FluentAssertions 提高断言可读性
- 遵循 AAA 模式组织测试代码
- 一个测试只测试一件事
- 测试行为而不是实现
- 保持测试简单和快速