C# --- 如何写UT

C# --- 如何写UT

  • [简单类:无依赖场景,直接 new + 调用](#简单类:无依赖场景,直接 new + 调用)
  • [有依赖类:注入 Service 场景,分类型处理](#有依赖类:注入 Service 场景,分类型处理)
    • [子场景 1:注入的是 Interface(推荐)------ Moq 隔离](#子场景 1:注入的是 Interface(推荐)—— Moq 隔离)
    • [子场景 2:注入的是实现类(不推荐)------ 直接 new 实例](#子场景 2:注入的是实现类(不推荐)—— 直接 new 实例)
  • [private 成员:为什么无法测试?如何间接覆盖?](#private 成员:为什么无法测试?如何间接覆盖?)

简单类:无依赖场景,直接 new + 调用

场景特征

  • 被测试类不依赖任何外部服务(无构造器注入、无外部依赖对象),仅包含纯逻辑计算、工具方法、数据转换等 "独立逻辑"。这类类的测试无需复杂配置,核心是验证 "输入 - 输出" 的正确性。
  • 测试原则
    直接实例化被测试类,调用目标方法后,通过 MSTest 内置的断言方法验证返回结果、异常抛出等场景是否符合预期。

被测试类(工具类)

csharp 复制代码
/// <summary>
/// 无依赖的字符串处理工具类
/// </summary>
public class StringProcessor
{
    /// <summary>
    /// 统计字符串中指定字符出现次数
    /// </summary>
    public int CountCharOccurrences(string input, char target)
    {
        if (string.IsNullOrEmpty(input))
            return 0;

        int count = 0;
        foreach (char c in input)
        {
            if (c == target)
                count++;
        }
        return count;
    }

    /// <summary>
    /// 字符串脱敏(手机号中间 4 位替换为 *)
    /// </summary>
    public string MaskPhoneNumber(string phone)
    {
        if (string.IsNullOrEmpty(phone) || phone.Length != 11 || !phone.All(char.IsDigit))
            throw new ArgumentException("手机号格式非法");

        return phone.Substring(0, 3) + "****" + phone.Substring(7);
    }

    /// <summary>
    /// 检查字符串是否为纯字母(忽略大小写)
    /// </summary>
    public bool IsPureLetter(string input)
    {
        if (string.IsNullOrEmpty(input))
            return false;

        return input.All(char.IsLetter);
    }
}

单元测试代码

csharp 复制代码
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace UT_Demo.Tests
{
    [TestClass]
    public class StringProcessorTests
    {
        // 直接 new 实例,无额外依赖
        private readonly StringProcessor _stringProcessor = new StringProcessor();

        [TestMethod]
        public void CountCharOccurrences_正常输入_返回正确次数()
        {
            // 测试正常场景
            Assert.AreEqual(2, _stringProcessor.CountCharOccurrences("hello world", 'l'));
            // 测试空字符串
            Assert.AreEqual(0, _stringProcessor.CountCharOccurrences("", 'a'));
            // 测试 null
            Assert.AreEqual(0, _stringProcessor.CountCharOccurrences(null, 'b'));
            // 测试无匹配字符
            Assert.AreEqual(0, _stringProcessor.CountCharOccurrences("abc", 'd'));
        }

        [TestMethod]
        [ExpectedException(typeof(ArgumentException))] // MSTest 断言异常的方式
        public void MaskPhoneNumber_非法格式_抛出异常()
        {
            // 测试长度不足 11 位
            _stringProcessor.MaskPhoneNumber("13800138");
            // 测试包含非数字字符
            // _stringProcessor.MaskPhoneNumber("1380013800a");
            // 测试空字符串
            // _stringProcessor.MaskPhoneNumber("");
        }

        [TestMethod]
        public void MaskPhoneNumber_合法格式_返回脱敏结果()
        {
            string result = _stringProcessor.MaskPhoneNumber("13800138000");
            Assert.AreEqual("138****8000", result);
        }

        [TestMethod]
        public void IsPureLetter_输入字符串_返回正确结果()
        {
            Assert.IsTrue(_stringProcessor.IsPureLetter("Hello"));
            Assert.IsTrue(_stringProcessor.IsPureLetter("WORLD"));
            Assert.IsFalse(_stringProcessor.IsPureLetter("Hello123"));
            Assert.IsFalse(_stringProcessor.IsPureLetter("Hello World"));
            Assert.IsFalse(_stringProcessor.IsPureLetter(null));
            Assert.IsFalse(_stringProcessor.IsPureLetter(""));
        }
    }
}

核心优势

  • 零配置成本:仅依赖 MSTest 框架,无需额外引入 Mock 工具;
  • 测试稳定性高:不依赖外部资源,测试结果可重复;
  • 维护成本低:代码简洁,用例覆盖场景清晰。

有依赖类:注入 Service 场景,分类型处理

场景特征

  • 被测试类依赖外部服务(如 IOrderService、IUserRepository),通过构造器注入获取依赖实例。这类测试的核心是 隔离外部依赖------ 单元测试仅验证当前类的逻辑,无需调用真实数据库、第三方接口等外部资源。
    根据注入的依赖类型(接口 / 实现类),需采用不同的测试策略,Moq 是 C# 中最常用的 Mock 工具,可快速实现依赖隔离。

子场景 1:注入的是 Interface(推荐)------ Moq 隔离

核心逻辑

  • 接口仅定义行为规范,不包含具体实现。通过 Moq 框架创建接口的 "虚拟实例"(Mock 对象),可灵活控制接口方法的返回值、模拟异常,还能验证接口的调用行为(如调用次数、参数是否正确),彻底隔离外部依赖。
    Moq 核心能力(适配 MSTest)
  • 控制返回:Returns()(固定返回值)、Throws()(模拟异常)、ReturnsAsync()(异步方法返回)、Callback()(自定义回调);
  • 验证调用:Verify()(验证方法调用)、Times.Once(调用 1 次)、Times.Never(未调用)、Times.Exactly(n)(指定调用次数);
  • 参数匹配:It.Is()(条件匹配)、It.IsAny()(任意类型匹配)、Capture()(捕获参数)。

步骤 1:定义依赖接口

csharp 复制代码
using System.Threading.Tasks;

/// <summary>
/// 订单服务接口(外部依赖,真实实现可能操作数据库)
/// </summary>
public interface IOrderService
{
    /// <summary>
    /// 根据订单ID查询订单金额
    /// </summary>
    decimal GetOrderAmount(long orderId);

    /// <summary>
    /// 异步更新订单状态
    /// </summary>
    Task<bool> UpdateOrderStatusAsync(long orderId, string status);

    /// <summary>
    /// 检查订单是否存在
    /// </summary>
    bool OrderExists(long orderId);
}

步骤 2:被测试类(注入接口)

csharp 复制代码
using System.Threading.Tasks;

/// <summary>
/// 订单支付处理器(依赖 IOrderService 接口)
/// </summary>
public class OrderPaymentHandler
{
    // 构造器注入(推荐,便于测试传入 Mock 实例)
    private readonly IOrderService _orderService;

    public OrderPaymentHandler(IOrderService orderService)
    {
        _orderService = orderService ?? throw new ArgumentNullException(nameof(orderService));
    }

    /// <summary>
    /// 计算实付金额(满 1000 减 100)
    /// </summary>
    public decimal CalculateActualPayment(long orderId)
    {
        if (orderId <= 0)
            throw new ArgumentException("订单ID非法", nameof(orderId));

        // 依赖接口查询订单金额
        if (!_orderService.OrderExists(orderId))
            throw new KeyNotFoundException("订单不存在");

        decimal orderAmount = _orderService.GetOrderAmount(orderId);
        // 满减逻辑
        return orderAmount >= 1000 ? orderAmount - 100 : orderAmount;
    }

    /// <summary>
    /// 处理支付流程(支付成功后更新订单状态)
    /// </summary>
    public async Task<string> ProcessPaymentAsync(long orderId)
    {
        if (orderId <= 0)
            return "支付失败:订单ID非法";

        if (!_orderService.OrderExists(orderId))
            return "支付失败:订单不存在";

        // 模拟支付逻辑(实际项目中可能调用支付网关)
        bool paymentSuccess = true;

        if (paymentSuccess)
        {
            // 调用接口更新订单状态为"已支付"
            bool updateSuccess = await _orderService.UpdateOrderStatusAsync(orderId, "Paid");
            return updateSuccess ? "支付成功" : "支付失败:订单状态更新异常";
        }

        return "支付失败:支付网关调用异常";
    }
}

步骤 3:单元测试代码

csharp 复制代码
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System;
using System.Threading.Tasks;

namespace UT_Demo.Tests
{
    [TestClass]
    public class OrderPaymentHandlerTests
    {
        // 创建 Moq 实例(泛型参数为依赖的接口)
        private readonly Mock<IOrderService> _mockOrderService = new Mock<IOrderService>();
        // 注入 Mock 实例到被测试类
        private readonly OrderPaymentHandler _paymentHandler;

        public OrderPaymentHandlerTests()
        {
            // 通过 .Object 获取 Mock 生成的接口实例
            _paymentHandler = new OrderPaymentHandler(_mockOrderService.Object);
        }

        [TestMethod]
        public void CalculateActualPayment_合法订单_返回满减后金额()
        {
            // 1. 配置 Mock 行为:订单存在 + 不同金额返回
            _mockOrderService.Setup(os => os.OrderExists(1L)).Returns(true);
            _mockOrderService.Setup(os => os.GetOrderAmount(1L)).Returns(1500m); // 满 1000 减 100

            _mockOrderService.Setup(os => os.OrderExists(2L)).Returns(true);
            _mockOrderService.Setup(os => os.GetOrderAmount(2L)).Returns(800m); // 不满减

            // 2. 执行测试方法
            decimal result1 = _paymentHandler.CalculateActualPayment(1L);
            decimal result2 = _paymentHandler.CalculateActualPayment(2L);

            // 3. 断言结果
            Assert.AreEqual(1400m, result1);
            Assert.AreEqual(800m, result2);

            // 4. 验证 Mock 接口调用:确保方法被正确调用
            _mockOrderService.Verify(os => os.OrderExists(1L), Times.Once);
            _mockOrderService.Verify(os => os.GetOrderAmount(2L), Times.Once);
            _mockOrderService.Verify(os => os.OrderExists(It.IsAny<long>()), Times.Exactly(2));
        }

        [TestMethod]
        [ExpectedException(typeof(ArgumentException))]
        public void CalculateActualPayment_非法订单ID_抛出异常()
        {
            // 测试负数订单ID
            _paymentHandler.CalculateActualPayment(-1L);
        }

        [TestMethod]
        [ExpectedException(typeof(KeyNotFoundException))]
        public void CalculateActualPayment_订单不存在_抛出异常()
        {
            // 配置 Mock 行为:订单不存在
            _mockOrderService.Setup(os => os.OrderExists(3L)).Returns(false);
            _paymentHandler.CalculateActualPayment(3L);
        }

        [TestMethod]
        public async Task ProcessPaymentAsync_正常流程_返回支付成功()
        {
            // 配置 Mock 行为:订单存在 + 更新状态成功
            _mockOrderService.Setup(os => os.OrderExists(4L)).Returns(true);
            _mockOrderService.Setup(os => os.UpdateOrderStatusAsync(4L, "Paid"))
                .ReturnsAsync(true); // 异步方法返回成功

            // 执行异步测试方法(MSTest 需用 async Task 修饰测试方法)
            string result = await _paymentHandler.ProcessPaymentAsync(4L);

            // 断言结果
            Assert.AreEqual("支付成功", result);

            // 验证异步方法调用
            _mockOrderService.Verify(os => os.UpdateOrderStatusAsync(4L, "Paid"), Times.Once);
        }

        [TestMethod]
        public async Task ProcessPaymentAsync_订单不存在_返回失败()
        {
            // 配置 Mock 行为:订单不存在
            _mockOrderService.Setup(os => os.OrderExists(5L)).Returns(false);

            string result = await _paymentHandler.ProcessPaymentAsync(5L);
            Assert.AreEqual("支付失败:订单不存在", result);

            // 验证:未调用更新状态方法
            _mockOrderService.Verify(os => os.UpdateOrderStatusAsync(It.IsAny<long>(), It.IsAny<string>()), Times.Never);
        }

        [TestMethod]
        public async Task ProcessPaymentAsync_使用Callback捕获参数()
        {
            // 定义变量捕获传入的参数
            long capturedOrderId = 0;
            string capturedStatus = string.Empty;

            // 配置 Mock 行为:捕获 UpdateOrderStatusAsync 的参数
            _mockOrderService.Setup(os => os.OrderExists(6L)).Returns(true);
            _mockOrderService.Setup(os => os.UpdateOrderStatusAsync(It.IsAny<long>(), It.IsAny<string>()))
                .Callback<long, string>((orderId, status) =>
                {
                    capturedOrderId = orderId;
                    capturedStatus = status;
                })
                .ReturnsAsync(true);

            // 执行测试
            await _paymentHandler.ProcessPaymentAsync(6L);

            // 断言捕获的参数是否正确
            Assert.AreEqual(6L, capturedOrderId);
            Assert.AreEqual("Paid", capturedStatus);
        }
    }
}

子场景 2:注入的是实现类(不推荐)------ 直接 new 实例

核心逻辑

  • 若依赖的是具体实现类(而非接口),Moq 无法直接 Mock 类的非虚方法(Moq 基于动态代理实现,仅支持接口或虚方法),只能直接 new 一个实现类实例注入。但这种方式会执行实现类的所有代码,若实现类依赖外部资源(如数据库、Redis),则会导致测试失败。

步骤 1:依赖的实现类(含外部资源调用)

csharp 复制代码
using System.Data.SqlClient;
using System.Threading.Tasks;

/// <summary>
/// 订单服务实现类(依赖数据库,不推荐直接注入测试)
/// </summary>
public class OrderServiceImpl : IOrderService
{
    // 数据库连接字符串(外部资源)
    private readonly string _connectionString;

    public OrderServiceImpl(string connectionString)
    {
        _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
    }

    public decimal GetOrderAmount(long orderId)
    {
        // 真实场景:查询数据库
        using (var conn = new SqlConnection(_connectionString))
        {
            conn.Open();
            var cmd = new SqlCommand("SELECT Amount FROM Orders WHERE OrderId = @OrderId", conn);
            cmd.Parameters.AddWithValue("@OrderId", orderId);
            var result = cmd.ExecuteScalar();
            return result != null ? Convert.ToDecimal(result) : 0m;
        }
    }

    public async Task<bool> UpdateOrderStatusAsync(long orderId, string status)
    {
        // 真实场景:更新数据库
        using (var conn = new SqlConnection(_connectionString))
        {
            await conn.OpenAsync();
            var cmd = new SqlCommand(
                "UPDATE Orders SET Status = @Status WHERE OrderId = @OrderId",
                conn
            );
            cmd.Parameters.AddWithValue("@OrderId", orderId);
            cmd.Parameters.AddWithValue("@Status", status);
            int affectedRows = await cmd.ExecuteNonQueryAsync();
            return affectedRows > 0;
        }
    }

    public bool OrderExists(long orderId)
    {
        // 真实场景:查询数据库
        using (var conn = new SqlConnection(_connectionString))
        {
            conn.Open();
            var cmd = new SqlCommand("SELECT COUNT(1) FROM Orders WHERE OrderId = @OrderId", conn);
            cmd.Parameters.AddWithValue("@OrderId", orderId);
            int count = Convert.ToInt32(cmd.ExecuteScalar());
            return count > 0;
        }
    }
}

步骤 2:被测试类(注入实现类)

csharp 复制代码
/// <summary>
/// 注入实现类的订单支付处理器(不推荐,测试困难)
/// </summary>
public class OrderPaymentHandlerV2
{
    private readonly OrderServiceImpl _orderService;

    public OrderPaymentHandlerV2(OrderServiceImpl orderService)
    {
        _orderService = orderService ?? throw new ArgumentNullException(nameof(orderService));
    }

    // 与 OrderPaymentHandler 逻辑一致
    public decimal CalculateActualPayment(long orderId)
    {
        if (orderId <= 0)
            throw new ArgumentException("订单ID非法", nameof(orderId));

        if (!_orderService.OrderExists(orderId))
            throw new KeyNotFoundException("订单不存在");

        decimal orderAmount = _orderService.GetOrderAmount(orderId);
        return orderAmount >= 1000 ? orderAmount - 100 : orderAmount;
    }
}

步骤 3:单元测试代码(问题暴露)

csharp 复制代码
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;

namespace UT_Demo.Tests
{
    [TestClass]
    public class OrderPaymentHandlerV2Tests
    {
        [TestMethod]
        [ExpectedException(typeof(SqlException))]
        public void CalculateActualPayment_无数据库_抛出异常()
        {
            // 必须 new 实现类,需传入数据库连接字符串(外部资源)
            // 若无真实数据库环境,SqlConnection 无法连接,测试直接报错
            var orderService = new OrderServiceImpl("Server=.;Database=TestDB;Integrated Security=True");
            var handler = new OrderPaymentHandlerV2(orderService);

            // 执行方法时,真实调用数据库查询,无数据库则抛出 SqlException
            handler.CalculateActualPayment(1L);
        }
    }
}

弊端与优化建议

  • 弊端:测试依赖外部资源(数据库、网络等),稳定性差、执行慢;无法隔离实现类内部逻辑,测试范围失控;
    优化建议:依赖注入优先使用接口,遵循 "面向接口编程" 原则,既提升代码灵活性(便于替换实现),也便于单元测试隔离。若无法修改依赖类型,可将类的方法改为虚方法,通过 Moq 部分 Mock 类,但会增加代码复杂度,不推荐优先使用。

private 成员:为什么无法测试?如何间接覆盖?

核心结论

  • private 成员(方法、属性、字段)是类的 "内部实现细节",仅允许类内部访问,MSTest、Moq 等主流测试框架均不支持直接访问或调用 private 成员。单元测试的核心是验证 "类的对外行为"(public 方法),而非内部实现,因此无需强行测试 private 成员。

为什么不建议强行测试 private 成员?

  • 违背封装原则:private 成员的设计目的是隐藏内部逻辑,强行访问会破坏类的封装性;
  • 增加维护成本:private 成员的名称、参数、逻辑修改时,测试代码也需同步修改,导致测试与实现过度耦合;
  • 测试目标偏移:单元测试应关注 "输入 - 输出" 是否正确,而非内部逻辑如何实现。

正确做法:通过 public 方法间接覆盖 private 逻辑

  • 所有 private 成员最终都会被 public 方法调用,只需设计足够的 public 方法测试用例,即可间接覆盖 private 成员的所有分支逻辑。
csharp 复制代码
/// <summary>
/// 商品价格计算服务(含 private 成员)
/// </summary>
public class ProductPriceCalculator
{
    /// <summary>
    /// 计算商品最终售价(对外公开方法,测试入口)
    /// </summary>
    public decimal CalculateFinalPrice(decimal originalPrice, int discountType, bool isMember)
    {
        if (originalPrice < 0)
            throw new ArgumentException("原价不能为负数");

        // 调用 private 方法计算折扣
        decimal discountRate = GetDiscountRate(discountType, isMember);
        // 调用 private 属性获取服务费比例
        decimal serviceFeeRate = ServiceFeeRate;

        // 最终价格 = 折后价 + 服务费
        decimal discountedPrice = originalPrice * (1 - discountRate);
        return discountedPrice + (discountedPrice * serviceFeeRate);
    }

    /// <summary>
    /// private 方法:计算折扣比例
    /// </summary>
    private decimal GetDiscountRate(int discountType, bool isMember)
    {
        if (isMember)
        {
            return discountType switch
            {
                1 => 0.2m, // 会员专属 8 折
                2 => 0.3m, // 会员专属 7 折
                _ => 0.1m  // 会员默认 9 折
            };
        }
        else
        {
            return discountType switch
            {
                1 => 0.05m, // 非会员 9.5 折
                _ => 0m     // 非会员无折扣
            };
        }
    }

    /// <summary>
    /// private 属性:服务费比例(固定 5%)
    /// </summary>
    private decimal ServiceFeeRate => 0.05m;
}

单元测试(通过 public 方法覆盖 private 逻辑)

csharp 复制代码
[TestClass]
public class ProductPriceCalculatorTests
{
    private readonly ProductPriceCalculator _calculator = new ProductPriceCalculator();

    [TestMethod]
    public void CalculateFinalPrice_覆盖所有private分支()
    {
        // 1. 会员 + discountType=1(20% 折扣):100*(1-0.2) + 100*(1-0.2)*0.05 = 84
        Assert.AreEqual(84m, _calculator.CalculateFinalPrice(100m, 1, true));

        // 2. 会员 + discountType=2(30% 折扣):200*(1-0.3) + 200*(1-0.3)*0.05 = 147
        Assert.AreEqual(147m, _calculator.CalculateFinalPrice(200m, 2, true));

        // 3. 会员 + discountType=3(默认 10% 折扣):150*(1-0.1) + 150*(1-0.1)*0.05 = 141.75
        Assert.AreEqual(141.75m, _calculator.CalculateFinalPrice(150m, 3, true));

        // 4. 非会员 + discountType=1(5% 折扣):300*(1-0.05) + 300*(1-0.05)*0.05 = 304.25
        Assert.AreEqual(304.25m, _calculator.CalculateFinalPrice(300m, 1, false));

        // 5. 非会员 + discountType=2(无折扣):50*(1-0) + 50*0.05 = 52.5
        Assert.AreEqual(52.5m, _calculator.CalculateFinalPrice(50m, 2, false));
    }

    [TestMethod]
    [ExpectedException(typeof(ArgumentException))]
    public void CalculateFinalPrice_负数原价_抛出异常()
    {
        _calculator.CalculateFinalPrice(-100m, 1, true);
    }
}

特殊场景优化

  • 若 private 成员逻辑过于复杂(如分支过多、代码行数过长),间接覆盖困难,说明类的设计可能存在问题。此时可将该 private 成员重构为独立类的 public 方法(或接口方法),通过依赖注入引入,既优化代码结构,也便于单独测试。

总结

  • C# 单元测试(MSTest + Moq)的核心是 "隔离、稳定、可重复",结合本文 3 大场景,总结核心实践原则:
  • 无依赖简单类:直接 new 实例,覆盖正常、边界、异常场景;
  • 依赖接口类:用 Moq 框架 Mock 接口,灵活控制返回值、验证调用行为,彻底隔离外部资源;
  • 依赖实现类:尽量重构为依赖接口,避免测试依赖外部资源;
  • private 成员:通过 public 方法间接覆盖,不强行测试内部实现。
相关推荐
Charles_go1 小时前
C#中级39、什么是依赖注入设计模式
java·设计模式·c#
yqcoder1 小时前
在 scss 中,&>div 作用
前端·css·scss
小马哥编程1 小时前
这个variables.scss文件中$menuText:#bfcbd9;:export {menuText: $menuText; }的语法符合要求吗
前端·css·scss
宋辰月1 小时前
zustand
前端·javascript·html
z***I3941 小时前
JavaScript原型链
开发语言·前端·javascript
JinSo1 小时前
Ultracite:为 AI 时代打造的零配置代码规范工具
前端·javascript·github
ZEGO即构开发者2 小时前
WebRTC 实战:用即构 SDK 搭建 Web 端 1v1 视频通话(含完整流程与 Demo)
前端·音视频·webrtc
eggcode2 小时前
C#开源库ACadSharp将Dwg转Dxf
c#·dxf·dwg
爆浆麻花2 小时前
为什么有些人边框不用border属性
前端·css