Wednesday, January 5, 2011

Unit Testing Worker Threads and Wpf Dispatcher Code

I was recently asked to write an example of how to test a method that makes use of the thread pool. (.NET4 you would use the Task.Factory, but the solution is still the same.) 

Consider the following methods on a class that requires tests to be written (some inconsequential class code is omitted):

public class PostController {
public void LoadData() {
    // Queue a work item to fetch the expensive data
    // This is generally the best way to use background threads. 
    // The JIT process determines how many threads are best
    // for a certain CPU.
    this.State = ModelState.Fetching;
    if (!ThreadPool.QueueUserWorkItem(objectState => this.ExpensiveFetchPostCallback())) {
        this.State = ModelState.Invalid;
        throw new InvalidOperationException("something went wrong...");
    }
}

private void ExpensiveFetchPostCallback() {
    var fetchedPost = this.DataProvider.GetPost(this.PostId);
           
    // Synchronise the call to the UI onto the UI thread thru the WPF dispatcher.
    this.Dispatcher.BeginInvoke(
        () => {
            this.PostContent = fetchedPost;
            this.State = ModelState.Active;
        },
        DispatcherPriority.Normal);
}
}
The target method to test is the public LoadData method.

Here's my nunit test:
[Test]
        public void LoadDataTest() {
            var controller2 = new PostController("TestDataHere") { State = ModelState.Fetching };
            var mockRepository = new MockRepository();
            var accessor2 = new PostController_Accessor(controller2);
            var mockedService = mockRepository.StrictMock<IGetPostServiceProxy>();

            mockedService.Expect(service => service.GetPost("TestDataHere"))
                .Return("Here is a test return value.")
                .Repeat.Once();
            mockRepository.ReplayAll(); // Initialise all mocks
            accessor2.dataProvider = mockedService;

            controller2.LoadData();
            int waitTime = 200, totalWaitTime = 0;
            while (controller2.State == ModelState.Fetching) {
                DispatcherHelper.DoEvents();
                Thread.Sleep(waitTime);
                totalWaitTime += waitTime;
                if (totalWaitTime > 2000) {
                    break;
                }
            }
            
            DispatcherHelper.DoEvents();
            Assert.AreEqual(ModelState.Active, controller2.State);
            mockRepository.VerifyAll();
        }
This test makes use of a couple of utilities I have written: a PrivateAccess Generator and a DispatcherHelper.
For more information on generating PrivateAccessors see this article.  The DispatcherHelper is intended to kickstart the Dispatcher, because outside WPF the Dispatcher pump that processes the Dispatcher queue is not running.  The DispatcherHelper rotates the pump once and returns. Here's the DispatcherHelper code:

namespace ReesTestToolkit {
    using System;
    using System.Diagnostics;
    using System.Windows.Threading;
    
    /// <summary>
    /// The code in this class is based on examples found in Sheva's TechSpace.
    /// URL: http://shevaspace.spaces.live.com/blog/cns!FD9A0F1F8DD06954!411.entry
    /// </summary>
    public static class DispatcherHelper {
        private static readonly DispatcherOperationCallback ExitFrameCallback = ExitFrame;

        /// <summary>
        /// Processes all UI messages currently in the message queue.
        /// </summary>
        public static void DoEvents() {
            // Create new nested message pump.
            var nestedFrame = new DispatcherFrame();

            // Dispatch a callback to the current message queue, when getting called, 
            // this callback will end the nested message loop.
            // note that the priority of this callback should be lower than the that of UI event messages.
            DispatcherOperation exitOperation = Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background, ExitFrameCallback, nestedFrame);

            // pump the nested message loop, the nested message loop will immediately 
            // process the messages left inside the message queue.
            Dispatcher.PushFrame(nestedFrame);

            // If the "exitFrame" callback doesn't get finished, Abort it.
            if (exitOperation.Status != DispatcherOperationStatus.Completed) {
                exitOperation.Abort();
            }
        }

        /// <summary>
        /// Processes all UI messages currently in the message queue.
        /// </summary>
        /// <param name="dispatcher">The dispatcher onto which to push a new frame.</param>
        public static void DoEvents(Dispatcher dispatcher) {
            // Create new nested message pump.
            var nestedFrame = new DispatcherFrame();
            Debug.Assert(dispatcher == nestedFrame.Dispatcher, "Multiple dispatchers are running");

            // Dispatch a callback to the current message queue, when getting called, 
            // this callback will end the nested message loop.
            // note that the priority of this callback should be lower than the that of UI event messages.
            DispatcherOperation exitOperation = dispatcher.BeginInvoke(DispatcherPriority.Background, ExitFrameCallback, nestedFrame);

            // pump the nested message loop, the nested message loop will immediately 
            // process the messages left inside the message queue.
            Dispatcher.PushFrame(nestedFrame);

            // If the "exitFrame" callback doesn't get finished, Abort it.
            if (exitOperation.Status != DispatcherOperationStatus.Completed) {
                exitOperation.Abort();
            }
        }

        private static object ExitFrame(object state) {
            var frame = state as DispatcherFrame;
            if (frame == null) {
                throw new ArgumentOutOfRangeException("state parameter is of the wrong type.");
            }

            // Exit the nested message loop.
            frame.Continue = false;
            return null;
        }
    }
}

Done.

1 comment: