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 方法间接覆盖,不强行测试内部实现。