如何在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
相关产品推荐
相关产品推荐

