HowTo-测试WPF应用-XUnit

HowTo-测试WPF应用-XUnit

问题

  1. 积累了不少wpf业务控件,都是通过Demo程序测试和调试功能。
  2. 随着控件的数量和复杂度增加,想通过单元测试来降低复杂度(fix bug后,自动测试bug是否改正,是否引入新bug)

思路

  1. 查看开源项目如何测试的: MahApps.Metro
  2. 细节如下
    1. 所有测试之前实例化 Start 一个单独的STA线程作为UI线程, 在其中初始化Application
    2. 测试时通过 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));
                });
        }
    }
}
posted @ 2022-05-28 11:34  colin_xia  阅读(352)  评论(0)    收藏  举报