使用 Moq 和 xUnit 对 ASP.NET Core Web API 进行单元测试(控制器 + 服务)

发布: (2025年12月8日 GMT+8 01:40)
4 min read
原文: Dev.to

Source: Dev.to

什么是 Moq?

Moq 允许你用轻量级的测试替身替换真实的依赖,从而在隔离的环境中测试逻辑。

核心方法

  • Setup() → 定义模拟行为
  • ReturnsAsync() → 为异步方法返回值
  • ThrowsAsync() → 模拟失败情况
  • Verify() → 断言某个依赖被调用

本指南的所有示例都遵循 Mock → Execute → Validate(模拟 → 执行 → 验证)模式。

为什么要测试 Web API 控制器?

控制器负责处理 HTTP 请求并返回响应。测试可以确保:

  • ✅ 正确的 HTTP 状态码(200、404、400、201)
  • ✅ 服务被以正确的参数调用
  • ✅ 验证逻辑正常工作
  • ✅ 错误得到妥善处理

原则: 控制器应当保持 (thin)——它们负责协调,而不是实现业务逻辑。

初始化(约 2 分钟)

dotnet new xunit -n YourApi.Tests
cd YourApi.Tests
dotnet add package Moq --version 4.20.72
dotnet add package FluentAssertions --version 6.12.2
dotnet add reference ../YourApi/YourApi.csproj

模式:Mock → Execute → Validate

示例控制器(ProductsController)

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ILogger _logger;

    public ProductsController(IProductService productService, ILogger logger)
    {
        _productService = productService;
        _logger = logger;
    }

    [HttpGet("{id}")]
    public async Task> GetById(int id)
    {
        var product = await _productService.GetProductAsync(id);

        if (product == null)
            return NotFound(new { message = $"Product with ID {id} not found" });

        var response = new ProductResponse
        {
            Id = product.Id,
            Name = product.Name,
            SKU = product.SKU,
            Description = product.Description,
            Price = product.Price,
            IsActive = product.IsActive,
            Category = product.Category != null
                ? new CategoryResponse { Id = product.Category.Id, Name = product.Category.Name }
                : new CategoryResponse()
        };

        return Ok(response);
    }

    [HttpPost]
    public async Task> Create([FromBody] CreateProductRequest request)
    {
        try
        {
            var product = await _productService.CreateProductAsync(request);

            var response = new ProductResponse
            {
                Id = product.Id,
                Name = product.Name,
                SKU = product.SKU,
                Price = product.Price,
                IsActive = product.IsActive
            };

            return CreatedAtAction(nameof(GetById), new { id = product.Id }, response);
        }
        catch (InvalidOperationException ex)
        {
            return Conflict(new { message = ex.Message });
        }
    }
}

测试控制器

通过模拟服务、调用动作方法,并对 ActionResult(状态码 + 响应体)进行断言来测试控制器。

测试类初始化

using CommonComps.Models;
using CommonComps.Models.Requests;
using CommonComps.Models.Responses;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using ProductWebAPI.Controllers;
using ProductWebAPI.Services;
using Xunit;

public class ProductsControllerTests
{
    private readonly Mock _mockService;
    private readonly Mock> _mockLogger;
    private readonly ProductsController _controller;

    public ProductsControllerTests()
    {
        _mockService = new Mock();
        _mockLogger = new Mock>();
        _controller = new ProductsController(_mockService.Object, _mockLogger.Object);
    }
}

GET 接口测试

[Fact]
public async Task GetById_ReturnsOk_WhenProductExists()
{
    // Arrange
    var product = new Product
    {
        Id = 1,
        Name = "Laptop",
        SKU = "LAP-001",
        Price = 999.99m,
        IsActive = true,
        Category = new Category { Id = 1, Name = "Electronics" }
    };

    _mockService.Setup(s => s.GetProductAsync(1))
        .ReturnsAsync(product);

    // Act
    var result = await _controller.GetById(1);

    // Assert
    var okResult = result.Result.Should().BeOfType().Subject;
    var response = okResult.Value.Should().BeOfType().Subject;
    response.Id.Should().Be(1);
    response.Name.Should().Be("Laptop");
    _mockService.Verify(s => s.GetProductAsync(1), Times.Once);
}

[Fact]
public async Task GetById_ReturnsNotFound_WhenProductDoesNotExist()
{
    // Arrange
    _mockService.Setup(s => s.GetProductAsync(999))
        .ReturnsAsync((Product?)null);

    // Act
    var result = await _controller.GetById(999);

    // Assert
    result.Result.Should().BeOfType();
}

POST 接口测试

[Fact]
public async Task Create_ReturnsCreated_WhenSuccessful()
{
    // Arrange
    var request = new CreateProductRequest
    {
        Name = "New Product",
        SKU = "NEW-001",
        Price = 49.99m,
        CategoryId = 1
    };

    var createdProduct = new Product
    {
        Id = 42,
        Name = "New Product",
        SKU = "NEW-001",
        Price = 49.99m,
        IsActive = true
    };

    _mockService.Setup(s => s.CreateProductAsync(It.IsAny()))
        .ReturnsAsync(createdProduct);

    // Act
    var result = await _controller.Create(request);

    // Assert
    var createdResult = result.Result.Should().BeOfType().Subject;
    createdResult.StatusCode.Should().Be(201);
    createdResult.ActionName.Should().Be(nameof(ProductsController.GetById));

    var response = createdResult.Value.Should().BeOfType().Subject;
    response.Id.Should().Be(42);
    _mockService.Verify(s => s.CreateProductAsync(It.IsAny()), Times.Once);
}

[Fact]
public async Task Create_ReturnsConflict_WhenDuplicateSKU()
{
    // Arrange
    var request = new CreateProductRequest { Name = "Test", SKU = "DUP-001" };

    _mockService.Setup(s => s.CreateProductAsync(It.IsAny()))
        .ThrowsAsync(new InvalidOperationException("Product with SKU DUP-001 already exists"));

    // Act
    var result = await _controller.Create(request);

    // Assert
    result.Result.Should().BeOfType();
}

演示 GIF

Moq 的关键技巧

设置返回值

// 返回特定值
_mockService.Setup(s => s.GetProductAsync(1)).ReturnsAsync(product);

// 返回 null
_mockService.Setup(s => s.GetProductAsync(999)).ReturnsAsync((Product?)null);

// 对任意参数返回
_mockService.Setup(s => s.GetProductAsync(It.IsAny())).ReturnsAsync(product);
Back to Blog

相关文章

阅读更多 »

复选框 Aria TagHelper

介绍 了解如何使用自定义 TagHelper 初始化位于 ASP.NET Core 页面中的所有复选框输入。该 TagHelper 会评估 checked 属性……