单元测试中不可变结构体MatchingPair值丢失问题排查
问题:自定义不可变结构体在MSTest DynamicData中丢失值
我创建了一个不可变的readonly struct MatchingPair,通过工厂方法初始化并保证内部资产顺序:
public readonly struct MatchingPair : IEquatable<MatchingPair> { private MatchingPair(Assets asset1, Assets asset2, bool validate = true) { if (validate) { InvalidMatchingPairException.ThrowIfEqualOrUnknown(asset1, asset2); } if (asset1 < asset2) { Asset1 = asset1; Asset2 = asset2; } else { Asset1 = asset2; Asset2 = asset1; } } public static MatchingPair Create(Assets asset1, Assets asset2) { return new MatchingPair(asset1, asset2); } internal static MatchingPair InternalCreate(Assets asset1, Assets asset2) { return new MatchingPair(asset1, asset2, false); } public Assets Asset1 { get; } public Assets Asset2 { get; } public override string ToString() { return $"{Asset1}<->{Asset2}"; } public bool Equals(MatchingPair other) { return Asset1 == other.Asset1 && Asset2 == other.Asset2; } public override bool Equals(object? obj) { return obj is MatchingPair other && Equals(other); } public override int GetHashCode() { return HashCode.Combine((int)Asset1, (int)Asset2); } public static bool operator ==(MatchingPair left, MatchingPair right) { return left.Equals(right); } public static bool operator !=(MatchingPair left, MatchingPair right) { return !left.Equals(right); } }
Assets是普通枚举,无特殊逻辑。
使用MSTest v2的DynamicDataAttribute批量生成测试用例时,发现MatchingPair的属性值丢失,全部变为枚举默认值Assets.Btc(值为0):
[TestMethod] [DynamicData(nameof(MatchingEngineExceptionConstructor4Data))] public void MatchingEngineException_Constructor4_Test(string? message, MatchingPair? matchingPair, string expectedMessage) { if (matchingPair.HasValue) { Assert.AreEqual(Assets.Btc, matchingPair.Value.Asset1); Assert.AreEqual(Assets.Etc, matchingPair.Value.Asset2); // 意外失败,Asset2实际为Assets.Btc } // ...实际测试代码... }
测试数据生成代码如下:
private static IEnumerable<object?[]> MatchingEngineExceptionConstructor4Data { get { foreach (var message in new[] { null, "Céẞßö." }) { var expectedMessage = message ?? "Error in the matching engine."; foreach (MatchingPair? matchingPair in new MatchingPair?[] { null, MatchingPair.Create(Assets.Btc, Assets.Etc) }) { if (matchingPair != null) { expectedMessage += $" (MatchingPair '{matchingPair}')"; } yield return [message, matchingPair, expectedMessage]; } } } }
关键现象:生成的expectedMessage显示正确的MatchingPair字符串(如Btc<->Etc),证明数据生成时结构体值是正确的,但测试方法中结构体属性却变为默认值。
原因分析
问题出在MSTest对自定义值类型(尤其是readonly struct)的动态数据处理逻辑上:
- 结构体封装限制:
MatchingPair仅暴露私有构造函数,只能通过工厂方法创建实例。MSTest在处理动态数据时,会尝试通过反射重建值类型实例,但无法调用私有构造函数,只能使用默认的无参构造函数生成默认值实例。 - 延迟执行的副作用:使用
yield return延迟生成测试数据时,MSTest会多次枚举IEnumerable(比如第一次获取测试用例数量,第二次获取每个用例的参数),期间可能错误地重新初始化结构体,而非复用已创建的实例。 - 可空值类型的装箱/拆箱问题:
MatchingPair?作为可空值类型,在装箱为object后,MSTest的内部处理逻辑可能未正确拆箱,导致丢失原有值,转而生成默认实例。
解决方案
方案1:提前生成所有测试数据,避免延迟执行
将测试数据提前存入列表,而非使用yield return延迟生成,确保MSTest直接复用已创建的结构体实例:
private static IEnumerable<object?[]> MatchingEngineExceptionConstructor4Data { get { var testCases = new List<object?[]>(); foreach (var message in new[] { null, "Céẞßö." }) { var baseMessage = message ?? "Error in the matching engine."; foreach (MatchingPair? matchingPair in new MatchingPair?[] { null, MatchingPair.Create(Assets.Btc, Assets.Etc) }) { var expectedMessage = baseMessage; if (matchingPair != null) { expectedMessage += $" (MatchingPair '{matchingPair}')"; } testCases.Add([message, matchingPair, expectedMessage]); } } return testCases; } }
方案2:修改结构体构造函数为公共(牺牲部分封装)
如果可以接受直接实例化结构体的风险,将构造函数改为公共,让MSTest能正确重建实例:
public MatchingPair(Assets asset1, Assets asset2, bool validate = true) { // 原有逻辑不变 }
方案3:绕过可空值类型,改用object传递参数
在测试数据中传递object类型的结构体,测试方法中手动拆箱,避免MSTest对可空值类型的错误处理:
// 测试方法 [TestMethod] [DynamicData(nameof(MatchingEngineExceptionConstructor4Data))] public void MatchingEngineException_Constructor4_Test(string? message, object? matchingPairObj, string expectedMessage) { var matchingPair = matchingPairObj as MatchingPair?; if (matchingPair.HasValue) { Assert.AreEqual(Assets.Btc, matchingPair.Value.Asset1); Assert.AreEqual(Assets.Etc, matchingPair.Value.Asset2); } } // 测试数据生成(无需修改,matchingPair会自动装箱为object) private static IEnumerable<object?[]> MatchingEngineExceptionConstructor4Data { get { foreach (var message in new[] { null, "Céẞßö." }) { var expectedMessage = message ?? "Error in the matching engine."; foreach (MatchingPair? matchingPair in new MatchingPair?[] { null, MatchingPair.Create(Assets.Btc, Assets.Etc) }) { if (matchingPair != null) { expectedMessage += $" (MatchingPair '{matchingPair}')"; } yield return [message, matchingPair, expectedMessage]; } } } }
内容的提问来源于stack exchange,提问作者Iaman Swtrse
相关产品推荐
相关产品推荐

