HowTo-测试WPF应用-XUnit
问题
- 积累了不少wpf业务控件,都是通过Demo程序测试和调试功能。
- 随着控件的数量和复杂度增加,想通过单元测试来降低复杂度(fix bug后,自动测试bug是否改正,是否引入新bug)
思路
- 查看开源项目如何测试的: MahApps.Metro
- 细节如下
- 所有测试之前实例化
Start 一个单独的STA线程作为UI线程, 在其中初始化Application
- 测试时通过
await TestHost.SwitchToAppThread(); wrap 后续代码在ui线程执行.
关键代码
所有测试前Start一个单独的Ui线程并初始化Application
ApplicationFixture.cs
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Windows;
using System.Windows.Threading;
using Xunit;
namespace MahApps.Metro.Tests.TestHelpers
{
public class ApplicationFixture : IDisposable
{
public ApplicationFixture()
{
// ... initialize
TestHost.Initialize();
}
public void Dispose()
{
// ... clean up
GC.Collect();
Dispatcher.ExitAllFrames();
Application.Current.Dispatcher.Invoke(Application.Current.Shutdown);
}
}
// 定义测试集合 ApplicationFixtureCollection 的共享上下文由 ApplicationFixture 维护
[CollectionDefinition("ApplicationFixtureCollection")]
public class ApplicationFixtureCollectionClass : ICollectionFixture<ApplicationFixture>
{
}
}
TestHost.cs
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace MahApps.Metro.Tests.TestHelpers
{
/// <summary>
/// This class is the ultimate hack to work around that we can't
/// create more than one application in the same AppDomain
///
/// It is initialized once at startup and is never properly cleaned up,
/// this means the AppDomain will throw an exception when xUnit unloads it.
///
/// Your test runner will inevitably hate you and hang endlessly after every test has run.
/// The Resharper runner will also throw an exception message in your face.
///
/// Better than no unit tests.
/// </summary>
public class TestHost
{
/*
线程同步
AutoResetEvent
AutoResetEvent 用途: 在一个等待线程收到信号时自动重置
set()
发送一个信号,允许一个调用waitone而等待线程继续执行。
有线程收到信号后,马上自动调用reset()
reset() 使因为调用waitone() 而等待线程都进入阻塞状态.
waitOne() 阻塞当前线程,直到收到信号
*/
private TestApp? app;
private readonly Thread? appThread;
private readonly AutoResetEvent gate = new(false);
private static TestHost? testHost;
public static void Initialize()
{
testHost ??= new TestHost();
}
private TestHost()
{
// 启动一个STA线程
this.appThread = new Thread(this.StartDispatcher);
this.appThread.SetApartmentState(ApartmentState.STA);
this.appThread.Start();
// 通过 AutoResetEvent 线程同步, 等待线程的wpf环境初始化完毕
this.gate.WaitOne();
}
private void StartDispatcher()
{
// 手动初始化 Application 需要明确关闭,用于测试
this.app = new TestApp { ShutdownMode = ShutdownMode.OnExplicitShutdown };
// InitializeComponent 加载组件的已编译页面。
// 这里实质就是加载 TestApp.xaml
this.app.InitializeComponent();
this.app.Exit += (_, _) =>
{
var message = $"Exit TestApp with Thread.CurrentThread: {Thread.CurrentThread.ManagedThreadId}" +
$" and Current.Dispatcher.Thread: {Application.Current.Dispatcher.Thread.ManagedThreadId}";
Debug.WriteLine(message);
};
this.app.Startup += async (_, _) =>
{
var message = $"Start TestApp with Thread.CurrentThread: {Thread.CurrentThread.ManagedThreadId}" +
$" and Current.Dispatcher.Thread: {Application.Current.Dispatcher.Thread.ManagedThreadId}";
Debug.WriteLine(message);
// 同时测试程序主线程, wpf线程准备ok
this.gate.Set();
/*
简单基础:
1. await 暂停当前方法的执行并 yield control 给 async 方法的调用者,直到 Task<TResult> 代表的异步任务执行完毕。
await 时会释放当前线程,等所 await 的 Task 完成时会从线程池中申请新的线程继续执行 await 之后的代码
2. Task.Yield 简单来说就是创建时就已经完成的 Task ,或者说执行时间为0的 Task ,或者说是空任务,也就是在创建时就将 Task 的 IsCompeted 值设置为0
这里分析:
1. 将控制权给async方法的调用者,这里是ui线程。
2. 将后续的代码申请新线程执行,可是后续没有代码。在这里 看上去 await Task.Yield(); 没有价值
3. 但如果注释掉该语句,报错
此异步方法缺少 "await" 运算符,将以同步方式运行。
请考虑使用 "await" 运算符等待非阻止的 API 调用,
或者使用 "await Task.Run(...)" 在后台线程上执行占用大量 CPU 的工作。
*/
await Task.Yield();
};
// 启动wpf Application
this.app.Run();
}
/// <summary>
/// Await this method in every test that should run on the UI thread.
/// </summary>
public static SwitchContextToUiThreadAwaiter SwitchToAppThread()
{
if (testHost?.app is null)
{
throw new InvalidOperationException($"{nameof(TestHost)} is not initialized!");
}
return new SwitchContextToUiThreadAwaiter(testHost.app.Dispatcher);
}
}
}
在Ui线程运行测试
SwitchContextToUiThreadAwaiter.cs
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.CompilerServices;
using System.Windows.Threading;
namespace MahApps.Metro.Tests.TestHelpers
{
public class SwitchContextToUiThreadAwaiter : INotifyCompletion
{
private readonly Dispatcher uiContext;
public SwitchContextToUiThreadAwaiter(Dispatcher uiContext)
{
this.uiContext = uiContext;
}
public SwitchContextToUiThreadAwaiter GetAwaiter()
{
return this;
}
public bool IsCompleted => false;
// 把 await TestHost.SwitchToAppThread(); 后面的代码 wrap 到ui线程执行
public void OnCompleted(Action continuation)
{
this.uiContext.Invoke(new Action(continuation));
}
public void GetResult()
{
}
}
}
具体的测试
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Threading.Tasks;
using MahApps.Metro.Controls;
using MahApps.Metro.Tests.TestHelpers;
using MahApps.Metro.Tests.Views;
using Xunit;
namespace MahApps.Metro.Tests.Tests
{
public class AutoWatermarkTest : AutomationTestBase
{
[Fact]
[DisplayTestMethodName]
public async Task TestAutoWatermark()
{
// 后续代码在Ui线程执行
await TestHost.SwitchToAppThread();
// Configurewait(flase),则不进行线程上下文信息的捕获,获得更好的性能.
var window = await WindowHelpers.CreateInvisibleWindowAsync<AutoWatermarkTestWindow>().ConfigureAwait(false);
// 在ui线程执行
window.Invoke(() =>
{
var autoWatermark = "AutoWatermark";
Assert.Equal(autoWatermark, window.TestTextBox.GetValue(TextBoxHelper.WatermarkProperty));
Assert.Equal(autoWatermark, window.TestTextBoxSubModel.GetValue(TextBoxHelper.WatermarkProperty));
Assert.Equal(autoWatermark, window.TestComboBox.GetValue(TextBoxHelper.WatermarkProperty));
Assert.Equal(autoWatermark, window.TestNumericUpDown.GetValue(TextBoxHelper.WatermarkProperty));
Assert.Equal(autoWatermark, window.TestDatePicker.GetValue(TextBoxHelper.WatermarkProperty));
Assert.Equal(autoWatermark, window.TestHotKeyBox.GetValue(TextBoxHelper.WatermarkProperty));
});
}
}
}