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

