You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

单元测试中不可变结构体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)的动态数据处理逻辑上:

  1. 结构体封装限制:MatchingPair仅暴露私有构造函数,只能通过工厂方法创建实例。MSTest在处理动态数据时,会尝试通过反射重建值类型实例,但无法调用私有构造函数,只能使用默认的无参构造函数生成默认值实例。
  2. 延迟执行的副作用:使用yield return延迟生成测试数据时,MSTest会多次枚举IEnumerable(比如第一次获取测试用例数量,第二次获取每个用例的参数),期间可能错误地重新初始化结构体,而非复用已创建的实例。
  3. 可空值类型的装箱/拆箱问题: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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 07:44:58