单元测试
🧪 确保 MediatR 应用的质量和可靠性
📖 概述
单元测试是保证 MediatR 应用质量的关键环节。由于 MediatR 的设计天然支持测试(Handler 是纯函数、依赖注入清晰),编写单元测试相对简单且高效。
测试目标
- ✅ 验证 Handler 业务逻辑正确性
- ✅ 测试管道行为的横切关注点
- ✅ 验证通知处理器的多播机制
- ✅ 确保异常处理符合预期
- ✅ Mock IMediator 进行集成测试
🔧 测试环境搭建
安装 NuGet 包
bash
# 创建测试项目
dotnet new xunit -n YourProject.Tests
# 添加引用
cd YourProject.Tests
dotnet add reference ../YourProject/YourProject.csproj
# 安装测试框架和 Mock 库
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package Moq
dotnet add package FluentAssertions # 可选,更优雅的断言基础测试结构
csharp
// Tests/Handlers/CreateOrderHandlerTests.cs
using Xunit;
using Moq;
using FluentAssertions;
public class CreateOrderHandlerTests
{
private readonly Mock<IOrderRepository> _mockRepo;
private readonly Mock<IMediator> _mockMediator;
private readonly CreateOrderHandler _handler;
public CreateOrderHandlerTests()
{
_mockRepo = new Mock<IOrderRepository>();
_mockMediator = new Mock<IMediator>();
_handler = new CreateOrderHandler(
_mockRepo.Object,
_mockMediator.Object);
}
[Fact]
public async Task Handle_ValidCommand_ShouldCreateOrder()
{
// Arrange
var command = new CreateOrderCommand
{
ProductName = "iPhone",
Quantity = 2,
Price = 7999
};
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.OrderId.Should().NotBeEmpty();
_mockRepo.Verify(r => r.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once);
}
}🎯 单独测试 Handler
测试无返回值 Handler
csharp
public class DeleteOrderHandler : IRequestHandler<DeleteOrderCommand>
{
private readonly IOrderRepository _repo;
public DeleteOrderHandler(IOrderRepository repo)
{
_repo = repo;
}
public async Task Handle(DeleteOrderCommand request, CancellationToken ct)
{
await _repo.DeleteAsync(request.OrderId, ct);
}
}
// 测试
public class DeleteOrderHandlerTests
{
private readonly Mock<IOrderRepository> _mockRepo;
private readonly DeleteOrderHandler _handler;
public DeleteOrderHandlerTests()
{
_mockRepo = new Mock<IOrderRepository>();
_handler = new DeleteOrderHandler(_mockRepo.Object);
}
[Fact]
public async Task Handle_ShouldDeleteOrder()
{
// Arrange
var orderId = Guid.NewGuid();
var command = new DeleteOrderCommand { OrderId = orderId };
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
_mockRepo.Verify(r => r.DeleteAsync(orderId, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_NonExistentOrder_ShouldNotThrow()
{
// Arrange
_mockRepo.Setup(r => r.DeleteAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new NotFoundException("Order not found"));
var command = new DeleteOrderCommand { OrderId = Guid.NewGuid() };
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() =>
_handler.Handle(command, CancellationToken.None));
}
}测试带返回值 Handler
csharp
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
private readonly IOrderRepository _repo;
private readonly IMapper _mapper;
public GetOrderHandler(IOrderRepository repo, IMapper mapper)
{
_repo = repo;
_mapper = mapper;
}
public async Task<OrderDto> Handle(GetOrderQuery request, CancellationToken ct)
{
var order = await _repo.GetByIdAsync(request.OrderId, ct);
if (order == null)
throw new NotFoundException($"Order {request.OrderId} not found");
return _mapper.Map<OrderDto>(order);
}
}
// 测试
public class GetOrderHandlerTests
{
private readonly Mock<IOrderRepository> _mockRepo;
private readonly Mock<IMapper> _mockMapper;
private readonly GetOrderHandler _handler;
public GetOrderHandlerTests()
{
_mockRepo = new Mock<IOrderRepository>();
_mockMapper = new Mock<IMapper>();
_handler = new GetOrderHandler(_mockRepo.Object, _mockMapper.Object);
}
[Fact]
public async Task Handle_ExistingOrder_ShouldReturnDto()
{
// Arrange
var orderId = Guid.NewGuid();
var order = new Order { Id = orderId, ProductName = "iPhone" };
var dto = new OrderDto { Id = orderId, ProductName = "iPhone" };
_mockRepo.Setup(r => r.GetByIdAsync(orderId, It.IsAny<CancellationToken>()))
.ReturnsAsync(order);
_mockMapper.Setup(m => m.Map<OrderDto>(order))
.Returns(dto);
var query = new GetOrderQuery { OrderId = orderId };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().BeEquivalentTo(dto);
_mockRepo.Verify(r => r.GetByIdAsync(orderId, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_NonExistentOrder_ShouldThrowNotFoundException()
{
// Arrange
var orderId = Guid.NewGuid();
_mockRepo.Setup(r => r.GetByIdAsync(orderId, It.IsAny<CancellationToken>()))
.ReturnsAsync((Order)null);
var query = new GetOrderQuery { OrderId = orderId };
// Act & Assert
var exception = await Assert.ThrowsAsync<NotFoundException>(() =>
_handler.Handle(query, CancellationToken.None));
exception.Message.Should().Contain(orderId.ToString());
}
}使用 Theory 进行参数化测试
csharp
public class CalculatePriceHandlerTests
{
private readonly CalculatePriceHandler _handler;
public CalculatePriceHandlerTests()
{
_handler = new CalculatePriceHandler();
}
[Theory]
[InlineData(1, 100, 100)] // 数量1,单价100,总价100
[InlineData(2, 100, 200)] // 数量2,单价100,总价200
[InlineData(5, 50, 250)] // 数量5,单价50,总价250
[InlineData(10, 99.99, 999.9)] // 小数价格
public async Task Handle_ShouldCalculateCorrectTotal(int quantity, decimal price, decimal expectedTotal)
{
// Arrange
var query = new CalculatePriceQuery
{
Quantity = quantity,
UnitPrice = price
};
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.TotalAmount.Should().BeApproximately(expectedTotal, 0.01m);
}
[Theory]
[InlineData(0)] // 数量为0
[InlineData(-1)] // 负数
[InlineData(-100)] // 大负数
public async Task Handle_InvalidQuantity_ShouldThrowValidationException(int invalidQuantity)
{
// Arrange
var query = new CalculatePriceQuery
{
Quantity = invalidQuantity,
UnitPrice = 100
};
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_handler.Handle(query, CancellationToken.None));
}
}🔧 测试管道行为
测试日志行为
csharp
public class LoggingBehaviorTests
{
private readonly Mock<ILogger<LoggingBehavior<TestRequest, TestResponse>>> _mockLogger;
private readonly LoggingBehavior<TestRequest, TestResponse> _behavior;
public LoggingBehaviorTests()
{
_mockLogger = new Mock<ILogger<LoggingBehavior<TestRequest, TestResponse>>>();
_behavior = new LoggingBehavior<TestRequest, TestResponse>(_mockLogger.Object);
}
[Fact]
public async Task Handle_ShouldLogStartAndEnd()
{
// Arrange
var request = new TestRequest { Data = "test" };
var response = new TestResponse { Result = "success" };
RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(response);
// Act
var result = await _behavior.Handle(request, next, CancellationToken.None);
// Assert
result.Should().BeEquivalentTo(response);
// 验证日志被调用
_mockLogger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("开始处理请求")),
null,
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task Handle_Exception_ShouldLogError()
{
// Arrange
var request = new TestRequest();
RequestHandlerDelegate<TestResponse> next = () => throw new InvalidOperationException("Test error");
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_behavior.Handle(request, next, CancellationToken.None));
_mockLogger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("请求处理失败")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
}
}测试验证行为
csharp
public class ValidationBehaviorTests
{
private readonly Mock<IValidator<TestRequest>> _mockValidator;
private readonly ValidationBehavior<TestRequest, TestResponse> _behavior;
public ValidationBehaviorTests()
{
_mockValidator = new Mock<IValidator<TestRequest>>();
_behavior = new ValidationBehavior<TestRequest, TestResponse>(
new[] { _mockValidator.Object });
}
[Fact]
public async Task Handle_ValidRequest_ShouldCallNext()
{
// Arrange
var request = new TestRequest { Name = "Valid" };
var response = new TestResponse();
_mockValidator.Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<TestRequest>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult()); // 验证通过
bool nextCalled = false;
RequestHandlerDelegate<TestResponse> next = () =>
{
nextCalled = true;
return Task.FromResult(response);
};
// Act
var result = await _behavior.Handle(request, next, CancellationToken.None);
// Assert
nextCalled.Should().BeTrue();
result.Should().Be(response);
}
[Fact]
public async Task Handle_InvalidRequest_ShouldThrowValidationException()
{
// Arrange
var request = new TestRequest { Name = "" }; // 无效数据
var failures = new List<ValidationFailure>
{
new ValidationFailure("Name", "Name cannot be empty")
};
_mockValidator.Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<TestRequest>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult(failures));
RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(new TestResponse());
// Act & Assert
var exception = await Assert.ThrowsAsync<ValidationException>(() =>
_behavior.Handle(request, next, CancellationToken.None));
exception.Errors.Should().HaveCount(1);
exception.Errors[0].PropertyName.Should().Be("Name");
}
}📢 测试发布通知
测试多个处理器被调用
csharp
public class OrderCreatedNotificationTests
{
private readonly Mock<IEmailService> _mockEmailService;
private readonly Mock<ICacheService> _mockCacheService;
private readonly Mock<IAuditService> _mockAuditService;
public OrderCreatedNotificationTests()
{
_mockEmailService = new Mock<IEmailService>();
_mockCacheService = new Mock<ICacheService>();
_mockAuditService = new Mock<IAuditService>();
}
[Fact]
public async Task AllHandlers_ShouldBeCalled()
{
// Arrange
var notification = new OrderCreatedNotification
{
OrderId = Guid.NewGuid(),
CustomerEmail = "test@example.com"
};
var emailHandler = new SendEmailHandler(_mockEmailService.Object);
var cacheHandler = new UpdateCacheHandler(_mockCacheService.Object);
var auditHandler = new AuditLogHandler(_mockAuditService.Object);
// Act - 并行执行所有处理器
await Task.WhenAll(
emailHandler.Handle(notification, CancellationToken.None),
cacheHandler.Handle(notification, CancellationToken.None),
auditHandler.Handle(notification, CancellationToken.None)
);
// Assert - 验证所有处理器都被调用
_mockEmailService.Verify(e => e.SendAsync(notification.CustomerEmail, It.IsAny<string>()), Times.Once);
_mockCacheService.Verify(c => c.InvalidateAsync(It.IsAny<string>()), Times.Once);
_mockAuditService.Verify(a => a.LogAsync(notification.OrderId), Times.Once);
}
[Fact]
public async Task OneHandlerFails_OthersShouldStillExecute()
{
// Arrange
var notification = new OrderCreatedNotification
{
OrderId = Guid.NewGuid(),
CustomerEmail = "test@example.com"
};
_mockEmailService.Setup(e => e.SendAsync(It.IsAny<string>(), It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException("Email service down"));
var emailHandler = new SendEmailHandler(_mockEmailService.Object);
var cacheHandler = new UpdateCacheHandler(_mockCacheService.Object);
// Act - 即使一个失败,其他仍应执行
var emailTask = emailHandler.Handle(notification, CancellationToken.None);
var cacheTask = cacheHandler.Handle(notification, CancellationToken.None);
await Assert.ThrowsAsync<InvalidOperationException>(() => emailTask);
await cacheTask; // 这个应该成功
// Assert
_mockCacheService.Verify(c => c.InvalidateAsync(It.IsAny<string>()), Times.Once);
}
}🎭 Mock IMediator
在控制器测试中 Mock Mediator
csharp
public class OrdersControllerTests
{
private readonly Mock<IMediator> _mockMediator;
private readonly OrdersController _controller;
public OrdersControllerTests()
{
_mockMediator = new Mock<IMediator>();
_controller = new OrdersController(_mockMediator.Object);
}
[Fact]
public async Task CreateOrder_ValidCommand_ShouldReturnCreated()
{
// Arrange
var command = new CreateOrderCommand
{
ProductName = "iPhone",
Quantity = 1
};
var expectedResult = new OrderResult
{
OrderId = Guid.NewGuid(),
TotalAmount = 7999
};
_mockMediator.Setup(m => m.Send(command, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResult);
// Act
var result = await _controller.CreateOrder(command);
// Assert
var actionResult = Assert.IsType<ActionResult<OrderResult>>(result);
var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
createdAtActionResult.StatusCode.Should().Be(201);
createdAtActionResult.RouteValues["id"].Should().Be(expectedResult.OrderId);
}
[Fact]
public async Task GetOrder_NotFound_ShouldReturn404()
{
// Arrange
var orderId = Guid.NewGuid();
var query = new GetOrderQuery { OrderId = orderId };
_mockMediator.Setup(m => m.Send(query, It.IsAny<CancellationToken>()))
.ReturnsAsync((OrderDto)null);
// Act
var result = await _controller.GetOrder(orderId);
// Assert
result.Result.Should().BeOfType<NotFoundResult>();
}
}🔗 集成测试
使用 WebApplicationFactory
bash
dotnet add package Microsoft.AspNetCore.Mvc.Testingcsharp
public class OrdersApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersApiIntegrationTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateOrder_EndToEnd_ShouldSucceed()
{
// Arrange
var command = new CreateOrderCommand
{
ProductName = "MacBook Pro",
Quantity = 1,
Price = 14999
};
var content = new StringContent(
JsonSerializer.Serialize(command),
Encoding.UTF8,
"application/json");
// Act
var response = await _client.PostAsync("/api/orders", content);
// Assert
response.EnsureSuccessStatusCode();
var responseBody = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<OrderResult>(responseBody);
result.Should().NotBeNull();
result.OrderId.Should().NotBeEmpty();
result.TotalAmount.Should().Be(14999);
}
[Fact]
public async Task GetOrder_InvalidId_ShouldReturn404()
{
// Act
var response = await _client.GetAsync($"/api/orders/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}🎯 最佳实践
✅ 推荐做法
csharp
// 1. 每个 Handler 一个测试类
public class CreateOrderHandlerTests { }
public class GetOrderHandlerTests { }
// 2. 使用构造函数初始化 Mock
public class MyTests
{
private readonly Mock<IService> _mockService;
public MyTests()
{
_mockService = new Mock<IService>();
}
}
// 3. 使用 FluentAssertions 提高可读性
result.Should().NotBeNull();
result.OrderId.Should().NotBeEmpty();
list.Should().HaveCount(5);
// 4. 使用 Theory 进行参数化测试
[Theory]
[InlineData(1, 100)]
[InlineData(2, 200)]
public void Test(int input, int expected) { }
// 5. 测试异常情况
await Assert.ThrowsAsync<NotFoundException>(() => handler.Handle(query, ct));❌ 避免的做法
csharp
// 1. 不要测试 MediatR 框架本身
// MediatR 已经有完善的测试,不需要重复测试框架
// 2. 不要在单元测试中启动完整应用
// 使用 Mock 隔离依赖
// 3. 不要忽略异步测试
public async Task Test() // ✅ 正确
public void Test() // ❌ 错误:异步方法应该返回 Task
// 4. 不要硬编码魔法数字
const int ExpectedCount = 5; // ✅ 正确
list.Count.Should().Be(5); // ❌ 错误📊 测试覆盖率目标
| 组件 | 目标覆盖率 | 说明 |
|---|---|---|
| Handler | 90%+ | 核心业务逻辑必须充分测试 |
| Pipeline Behavior | 85%+ | 横切关注点需要全面测试 |
| Notification Handler | 80%+ | 事件处理器需要测试 |
| Controller | 70%+ | 主要测试 HTTP 层面 |
| Validator | 95%+ | 验证规则必须完全覆盖 |
🎓 总结
核心价值
✅ Handler 易于测试:纯函数、依赖清晰
✅ Mock 简单:标准依赖注入模式
✅ 隔离性好:无需启动完整应用
✅ 快速反馈:单元测试运行速度快
关键要点
- 每个 Handler 独立测试
- 使用 Mock 隔离外部依赖
- 测试正常流程和异常流程
- 使用 Theory 进行参数化测试
- 集成测试验证端到端流程
💡 提示: MediatR 的设计让单元测试变得简单,充分利用这一优势保证代码质量!