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

如何在XUnit中泛化SUT重复测试?解决MemberData静态限制

问题与解决方案

问题背景

给被测系统(SUT)的每个方法单独编写测试类时,出现大量重复测试代码。核心重复场景包括:

  • 验证对象释放(或异步释放)后调用方法是否抛出ObjectDisposedException;
  • 验证任意单个参数为null时调用方法是否抛出ArgumentNullException。

每个SUT方法需编写6-7个此类测试,当前已有150+重复测试且数量持续增长。尝试通过基类封装通用测试逻辑,仅在子类定义测试数据,但因XUnit的MemberData要求引用静态成员,导致实现无法运行。原尝试代码如下:

public class TestBase<TSut> where TSut : IDisposable {
    protected TSut Sut { get; set; }
    protected Delegate MethodToTest { get; set; }
    protected IEnumerable<object?[]> DefaultParameters { get; set; } // 合法测试数据
    protected IEnumerable<object?[]> NullCheckingParameters { get; set; } // 每组仅一个null参数

    [Theory]
    [MemberData(nameof(DefaultParameters))]
    public void TestMethod_ShouldThrowException_WhenObjectWasDisposed(object?[] data) {
        this.Sut.Dispose();
        Assert.Throws<ObjectDisposedException>(
            () => this.MethodToTest.Method.Invoke(this.Sut, data)
        );
    }
    
    [Theory]
    [MemberData(nameof(NullCheckingParameters))]
    public void TestMethod_ShouldThrowException_WhenParameterWasNull(object?[] data) {
        this.Sut.Dispose(); // 此处为笔误,空参数测试无需先释放对象
        Assert.Throws<ArgumentNullException>(
            () => this.MethodToTest.Method.Invoke(this.Sut, data)
        );
    }
}

public class MethodTests : TestBase<MySut> {
    public MethodTests() {
        this.Sut = new MySut();
        this.MethodToTest = this.Sut.MethodToTest;

        this.DefaultParameters = new[] {
            new object?[] {"Valid1", "Valid2", "Valid3"}
        };

        this.NullCheckingParameters = new[] {
            new object?[] {null, "Valid2", "Valid3"},
            new object?[] {"Valid1", null, "Valid3"},
            new object?[] {"Valid1", "Valid2", null}
        };
    }
}

可行解决方案

方案1:抽象静态成员 + 泛型基类(推荐,C# 8+)

利用C#的抽象静态成员特性,让子类必须实现静态的测试数据源,基类直接引用这些静态成员。这种方式既保留了基类的通用性,又满足XUnit对MemberData的静态要求。

public abstract class TestBase<TSut> where TSut : IDisposable, new() {
    protected TSut Sut { get; }
    protected abstract Delegate MethodToTest { get; }

    // 子类必须实现的静态测试数据
    protected abstract static IEnumerable<object?[]> DefaultParameters { get; }
    protected abstract static IEnumerable<object?[]> NullCheckingParameters { get; }

    [Theory]
    [MemberData(nameof(DefaultParameters), MemberType = typeof(TestBase<TSut>))]
    public void Test_ThrowsObjectDisposedException_AfterDispose(object?[] data) {
        Sut.Dispose();
        Assert.Throws<ObjectDisposedException>(
            () => MethodToTest.Method.Invoke(Sut, data)
        );
    }
    
    [Theory]
    [MemberData(nameof(NullCheckingParameters), MemberType = typeof(TestBase<TSut>))]
    public void Test_ThrowsArgumentNullException_WhenParameterIsNull(object?[] data) {
        Assert.Throws<ArgumentNullException>(
            () => MethodToTest.Method.Invoke(Sut, data)
        );
    }

    protected TestBase() {
        Sut = new TSut();
    }
}

// 子类实现示例
public class MethodTests : TestBase<MySut> {
    // 静态测试数据实现
    public static IEnumerable<object?[]> DefaultParameters => new[] {
        new object?[] {"Valid1", "Valid2", "Valid3"}
    };

    public static IEnumerable<object?[]> NullCheckingParameters => new[] {
        new object?[] {null, "Valid2", "Valid3"},
        new object?[] {"Valid1", null, "Valid3"},
        new object?[] {"Valid1", "Valid2", null}
    };

    protected override Delegate MethodToTest => Sut.MethodToTest;
}

方案2:自定义XUnit属性封装测试逻辑

直接封装通用测试逻辑到自定义属性中,子类无需继承基类,只需给测试方法添加属性并传入必要参数即可,代码更简洁。

空参数检查属性示例

public class NullArgumentTestAttribute : TheoryAttribute {
    public NullArgumentTestAttribute(Type sutType, string methodName, params object[] validArgs) {
        var method = sutType.GetMethod(methodName) 
            ?? throw new ArgumentException($"方法 {methodName} 不存在于类型 {sutType.Name}");
        var parameters = method.GetParameters();
        var testCases = new List<object[]>();

        // 为每个参数生成单独为null的测试用例
        for (int i = 0; i < parameters.Length; i++) {
            var args = (object[])validArgs.Clone();
            args[i] = null;
            testCases.Add(args);
        }

        TestCases = testCases;
    }
}

// 使用示例
public class MethodTests {
    private readonly MySut _sut = new MySut();

    [NullArgumentTest(typeof(MySut), nameof(MySut.MethodToTest), "Valid1", "Valid2", "Valid3")]
    public void Test_ThrowsArgumentNullException_WhenParameterIsNull(string arg1, string arg2, string arg3) {
        Assert.Throws<ArgumentNullException>(() => _sut.MethodToTest(arg1, arg2, arg3));
    }
}

同理可封装对象释放后的异常检查属性,进一步减少重复代码。

方案3:使用ClassData实现数据源

定义实现IEnumerable<object?[]>的数据源类,基类测试方法通过ClassData引用,子类负责提供具体数据。这种方式兼容性更好,适合旧版本C#/.NET框架。

// 通用数据源类
public class ParameterData : IEnumerable<object?[]> {
    private readonly IEnumerable<object?[]> _data;

    public ParameterData(IEnumerable<object?[]> data) {
        _data = data;
    }

    public IEnumerator<object?[]> GetEnumerator() => _data.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

// 基类调整
public abstract class TestBase<TSut> where TSut : IDisposable, new() {
    protected TSut Sut { get; }
    protected abstract Delegate MethodToTest { get; }
    protected abstract IEnumerable<object?[]> DefaultParams { get; }
    protected abstract IEnumerable<object?[]> NullCheckParams { get; }

    [Theory]
    [ClassData(typeof(ParameterData))]
    public void Test_DisposedObjectThrowsException(object?[] data) {
        Sut.Dispose();
        Assert.Throws<ObjectDisposedException>(() => MethodToTest.Method.Invoke(Sut, data));
    }

    // 子类需重写该方法并指定ClassData
    [Theory]
    [ClassData(typeof(ParameterData))]
    public abstract void Test_NullParameterThrowsException(object?[] data);

    protected TestBase() {
        Sut = new TSut();
    }
}

// 子类实现
public class MethodTests : TestBase<MySut> {
    protected override Delegate MethodToTest => Sut.MethodToTest;
    protected override IEnumerable<object?[]> DefaultParams => new[] {
        new object?[] {"Valid1", "Valid2", "Valid3"}
    };
    protected override IEnumerable<object?[]> NullCheckParams => new[] {
        new object?[] {null, "Valid2", "Valid3"},
        new object?[] {"Valid1", null, "Valid3"},
        new object?[] {"Valid1", "Valid2", null}
    };

    public override void Test_NullParameterThrowsException(object?[] data) {
        Assert.Throws<ArgumentNullException>(() => MethodToTest.Method.Invoke(Sut, data));
    }
}

关键注意事项

  • 原代码中空参数测试方法错误调用了Sut.Dispose(),会导致先抛出ObjectDisposedException,无法正确验证ArgumentNullException,需移除该调用。
  • 若需支持异步方法,需重载测试方法,使用Assert.ThrowsAsync替代Assert.Throws。

内容的提问来源于stack exchange,提问作者Marcel Lorenz

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.09 08:05:14