Skip to content

单元测试 ​

🧪 确保 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.Testing
csharp
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);   // ❌ 错误

📊 测试覆盖率目标 ​

组件目标覆盖率说明
Handler90%+核心业务逻辑必须充分测试
Pipeline Behavior85%+横切关注点需要全面测试
Notification Handler80%+事件处理器需要测试
Controller70%+主要测试 HTTP 层面
Validator95%+验证规则必须完全覆盖

🎓 总结 ​

核心价值 ​

✅ Handler 易于测试:纯函数、依赖清晰
✅ Mock 简单:标准依赖注入模式
✅ 隔离性好:无需启动完整应用
✅ 快速反馈:单元测试运行速度快

关键要点 ​

  1. 每个 Handler 独立测试
  2. 使用 Mock 隔离外部依赖
  3. 测试正常流程和异常流程
  4. 使用 Theory 进行参数化测试
  5. 集成测试验证端到端流程

💡 提示: MediatR 的设计让单元测试变得简单,充分利用这一优势保证代码质量!

Released under the CC BY-SA 4.0 License.