[WPF] 在單元測試中使用 Prism 的 EventAggregator,訂閱到 ThreadOption.UIThread 會報錯

1. 問題

[TestClass]
public class UnitTest1
{
    [TestMethod]
    public void TestMethod1()
    {
        ContainerLocator.Container.Resolve<TestViewModel>();
    }
}

public class TestViewModel
{
    public TestViewModel(IEventAggregator eventAggregator)
    {
        var testEvent = eventAggregator.GetEvent<TestEvent>();
        testEvent.Subscribe(() => { }, ThreadOption.UIThread);
    }
}

public class TestEvent : PubSubEvent
{

}

上面是一段使用了 Prism 的單元測試,它主要的邏輯是在 EventAggregator 中訂閱了 TestEvent,當接收到消息後在 UI 執行緒上執行後續的邏輯。這種程式碼在正常程式中沒有問題,但在單元測試中會報錯:

System.InvalidOperationException: To use the UIThread option for subscribing, the EventAggregator must be constructed on the UI thread.

2. 原因

翻翻源碼,可以發現這個 Exception 在 PubSubEventSubscribe 函數中拋出:

switch (threadOption)
{
    case ThreadOption.PublisherThread:
        subscription = new EventSubscription(actionReference);
        break;
    case ThreadOption.BackgroundThread:
        subscription = new BackgroundEventSubscription(actionReference);
        break;
    case ThreadOption.UIThread:
        if (SynchronizationContext == null) throw new InvalidOperationException(Resources.EventAggregatorNotConstructedOnUIThread);
        subscription = new DispatcherEventSubscription(actionReference, SynchronizationContext);
        break;
    default:
        subscription = new EventSubscription(actionReference);
        break;

SynchronizationContext 為 null 時就會判斷當前不在 UI 執行緒,然後拋出 Exception。而 SynchronizationContext 又是在 EventAggregator 中賦值:

private readonly SynchronizationContext syncContext = SynchronizationContext.Current;

public TEventType GetEvent<TEventType>() where TEventType : EventBase, new()
{
    lock (events)
    {
        EventBase existingEvent = null;

        if (!events.TryGetValue(typeof(TEventType), out existingEvent))
        {
            TEventType newEvent = new TEventType();
            newEvent.SynchronizationContext = syncContext;
            events[typeof(TEventType)] = newEvent;

            return newEvent;
        }
        else
        {
            return (TEventType)existingEvent;
        }
    }
}

問題就出在 SynchronizationContext.Current 這裡。這個屬性用於獲取當前執行緒的同步上下文。不是每一個執行緒都有一個 SynchronizationContext 對象。一個總是有 SynchronizationContext 對象的是UI執行緒。由於單元測試並不是運行在 UI 執行緒,所以這個屬性在單元測試中一直為 null。

3. 解決方案

現在我們知道問題原因了,解決方案也很簡單,只要自定義一個 EventAggregator,源碼全部照抄,但是把這句:

private readonly SynchronizationContext syncContext = SynchronizationContext.Current;

替換成這句:

private readonly SynchronizationContext syncContext = new SynchronizationContext();

就不會出現 PubSubEvent 中 SynchronizationContext 等於 null 的情況了。然後再把這個類註冊到容器中作為 IEventAggregator:

ContainerLocator.Current.RegisterSingleton<IEventAggregator, MyEventAggregator>();

4. 最後

根據單元測試項目的結構,容器的初始化會有不同的方式,如果想盡量模仿 PrismApplication 的話可以參考 PrismApplicationBasePrismInitializationExtensions 寫一個初始化類,大概差不多這樣(簡化了部分程式碼):

[TestClass]
public abstract class TestInitializerBase
{
    public void Initialize()
    {
        ContainerLocator.SetContainerExtension(() => new UnityContainerExtension());
        ContainerExtension = ContainerLocator.Current;

        ContainerExtension.RegisterSingleton<IDialogService, DialogService>();
        ContainerExtension.RegisterSingleton<IModuleInitializer, ModuleInitializer>();
        ContainerExtension.RegisterSingleton<IModuleManager, ModuleManager>();
        ContainerExtension.RegisterSingleton<RegionAdapterMappings>();
        ContainerExtension.RegisterSingleton<IRegionManager, RegionManager>();
        ContainerExtension.RegisterSingleton<IRegionNavigationContentLoader, RegionNavigationContentLoader>();

        ContainerExtension.RegisterSingleton<IEventAggregator, EventAggregator>();

        ContainerExtension.RegisterSingleton<IRegionViewRegistry, RegionViewRegistry>();
        ContainerExtension.RegisterSingleton<IRegionBehaviorFactory, RegionBehaviorFactory>();
        ContainerExtension.Register<IRegionNavigationJournalEntry, RegionNavigationJournalEntry>();
        ContainerExtension.Register<IRegionNavigationJournal, RegionNavigationJournal>();
        ContainerExtension.Register<IRegionNavigationService, RegionNavigationService>();

      
        RegisterRequiredTypes(ContainerExtension);

    }

    public IContainerExtension ContainerExtension { get; private set; }

    protected abstract void RegisterRequiredTypes(IContainerRegistry containerRegistry);
}

public class TestInitializer : TestInitializerBase
{
    [AssemblyInitialize]
    public static void InitializeAseemble(TestContext testContext)
    {
        var testInitializer = new TestInitializer();
        testInitializer.Initialize();
    }

    protected override void RegisterRequiredTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.RegisterSingleton<IEventAggregator, MyEventAggregator>();
    }
}

這樣在 TestInitializer 中可以註冊各種方便單元測試的偽對象。