Skip to main content

单元测试 (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 模式组织测试代码
  • 一个测试只测试一件事
  • 测试行为而不是实现
  • 保持测试简单和快速

相关资源