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

如何测试WPF自定义控件中Storyboard启动及解决Completed事件未触发问题

自定义WPF控件Storyboard触发行为的xUnit测试问题解决

问题背景

实现了一个自定义WPF控件BaseAnimatedContentControl,通过依赖属性注入Storyboard,当控件Content变化时会调用Storyboard.Begin()方法,实际运行正常。但编写xUnit测试时遇到两个问题:

  • Storyboard.Begin()是不可重写方法,无法用Mock<Storyboard>直接验证调用
  • 尝试通过绑定Storyboard.Completed事件+TaskCompletionSource间接验证时,调试可见Begin()被调用,但Completed事件始终不触发,测试卡在等待环节

测试代码

[StaFact]
public async Task TestContentChangedTriggersStoryboard()
{
    // 构建测试用Storyboard:
    var storyboard = new Storyboard { Duration = new Duration(TimeSpan.FromMilliseconds(100)) };
    var dummyAnimation = new DoubleAnimation(0d, 1d, new Duration(TimeSpan.FromMilliseconds(50)));
    Storyboard.SetTargetProperty(dummyAnimation, new PropertyPath(FrameworkElement.TagProperty));
    storyboard.Children.Add(dummyAnimation);

    // 设置Storyboard运行跟踪方式
    var completionSource = new TaskCompletionSource<bool>();
    storyboard.Completed += (s, e) => completionSource.SetResult(true);
    storyboard.Freeze();

    // 初始化被测控件并分配测试用Storyboard:
    var sut = new BaseAnimatedContentControl { Content = "Blah", Storyboard = storyboard };

    // 设置视觉容器以确保控件模板正确初始化:
    var root = new Popup();
    root.BeginInit();
    root.Child = sut;
    root.EndInit();
    root.IsOpen = true;

    // 验证初始状态:
    Assert.Null(sut.OldContent);
    Assert.Equal("Blah", sut.Content);
    Assert.Null(sut.Tag);

    sut.Content = "Blubb"; // <- 此行触发启动Storyboard的代码
    
    // 验证Storyboard被调用并等待其完成(测试卡在此处)
    bool completed = await completionSource.Task;
    Assert.True(completed);

    // 验证被测代码的效果:
    Assert.Equal("Blah", sut.OldContent);
    Assert.Equal("Blubb", sut.Content);
    Assert.Equal(1, sut.Tag);
}

控件实现

public class BaseAnimatedContentControl : ContentControl
{
    static BaseAnimatedContentControl()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(BaseAnimatedContentControl), new FrameworkPropertyMetadata(typeof(BaseAnimatedContentControl)));
    }

    public object? OldContent
    {
        get => GetValue(OldContentProperty);
        protected set => SetValue(OldContentPropertyKey, value);
    }
    private static readonly DependencyPropertyKey OldContentPropertyKey =
        DependencyProperty.RegisterReadOnly(nameof(OldContent), typeof(object), typeof(BaseAnimatedContentControl), new PropertyMetadata(null));
    public static readonly DependencyProperty OldContentProperty = OldContentPropertyKey.DependencyProperty;

    public Storyboard? Storyboard { get => (Storyboard?)GetValue(StoryboardProperty); set => SetValue(StoryboardProperty, value); }
    public static readonly DependencyProperty StoryboardProperty =
        DependencyProperty.Register(nameof(Storyboard), typeof(Storyboard), typeof(BaseAnimatedContentControl), new PropertyMetadata(null));

    protected override void OnContentChanged(object oldContent, object newContent)
    {
        if (Storyboard != null && (oldContent != null || OldContent != null) && oldContent != newContent)
        {
            OldContent = oldContent;
            Storyboard.Begin(this, Template);
        }
        base.OnContentChanged(oldContent, newContent);
    }
}

解决方案:修复Completed事件不触发的问题

1. 手动推进Dispatcher消息循环

WPF动画的运行、事件触发依赖Dispatcher消息循环,xUnit测试环境不会自动处理消息队列,需要手动推进:

修改测试中等待Completed事件的部分,添加Dispatcher消息循环处理逻辑:

sut.Content = "Blubb";

// 手动推进Dispatcher消息循环,让动画执行并触发Completed事件
var frame = new DispatcherFrame();
_ = Task.Run(async () =>
{
    // 等待足够时间确保动画完成(可根据动画时长调整)
    await Task.Delay(200);
    frame.Continue = false;
});
Dispatcher.PushFrame(frame);

// 此时再验证Completed状态
bool completed = completionSource.Task.IsCompletedSuccessfully;
Assert.True(completed);

2. 确保控件模板正确加载

调用Storyboard.Begin(this, Template)时,若控件模板未加载完成,动画无法正确绑定目标属性。可在Popup打开后强制加载模板:

root.IsOpen = true;
// 强制加载控件模板
sut.ApplyTemplate();
// 等待模板加载完成(可选,确保可视化树初始化)
Dispatcher.CurrentDispatcher.Invoke(() => {}, DispatcherPriority.Loaded);

3. 移除Storyboard的Freeze操作(可选)

虽然Freeze()能提升性能,但在测试环境中可能影响事件触发逻辑,可尝试移除storyboard.Freeze()再测试。

替代验证思路:避免依赖动画实际运行

1. 封装可Mock的Storyboard接口

由于Storyboard.Begin()不可重写,可通过包装类抽象出接口,让控件依赖抽象而非具体实现,测试时直接Mock验证调用:

定义接口与包装类

public interface IAnimatedStoryboard
{
    void Begin(FrameworkElement target, FrameworkTemplate template);
    event EventHandler Completed;
}

public class StoryboardAdapter : IAnimatedStoryboard
{
    private readonly Storyboard _innerStoryboard;

    public StoryboardAdapter(Storyboard storyboard)
    {
        _innerStoryboard = storyboard;
        _innerStoryboard.Completed += (s, e) => Completed?.Invoke(this, e);
    }

    public void Begin(FrameworkElement target, FrameworkTemplate template)
    {
        _innerStoryboard.Begin(target, template);
    }

    public event EventHandler? Completed;
}

修改控件依赖

将控件中的Storyboard依赖替换为接口:

public class BaseAnimatedContentControl : ContentControl
{
    // ... 其他原有代码 ...

    public IAnimatedStoryboard? AnimatedStoryboard 
    { 
        get => (IAnimatedStoryboard?)GetValue(AnimatedStoryboardProperty); 
        set => SetValue(AnimatedStoryboardProperty, value); 
    }
    public static readonly DependencyProperty AnimatedStoryboardProperty =
        DependencyProperty.Register(nameof(AnimatedStoryboard), typeof(IAnimatedStoryboard), typeof(BaseAnimatedContentControl), new PropertyMetadata(null));

    protected override void OnContentChanged(object oldContent, object newContent)
    {
        if (AnimatedStoryboard != null && (oldContent != null || OldContent != null) && oldContent != newContent)
        {
            OldContent = oldContent;
            AnimatedStoryboard.Begin(this, Template);
        }
        base.OnContentChanged(oldContent, newContent);
    }
}

测试时Mock验证

[StaFact]
public void TestContentChangedTriggersStoryboard()
{
    // Mock抽象接口
    var mockStoryboard = new Mock<IAnimatedStoryboard>();
    
    var sut = new BaseAnimatedContentControl 
    { 
        Content = "Blah", 
        AnimatedStoryboard = mockStoryboard.Object 
    };

    var root = new Popup { Child = sut, IsOpen = true };
    sut.ApplyTemplate();

    // 触发Content变化
    sut.Content = "Blubb";

    // 直接验证Begin方法是否被调用
    mockStoryboard.Verify(s => s.Begin(sut, sut.Template), Times.Once);
    // 验证OldContent等状态
    Assert.Equal("Blah", sut.OldContent);
}

2. 直接验证动画对目标属性的修改

若无需验证Completed事件,可在推进Dispatcher后直接检查动画修改的属性(如Tag):

sut.Content = "Blubb";

// 推进消息循环
Dispatcher.CurrentDispatcher.InvokeAsync(() => {}, DispatcherPriority.ApplicationIdle).Wait();

// 验证动画效果
Assert.Equal(1.0, sut.Tag);

内容的提问来源于stack exchange,提问作者Oliver Giesen

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.17 12:07:10