To test the functional part of our code with unit tests we have to exclude the concurrency and synchronization aspects. These aspects are system-wide concerns and have to be tested later separately with integration tests. Integration tests can for example put the system under load to uncover synchronisation problems between multiple threads.
But first we have to assure that the functional part of our code works as expected. We have to equip our code to be able to isolate the concurrency and synchronization aspects. Then we can write Unit Tests which are executing our functionality on the same thread as the unit test.
So for Unit Tests in conjunction with multithreaded code we can summarize:
- Multithreaded code has to be equipped for isolating concurrency and synchronization aspects from functional aspects.
- Unit Tests can only test the functional aspect of multithreaded code. They can not test concurrency and synchronization aspects of the code, because these are concerns outreaching a single unit.
[Test] public void ShouldUpdateTheStatusWhenDoAsync() { // Arrange DeterministicTaskScheduler taskScheduler = new DeterministicTaskScheduler(); SystemUnderTest sut = new SystemUnderTest(taskScheduler); Assert.AreEqual("Nothing done", sut.Status); // Act sut.DoAsync(); // starts a new task and returns immediately Assert.AreEqual("Starting task", sut.Status); // Now execute the new task taskScheduler.RunTasksUntilIdle(); // Assert Assert.AreEqual("Task done", sut.Status); }
Our code which starts a new task has to provide dependency injection capability for the TaskScheduler. We start a new task in DoAsync() via Task.Factory.StartNew():
public class SystemUnderTest { private string status; private TaskScheduler taskScheduler; public SystemUnderTest(TaskScheduler taskScheduler) { status = "Nothing done"; this.taskScheduler = taskScheduler; } public string Status {get { return status;}} public void DoAsync() { status = "Starting task"; Task.Factory.StartNew( DoWork1, new CancellationToken(), TaskCreationOptions.None, taskScheduler); } private void DoWork1() { // Do some work here status = "Task done"; } }
The DeterministicTaskScheduler to be used in unit tests:
////// TaskScheduker for executing tasks on the same thread that calls RunTasksUntilIdle() or RunPendingTasks() /// public class DeterministicTaskScheduler : TaskScheduler { private List<Task> scheduledTasks = new List<Task>(); #region TaskScheduler methods protected override void QueueTask(Task task) { scheduledTasks.Add(task); } protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) { scheduledTasks.Add(task); return false; } protected override IEnumerable<Task> GetScheduledTasks() { return scheduledTasks; } public override int MaximumConcurrencyLevel { get { return 1; } } #endregion ////// Executes the scheduled Tasks synchronously on the current thread. /// If those tasks schedule new tasks they will also be executed /// until no pending tasks are left. /// public void RunTasksUntilIdle() { while (scheduledTasks.Any()) { this.RunPendingTasks(); } } ////// Executes the scheduled Tasks synchronously on the current thread. /// If those tasks schedule new tasks they will only be executed /// with the next call to RunTasksUntilIdle() or RunPendingTasks(). /// public void RunPendingTasks() { foreach (var task in scheduledTasks.ToArray()) { this.TryExecuteTask(task); scheduledTasks.Remove(task); } } }
In the client production code we can inject a real TaskScheduler from the current context to our class:
SystemUnderTest sut = new SystemUnderTest( TaskScheduler.FromCurrentSynchronizationContext()); sut.DoAsync();
See also my MSDN Magazine article for general remarks about unit testing multithreaded code.