使用反射在C#中进行单元测试以检查事件是否被触发

13

我想测试设置某个属性(或更一般的说,执行一些代码)是否会在我的对象上触发某个事件。在这方面,我的问题类似于在C#中单元测试引发事件的问题,但是我需要很多这样的测试而且我讨厌样板文件。因此,我正在寻找一种更通用的解决方案,使用反射。

理想情况下,我想这样做:

[TestMethod]
public void TestWidth() {
    MyClass myObject = new MyClass();
    AssertRaisesEvent(() => { myObject.Width = 42; }, myObject, "WidthChanged");
}

为了实现AssertRaisesEvent,我已经做到了这一步:

private void AssertRaisesEvent(Action action, object obj, string eventName)
{
    EventInfo eventInfo = obj.GetType().GetEvent(eventName);
    int raisedCount = 0;
    Action incrementer = () => { ++raisedCount; };
    Delegate handler = /* what goes here? */;

    eventInfo.AddEventHandler(obj, handler);
    action.Invoke();
    eventInfo.RemoveEventHandler(obj, handler);

    Assert.AreEqual(1, raisedCount);
}

正如你所看到的,我的问题在于创建适合此事件的委托。该委托除了调用incrementer之外不应执行任何操作。

由于C#中存在大量的语法糖,导致我对委托和事件的真实工作原理的概念有些模糊。这也是我第一次尝试反射。缺少什么部分?

5个回答

9
我最近撰写了一系列关于单元测试同步和异步事件发布对象的博客文章。这些文章描述了一种单元测试方法和框架,并提供了带有测试的完整源代码。
我描述了一个“事件监视器”的实现,它允许更清晰地编写事件序列单元测试,即摆脱所有混乱的样板代码。
使用我文章中描述的事件监视器,可以像这样编写测试:
var publisher = new AsyncEventPublisher();

Action test = () =>
{
    publisher.RaiseA();
    publisher.RaiseB();
    publisher.RaiseC();
};

var expectedSequence = new[] { "EventA", "EventB", "EventC" };

EventMonitor.Assert(publisher, test, expectedSequence);

或者对于实现了INotifyPropertyChanged接口的类型:

var publisher = new PropertyChangedEventPublisher();

Action test = () =>
{
    publisher.X = 1;
    publisher.Y = 2;
};

var expectedSequence = new[] { "X", "Y" };

EventMonitor.Assert(publisher, test, expectedSequence);

对于原始问题中的情况:

MyClass myObject = new MyClass();
EventMonitor.Assert(myObject, () => { myObject.Width = 42; }, "Width");

EventMonitor会完成所有的重活,运行测试(操作)并断言事件按预期序列(expectedSequence)被触发。它还会在测试失败时打印出好的诊断信息。反射和IL在内部使用以使动态事件订阅工作,但这一切都被很好地封装起来,因此只需要像上面的代码一样编写事件测试即可。
在描述问题和方法以及源代码方面有很多细节,请参考文章。

http://gojisoft.com/blog/2010/04/22/event-sequence-unit-testing-part-1/


太棒了!如果这不能回答问题,那就没有什么能回答了。 - Thomas
嗨,Thomas,重新阅读原始问题后更新了代码示例。我调整了EventMonitor的API,使其更加简洁。干杯。 :) - Tim Lloyd
非常不错,但它甚至无法处理你在博客中声称的“任意事件”。然而,在野外发生的事件中,可能有99%都遵循微软的建议,因此缺乏通用性在实践中应该不是一个大问题。 - Ben Voigt
我认为我理解你的意思,但我不会说它“甚至一点也不接近” - 它将覆盖大约99%(可能是99.99%)的内容。我将使用MSIL更新源代码,以管理ref和out参数混淆(如果这就是你的意思),因为我在其他地方有它。我觉得博客文章有点臃肿了!但为了正确性,它至少应该在源代码中...谢谢。 - Tim Lloyd
2
那个源代码还可用吗?我想看看能否用它来测试事件处理程序 :) - ahaaman

8

使用Lambda表达式,你只需要很少的代码就能完成这个操作。只需将lambda分配给事件,并在处理程序中设置一个值即可。无需反射,而且你还能获得强类型的重构。

[TestFixture]
public class TestClass
{
    [Test]
    public void TestEventRaised()
    {
        // arrange
        var called = false;

        var test = new ObjectUnderTest();
        test.WidthChanged += (sender, args) => called = true;

        // act
        test.Width = 42;

        // assert
        Assert.IsTrue(called);
    }

    private class ObjectUnderTest
    {
        private int _width;
        public event EventHandler WidthChanged;

        public int Width
        {
            get { return _width; }
            set
            {
                _width = value; OnWidthChanged();
            }
        }

        private void OnWidthChanged()
        {
            var handler = WidthChanged;
            if (handler != null)
                handler(this, EventArgs.Empty);
        }
    }
}

是的,我知道。但是仍然需要4行代码,而其实只需要1行就够了。我只是想看看是否可以更通用地解决它。 - Thomas
4
+1 这是一个被低估的解决方案。它易于阅读、理解和调试。它避免了对复杂代码(如MSIL,Reflection等)的依赖,并且可以覆盖几乎所有自定义事件处理程序的边缘情况,或其他任何可能存在的情况。至少应该投票更高。 - HodlDwon

2
你所提出的风格的解决方案要覆盖所有情况将非常难以实现。但如果您愿意接受具有ref和out参数或返回值的委托类型不被覆盖,那么您应该能够使用DynamicMethod。
在设计时,创建一个类来保存计数,我们称之为CallCounter。
在AssertRaisesEvent中:
  • 创建一个CallCounter类的实例,并将其保存在强类型变量中

  • 将计数器初始化为零

  • 在您的计数器类中构建DynamicMethod

    new DynamicMethod(string.Empty, typeof(void), parameter types extracted from the eventInfo, typeof(CallCounter))

  • 获取DynamicMethod的MethodBuilder并使用reflection.Emit添加操作码以增加字段

    • ldarg.0(this指针)
    • ldc_I4_1(常量1)
    • ldarg.0(this指针)
    • ldfld(读取计数的当前值)
    • add
    • stfld(将更新后的计数放回成员变量中)
  • 调用CreateDelegate的两个参数重载, 第一个参数是从eventInfo中提取的事件类型,第二个参数是CallCounter的实例

  • 将生成的委托传递给eventInfo.AddEventHandler(这一步已经完成)

  • 现在您可以执行测试用例了(这一步已经完成)。

  • 最后按照惯例读取计数。

我唯一不确定如何做的步骤是从EventInfo中获取参数类型。你使用EventHandlerType属性,然后呢?那么,在该页面上有一个示例,显示您只需获取委托的Invoke方法的MethodInfo(我猜名称“Invoke”在某个标准中得到保证),然后GetParameters并提取所有ParameterType值,检查一路上是否有ref/out参数。

我相信Marc Gravell会找到一种使用表达式树来避免MethodBuilder的方法,但这基本上是相同的思路。而且,当TDelegate在编译时不被知道时,正确获取Expression<TDelegate>.Compile是非常复杂的。 - Ben Voigt
如果这真的是唯一的方法,我想我会选择使用样板文件。 - Thomas

1
这样怎么样:
private void AssertRaisesEvent(Action action, object obj, string eventName)
    {
        EventInfo eventInfo = obj.GetType().GetEvent(eventName);
        int raisedCount = 0;
        EventHandler handler = new EventHandler((sender, eventArgs) => { ++raisedCount; });
        eventInfo.AddEventHandler(obj, handler );
        action.Invoke();
        eventInfo.RemoveEventHandler(obj, handler);

        Assert.AreEqual(1, raisedCount);
    }

1
只有在声明的事件类型为“EventHandler”时才有效,例如“PropertyChangedEvent”甚至自定义事件类型都不行。 - Thomas

1

这是一个旧的帖子,但我认为它仍然是一个相关的问题,我没有找到一个简单的解决方案。在工作中,我需要做事件测试,由于没有满意的现有解决方案,我决定实现一个小型库。由于我对我的解决方案感到非常满意,因此我决定将其放在GitHub上。也许你也会发现它有用:

https://github.com/f-tischler/EventTesting

这是它的实际效果:

using EventTesting;

// ARRANGE
var myObj = new MyClass();

var hook = EventHook.For(myObj)
    .HookOnly((o, h) => o.WidthChanged+= h);

// ACT
myObj.Width = 42;

// ASSERT
hook.Verify(Called.Once());

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接